diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 19132ecf9fd..140733353f4 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -2,89 +2,83 @@ // For license information, please see license.txt frappe.ui.form.on("Job Card", { - setup: function (frm) { - frm.set_query("operation", function () { - return { - query: "erpnext.manufacturing.doctype.job_card.job_card.get_operations", - filters: { - work_order: frm.doc.work_order, - }, - }; - }); + setup(frm) { + frm.set_query("operation", () => ({ + query: "erpnext.manufacturing.doctype.job_card.job_card.get_operations", + filters: { work_order: frm.doc.work_order }, + })); - frm.set_query("serial_and_batch_bundle", () => { - return { - filters: { - item_code: frm.doc.production_item, - voucher_type: frm.doc.doctype, - voucher_no: ["in", [frm.doc.name, ""]], - is_cancelled: 0, - }, - }; - }); + frm.set_query("serial_and_batch_bundle", () => ({ + filters: { + item_code: frm.doc.production_item, + voucher_type: frm.doc.doctype, + voucher_no: ["in", [frm.doc.name, ""]], + is_cancelled: 0, + }, + })); - frm.set_query("item_code", "secondary_items", () => { - return { - filters: { - disabled: 0, - }, - }; - }); + frm.set_query("item_code", "secondary_items", () => ({ + filters: { disabled: 0 }, + })); frm.set_query("operation", "time_logs", () => { - let operations = (frm.doc.sub_operations || []).map((d) => d.sub_operation); - return { - filters: { - name: ["in", operations], - }, - }; + const operations = (frm.doc.sub_operations || []).map((d) => d.sub_operation); + return { filters: { name: ["in", operations] } }; }); - frm.set_query("work_order", function () { - return { - filters: { - status: ["not in", ["Cancelled", "Closed", "Stopped"]], - }, - }; - }); + frm.set_query("work_order", () => ({ + filters: { status: ["not in", ["Cancelled", "Closed", "Stopped"]] }, + })); frm.events.set_company_filters(frm, "target_warehouse"); frm.events.set_company_filters(frm, "source_warehouse"); frm.events.set_company_filters(frm, "wip_warehouse"); - frm.set_query("source_warehouse", "items", () => { - return { - filters: { - company: frm.doc.company, - }, - }; + + frm.set_query("source_warehouse", "items", () => ({ + filters: { company: frm.doc.company }, + })); + + frm.set_indicator_formatter("sub_operation", (doc) => { + if (doc.status === "Pending") return "red"; + return doc.status === "Complete" ? "green" : "orange"; }); - frm.set_indicator_formatter("sub_operation", function (doc) { - if (doc.status == "Pending") { - return "red"; - } else { - return doc.status === "Complete" ? "green" : "orange"; - } - }); + frm.set_query("employee", () => ({ + filters: { + company: frm.doc.company, + status: "Active", + }, + })); + }, - frm.set_query("employee", () => { - return { - filters: { - company: frm.doc.company, - status: "Active", - }, - }; - }); + pending_qty(frm) { + if (frm.doc.total_completed_qty <= 0.0) { + frm.doc.pending_qty = 0.0; + refresh_field("pending_qty"); + frappe.throw(__("Please complete the job first before entering Pending Quantity")); + } + + if (frm.doc.pending_qty < 0) { + frappe.throw(__("Pending Quantity cannot be less than 0")); + } + + const remaining_qty = flt(frm.doc.for_quantity) - flt(frm.doc.total_completed_qty); + + if (remaining_qty < frm.doc.pending_qty) { + frm.doc.pending_qty = 0.0; + refresh_field("pending_qty"); + frappe.throw(__("Pending Quantity cannot be greater than {0}", [remaining_qty])); + } + + const process_loss_qty = flt(remaining_qty) - flt(frm.doc.pending_qty); + frm.doc.process_loss_qty = process_loss_qty >= 0 ? process_loss_qty : 0; + refresh_field("process_loss_qty"); }, set_company_filters(frm, fieldname) { - frm.set_query(fieldname, () => { - return { - filters: { - company: frm.doc.company, - }, - }; - }); + frm.set_query(fieldname, () => ({ + filters: { company: frm.doc.company }, + })); }, make_fields_read_only(frm) { @@ -99,33 +93,29 @@ frappe.ui.form.on("Job Card", { }, setup_stock_entry(frm) { - if ( - frm.doc.track_semi_finished_goods && - frm.doc.docstatus === 1 && - !frm.doc.is_subcontracted && - (frm.doc.skip_material_transfer || frm.doc.transferred_qty > 0) && - flt(frm.doc.manufactured_qty) + flt(frm.doc.process_loss_qty) < flt(frm.doc.for_quantity) - ) { - frm.add_custom_button(__("Make Stock Entry"), () => { - frappe.confirm( - __("Do you want to submit the stock entry?"), - () => { - frm.events.make_manufacture_stock_entry(frm, 1); - }, - () => { - frm.events.make_manufacture_stock_entry(frm, 0); - } - ); - }).addClass("btn-primary"); - } + const { doc } = frm; + const can_make_stock_entry = + doc.track_semi_finished_goods && + doc.docstatus === 1 && + !doc.is_subcontracted && + (doc.skip_material_transfer || doc.transferred_qty > 0) && + flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < flt(doc.for_quantity); + + if (!can_make_stock_entry) return; + + frm.add_custom_button(__("Make Stock Entry"), () => { + frappe.confirm( + __("Do you want to submit the stock entry?"), + () => frm.events.make_manufacture_stock_entry(frm, 1), + () => frm.events.make_manufacture_stock_entry(frm, 0) + ); + }).addClass("btn-primary"); }, make_manufacture_stock_entry(frm, submit_entry) { frm.call({ method: "make_stock_entry_for_semi_fg_item", - args: { - auto_submit: submit_entry, - }, + args: { auto_submit: submit_entry }, doc: frm.doc, freeze: true, callback() { @@ -134,190 +124,134 @@ frappe.ui.form.on("Job Card", { }); }, - refresh: function (frm) { - frm.trigger("setup_stock_entry"); + refresh(frm) { + const { doc } = frm; + const has_items = doc.items && doc.items.length; + + // Clear any running timer tick from a previous render. + if (frm._jcd_timer_interval) { + clearInterval(frm._jcd_timer_interval); + frm._jcd_timer_interval = null; + } - let has_items = frm.doc.items && frm.doc.items.length; frm.trigger("make_fields_read_only"); - if (!frm.is_new() && frm.doc.__onload?.work_order_closed) { + if (!frm.is_new() && doc.__onload?.work_order_closed) { frm.disable_save(); return; } - if (frm.doc.is_subcontracted) { + if (doc.is_subcontracted) { frm.trigger("make_subcontracting_po"); return; } - let has_stock_entry = frm.doc.__onload && frm.doc.__onload.has_stock_entry ? true : false; + if (doc.docstatus > 0) { + frm.set_df_property("pending_qty", "read_only", 1); + } + const has_stock_entry = !!doc.__onload?.has_stock_entry; frm.toggle_enable("for_quantity", !has_stock_entry); - if (frm.doc.docstatus != 0) { + if (doc.docstatus != 0) { frm.fields_dict["time_logs"].grid.update_docfield_property("completed_qty", "read_only", 1); frm.fields_dict["time_logs"].grid.update_docfield_property("time_in_mins", "read_only", 1); } - if (!frm.is_new() && !frm.doc.skip_material_transfer && frm.doc.docstatus < 2) { - let to_request = frm.doc.for_quantity > frm.doc.transferred_qty; - let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; + frm.events.setup_material_transfer_buttons(frm, has_items); - if (has_items && (to_request || excess_transfer_allowed)) { - frm.add_custom_button( - __("Material Request"), - () => { - frm.trigger("make_material_request"); - }, - __("Create") - ); - } - - // check if any row has untransferred materials - // in case of multiple items in JC - let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty); - - if (has_items && (to_transfer || excess_transfer_allowed)) { - frm.add_custom_button( - __("Material Transfer"), - () => { - frm.trigger("make_stock_entry"); - }, - __("Create") - ); - } - } - - if (frm.doc.docstatus == 1 && !frm.doc.is_corrective_job_card && !frm.doc.finished_good) { + if (doc.docstatus == 1 && !doc.is_corrective_job_card && !doc.finished_good) { frm.trigger("setup_corrective_job_card"); } - frm.set_query("quality_inspection", function () { - return { - query: "erpnext.stock.doctype.quality_inspection.quality_inspection.quality_inspection_query", - filters: { - item_code: frm.doc.production_item, - reference_name: frm.doc.name, - }, - }; - }); + frm.set_query("quality_inspection", () => ({ + query: "erpnext.stock.doctype.quality_inspection.quality_inspection.quality_inspection_query", + filters: { + item_code: doc.production_item, + reference_name: doc.name, + }, + })); frm.trigger("toggle_operation_number"); - if ( - frm.doc.for_quantity + frm.doc.process_loss_qty > frm.doc.total_completed_qty && - (frm.doc.skip_material_transfer || - frm.doc.transferred_qty >= frm.doc.for_quantity + frm.doc.process_loss_qty || - !frm.doc.finished_good || - !has_items?.length) - ) { - let last_row = {}; - if (frm.doc.sub_operations?.length && frm.doc.time_logs?.length) { - last_row = get_last_row(frm.doc.time_logs); - } + const is_timer_running = frm.events.setup_job_action_buttons(frm, has_items); - if ( - (!frm.doc.time_logs?.length || (frm.doc.sub_operations?.length && last_row?.to_time)) && - !frm.doc.is_paused - ) { - frm.add_custom_button(__("Start Job"), () => { - let from_time = frappe.datetime.now_datetime(); - if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { - frappe.prompt( - { - fieldtype: "Table MultiSelect", - label: __("Select Employees"), - options: "Job Card Time Log", - fieldname: "employees", - reqd: 1, - filters: { - status: "Active", - }, - }, - (d) => { - frm.events.start_timer(frm, from_time, d.employees); - }, - __("Assign Job to Employee") - ); - } else { - frm.events.start_timer(frm, from_time, frm.doc.employee); - } - }); - } else if (frm.doc.is_paused) { - frm.add_custom_button(__("Resume Job"), () => { - frm.call({ - method: "resume_job", - doc: frm.doc, - args: { - start_time: frappe.datetime.now_datetime(), - }, - callback() { - frm.reload_doc(); - }, - }); - }); - } else { - let manufactured_qty = frm.doc.manufactured_qty || frm.doc.total_completed_qty; - if (frm.doc.for_quantity - (manufactured_qty + frm.doc.process_loss_qty) > 0) { - if (!frm.doc.is_paused) { - frm.add_custom_button(__("Pause Job"), () => { - frm.call({ - method: "pause_job", - doc: frm.doc, - args: { - end_time: frappe.datetime.now_datetime(), - }, - callback() { - frm.reload_doc(); - }, - }); - }); - } - - frm.add_custom_button(__("Complete Job"), () => { - frm.trigger("complete_job_card"); - }); - } - - frm.trigger("make_dashboard"); - } + if (!is_timer_running) { + frm.trigger("setup_stock_entry"); } frm.trigger("setup_quality_inspection"); - if (frm.doc.work_order) { - frappe.db.get_value("Work Order", frm.doc.work_order, "transfer_material_against").then((r) => { - if (r.message.transfer_material_against == "Work Order" && !frm.doc.operation_row_id) { + if (doc.work_order) { + frappe.db.get_value("Work Order", doc.work_order, "transfer_material_against").then((r) => { + if (r.message.transfer_material_against == "Work Order" && !doc.operation_row_id) { frm.set_df_property("items", "hidden", 1); } }); } - let sbb_field = frm.get_docfield("serial_and_batch_bundle"); + const sbb_field = frm.get_docfield("serial_and_batch_bundle"); if (sbb_field) { - sbb_field.get_route_options_for_new_doc = () => { - return { - item_code: frm.doc.production_item, - warehouse: frm.doc.wip_warehouse, - voucher_type: frm.doc.doctype, - }; - }; + sbb_field.get_route_options_for_new_doc = () => ({ + item_code: doc.production_item, + warehouse: doc.wip_warehouse, + voucher_type: doc.doctype, + }); } }, + // Adds Material Request and Material Transfer buttons when items need to be transferred. + setup_material_transfer_buttons(frm, has_items) { + const { doc } = frm; + + if (frm.is_new() || doc.skip_material_transfer || doc.docstatus >= 2) return; + + const excess_transfer_allowed = doc.__onload.job_card_excess_transfer; + const to_request = doc.for_quantity > doc.transferred_qty; + + if (has_items && (to_request || excess_transfer_allowed)) { + frm.add_custom_button( + __("Material Request"), + () => frm.trigger("make_material_request"), + __("Create") + ); + } + + // check if any row has untransferred materials in case of multiple items in JC + const to_transfer = doc.items.some((row) => row.transferred_qty < row.required_qty); + + if (has_items && (to_transfer || excess_transfer_allowed)) { + frm.add_custom_button( + __("Material Transfer"), + () => frm.trigger("make_stock_entry"), + __("Create") + ); + } + }, + + // Renders the dashboard widget (info + timer + action buttons) into job_card_dashboard wrapper. + // Returns true if the job timer is actively running, so the caller can skip the stock entry button. + setup_job_action_buttons(frm, has_items) { + return frm.events.make_dashboard(frm, has_items); + }, + complete_job_card(frm) { - let fields = [ + let pending_qty = frm.doc.for_quantity - frm.doc.total_completed_qty; + if (frm.doc.pending_qty > 0) { + pending_qty = frm.doc.pending_qty; + } + + const fields = [ { fieldtype: "Float", label: __("Qty to Manufacture"), fieldname: "for_quantity", reqd: 1, - default: frm.doc.for_quantity, + default: pending_qty, change() { - let doc = frm.job_completion_dialog; - - doc.set_value("completed_qty", doc.get_value("for_quantity")); - doc.set_value("process_loss_qty", 0); + const dialog = frm.job_completion_dialog; + dialog.set_value("completed_qty", dialog.get_value("for_quantity")); + dialog.set_value("process_loss_qty", 0); }, }, { @@ -325,13 +259,28 @@ frappe.ui.form.on("Job Card", { label: __("Completed Quantity"), fieldname: "completed_qty", reqd: 1, - default: frm.doc.for_quantity - frm.doc.total_completed_qty, + default: pending_qty, change() { - let doc = frm.job_completion_dialog; - - let process_loss_qty = doc.get_value("for_quantity") - doc.get_value("completed_qty"); - if (process_loss_qty > 0 && process_loss_qty != doc.get_value("process_loss_qty")) { - doc.set_value("process_loss_qty", process_loss_qty); + const dialog = frm.job_completion_dialog; + const remaining = dialog.get_value("for_quantity") - dialog.get_value("completed_qty"); + if (remaining > 0 && remaining != dialog.get_value("pending_qty")) { + dialog.set_value("pending_qty", remaining); + } + }, + }, + { + fieldtype: "Float", + label: __("Pending Quantity"), + fieldname: "pending_qty", + default: 0.0, + change() { + const dialog = frm.job_completion_dialog; + const process_loss_qty = + dialog.get_value("for_quantity") - + dialog.get_value("completed_qty") - + dialog.get_value("pending_qty"); + if (process_loss_qty >= 0 && process_loss_qty != dialog.get_value("process_loss_qty")) { + dialog.set_value("process_loss_qty", process_loss_qty); } }, }, @@ -340,10 +289,14 @@ frappe.ui.form.on("Job Card", { label: __("Process Loss Quantity"), fieldname: "process_loss_qty", onchange() { - let doc = frm.job_completion_dialog; - - let completed_qty = doc.get_value("for_quantity") - doc.get_value("process_loss_qty"); - doc.set_value("completed_qty", completed_qty); + const dialog = frm.job_completion_dialog; + const remaining = + dialog.get_value("for_quantity") - + dialog.get_value("completed_qty") - + dialog.get_value("process_loss_qty"); + if (remaining >= 0 && remaining != dialog.get_value("pending_qty")) { + dialog.set_value("pending_qty", remaining); + } }, }, { @@ -358,20 +311,16 @@ frappe.ui.form.on("Job Card", { fieldname: "sub_operation", options: "Operation", get_query() { - let non_completed_operations = frm.doc.sub_operations.filter( - (d) => d.status === "Pending" - ); + const non_completed = frm.doc.sub_operations.filter((d) => d.status === "Pending"); return { - filters: { - name: ["in", non_completed_operations.map((d) => d.sub_operation)], - }, + filters: { name: ["in", non_completed.map((d) => d.sub_operation)] }, }; }, reqd: 1, }); } - let last_completed_row = get_last_completed_row(frm.doc.time_logs); + const last_completed_row = get_last_completed_row(frm.doc.time_logs); let last_row = {}; if (frm.doc.sub_operations?.length && frm.doc.time_logs?.length) { last_row = get_last_row(frm.doc.time_logs); @@ -399,10 +348,12 @@ frappe.ui.form.on("Job Card", { args: { qty: data.completed_qty, for_quantity: data.for_quantity, + pending_qty: data.pending_qty, + process_loss_qty: data.process_loss_qty, end_time: data.end_time, sub_operation: data.sub_operation, }, - callback: function (r) { + callback() { frm.reload_doc(); }, }); @@ -428,19 +379,15 @@ frappe.ui.form.on("Job Card", { frm.call({ method: "start_timer", doc: frm.doc, - args: { - start_time: start_time, - employees: employees, - }, - callback: function (r) { + args: { start_time, employees }, + callback() { frm.reload_doc(); - frm.trigger("make_dashboard"); }, }); }, make_finished_good(frm) { - let fields = [ + const fields = [ { fieldtype: "Float", label: __("Completed Quantity"), @@ -466,12 +413,9 @@ frappe.ui.form.on("Job Card", { frm.call({ method: "make_finished_good", doc: frm.doc, - args: { - qty: data.qty, - end_time: data.end_time, - }, - callback: function (r) { - var doc = frappe.model.sync(r.message); + args: { qty: data.qty, end_time: data.end_time }, + callback(r) { + const doc = frappe.model.sync(r.message); frappe.set_route("Form", doc[0].doctype, doc[0].name); }, }); @@ -482,8 +426,8 @@ frappe.ui.form.on("Job Card", { ); }, - setup_quality_inspection: function (frm) { - let quality_inspection_field = frm.get_docfield("quality_inspection"); + setup_quality_inspection(frm) { + const quality_inspection_field = frm.get_docfield("quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function (frm) { return { inspection_type: "In Process", @@ -498,24 +442,22 @@ frappe.ui.form.on("Job Card", { }; }, - setup_corrective_job_card: function (frm) { + setup_corrective_job_card(frm) { frm.add_custom_button( __("Corrective Job Card"), () => { - let operations = frm.doc.sub_operations.map((d) => d.sub_operation).concat(frm.doc.operation); + const operations = frm.doc.sub_operations + .map((d) => d.sub_operation) + .concat(frm.doc.operation); - let fields = [ + const fields = [ { fieldtype: "Link", label: __("Corrective Operation"), options: "Operation", fieldname: "operation", get_query() { - return { - filters: { - is_corrective_operation: 1, - }, - }; + return { filters: { is_corrective_operation: 1 } }; }, }, { @@ -524,20 +466,14 @@ frappe.ui.form.on("Job Card", { options: "Operation", fieldname: "for_operation", get_query() { - return { - filters: { - name: ["in", operations], - }, - }; + return { filters: { name: ["in", operations] } }; }, }, ]; frappe.prompt( fields, - (d) => { - frm.events.make_corrective_job_card(frm, d.operation, d.for_operation); - }, + (d) => frm.events.make_corrective_job_card(frm, d.operation, d.for_operation), __("Select Corrective Operation") ); }, @@ -545,7 +481,7 @@ frappe.ui.form.on("Job Card", { ); }, - make_corrective_job_card: function (frm, operation, for_operation) { + make_corrective_job_card(frm, operation, for_operation) { frappe.call({ method: "erpnext.manufacturing.doctype.job_card.job_card.make_corrective_job_card", args: { @@ -553,7 +489,7 @@ frappe.ui.form.on("Job Card", { operation: operation, for_operation: for_operation, }, - callback: function (r) { + callback(r) { if (r.message) { frappe.model.sync(r.message); frappe.set_route("Form", r.message.doctype, r.message.name); @@ -562,7 +498,7 @@ frappe.ui.form.on("Job Card", { }); }, - operation: function (frm) { + operation(frm) { frm.trigger("toggle_operation_number"); if (frm.doc.operation && frm.doc.work_order) { @@ -572,28 +508,22 @@ frappe.ui.form.on("Job Card", { work_order: frm.doc.work_order, operation: frm.doc.operation, }, - callback: function (r) { - if (r.message) { - if (r.message.length == 1) { - frm.set_value("operation_id", r.message[0].name); - } else { - let args = []; + callback(r) { + if (!r.message) return; - r.message.forEach((row) => { - args.push({ label: row.idx, value: row.name }); - }); - - let description = __("Operation {0} added multiple times in the work order {1}", [ - frm.doc.operation, - frm.doc.work_order, - ]); - - frm.set_df_property("operation_row_number", "options", args); - frm.set_df_property("operation_row_number", "description", description); - } - - frm.trigger("toggle_operation_number"); + if (r.message.length == 1) { + frm.set_value("operation_id", r.message[0].name); + } else { + const args = r.message.map((row) => ({ label: row.idx, value: row.name })); + const description = __("Operation {0} added multiple times in the work order {1}", [ + frm.doc.operation, + frm.doc.work_order, + ]); + frm.set_df_property("operation_row_number", "options", args); + frm.set_df_property("operation_row_number", "description", description); } + + frm.trigger("toggle_operation_number"); }, }); } @@ -610,89 +540,233 @@ frappe.ui.form.on("Job Card", { frm.toggle_reqd("operation_row_number", !frm.doc.operation_id && frm.doc.operation); }, - make_time_log: function (frm, args) { + make_time_log(frm, args) { frm.events.update_sub_operation(frm, args); frappe.call({ method: "erpnext.manufacturing.doctype.job_card.job_card.make_time_log", - args: { - args: args, - }, + args: { args }, freeze: true, - callback: function () { + callback() { frm.reload_doc(); frm.trigger("make_dashboard"); }, }); }, - update_sub_operation: function (frm, args) { - if (frm.doc.sub_operations && frm.doc.sub_operations.length) { - let sub_operations = frm.doc.sub_operations.filter((d) => d.status != "Complete"); - if (sub_operations && sub_operations.length) { - args["sub_operation"] = sub_operations[0].sub_operation; + update_sub_operation(frm, args) { + if (frm.doc.sub_operations?.length) { + const pending_sub_ops = frm.doc.sub_operations.filter((d) => d.status != "Complete"); + if (pending_sub_ops.length) { + args["sub_operation"] = pending_sub_ops[0].sub_operation; } } }, - make_dashboard: function (frm) { - if (frm.doc.__islocal) return; - var section = ""; + make_dashboard(frm, has_items) { + if (frm.doc.__islocal) return false; - function setCurrentIncrement() { - currentIncrement += 1; - return currentIncrement; + frm.dashboard.refresh(); + + // Clear any previously running timer tick before re-rendering. + if (frm._jcd_timer_interval) { + clearInterval(frm._jcd_timer_interval); + frm._jcd_timer_interval = null; } - function updateStopwatch(increment) { - var hours = Math.floor(increment / 3600); - var minutes = Math.floor((increment - hours * 3600) / 60); - var seconds = Math.floor(flt(increment - hours * 3600 - minutes * 60, 2)); + const wrapper = $(frm.fields_dict["job_card_dashboard"].wrapper); + wrapper.empty(); - $(section) - .find(".hours") - .text(hours < 10 ? "0" + hours.toString() : hours.toString()); - $(section) - .find(".minutes") - .text(minutes < 10 ? "0" + minutes.toString() : minutes.toString()); - $(section) - .find(".seconds") - .text(seconds < 10 ? "0" + seconds.toString() : seconds.toString()); + const { doc } = frm; + const { time_logs, status } = doc; + + // ── Determine which action buttons to show ──────────────────────── + const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty; + const materials_ready = + doc.skip_material_transfer || + doc.transferred_qty >= doc.for_quantity + doc.process_loss_qty || + !doc.finished_good || + !has_items?.length; + + let last_row = {}; + const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0; + if (has_sub_ops_or_pending_qty && time_logs?.length) { + last_row = get_last_row(time_logs); } - function initialiseTimer() { - const interval = setInterval(function () { - var current = setCurrentIncrement(); - updateStopwatch(current); + const no_time_logs_yet = !time_logs?.length; + const pending_qty_cycle_done = flt(doc.pending_qty) > 0.0 && last_row?.to_time; + const sub_operation_cycle_done = doc.sub_operations?.length && last_row?.to_time; + const should_show_start = + (no_time_logs_yet || pending_qty_cycle_done || sub_operation_cycle_done) && !doc.is_paused; + + const last_log_complete = time_logs?.length && time_logs[time_logs.length - 1].to_time; + const is_on_hold = status === "On Hold"; + const is_actively_running = !!( + time_logs?.length && + !last_log_complete && + !is_on_hold && + !doc.is_paused + ); + + let show_start = false, + show_pause = false, + show_resume = false, + show_complete = false, + is_timer_running = false; + + if (has_remaining_qty && materials_ready) { + const manufactured_qty = doc.manufactured_qty || doc.total_completed_qty; + const qty_yet_to_manufacture = doc.for_quantity - (manufactured_qty + doc.process_loss_qty); + + if (should_show_start) { + show_start = true; + } else if (doc.is_paused) { + show_resume = true; + } else if (qty_yet_to_manufacture > 0) { + show_pause = true; + show_complete = true; + is_timer_running = true; + } + } + + // ── Timer color reflects job state ──────────────────────────────── + const [timer_color, timer_bg, timer_border] = [ + "var(--gray-600,#6b7280)", + "var(--gray-100,#f3f4f6)", + "var(--gray-300,#d1d5db)", + ]; + + // ── Action button HTML ──────────────────────────────────────────── + const btn = (cls, icon_path, label, icon_color) => ` + `; + + const icons = { + play: { d: '', fill: "currentColor", stroke: "none" }, + pause: { + d: '', + fill: "currentColor", + stroke: "none", + }, + check: { d: '', sw: 3 }, + }; + + const buttons_html = [ + show_start && btn("btn-default jcd-btn-start", "play", __("Start Job")), + show_resume && btn("btn-default jcd-btn-resume", "play", __("Resume Job")), + show_pause && btn("btn-default jcd-btn-pause", "pause", __("Pause Job")), + show_complete && btn("btn-success jcd-btn-complete", "check", __("Complete Job"), "white"), + ] + .filter(Boolean) + .join(""); + + // ── Render widget ───────────────────────────────────────────────── + wrapper.append(` +
+
+
+
+ ${__("Elapsed Time")} +
+
+ ${frappe.utils.icon("clock-4", "md", "", "", "", "", timer_color)} + + 00:00:00 + +
+
+
+ ${buttons_html} +
+
+
`); + + // ── Wire up button click handlers ───────────────────────────────── + if (show_start) { + wrapper.find(".jcd-btn-start").on("click", () => { + const from_time = frappe.datetime.now_datetime(); + const has_no_employee = !frm.doc.employee || !frm.doc.employee.length; + + if (has_no_employee) { + frappe.prompt( + { + fieldtype: "Table MultiSelect", + label: __("Select Employees"), + options: "Job Card Time Log", + fieldname: "employees", + reqd: 1, + filters: { status: "Active" }, + }, + (d) => frm.events.start_timer(frm, from_time, d.employees), + __("Assign Job to Employee") + ); + } else { + frm.events.start_timer(frm, from_time, frm.doc.employee); + } + }); + } + + if (show_resume) { + wrapper.find(".jcd-btn-resume").on("click", () => { + frm.call({ + method: "resume_job", + doc: frm.doc, + args: { start_time: frappe.datetime.now_datetime() }, + callback() { + frm.reload_doc(); + }, + }); + }); + } + + if (show_pause) { + wrapper.find(".jcd-btn-pause").on("click", () => { + frm.call({ + method: "pause_job", + doc: frm.doc, + args: { end_time: frappe.datetime.now_datetime() }, + callback() { + frm.reload_doc(); + }, + }); + }); + } + + if (show_complete) { + wrapper.find(".jcd-btn-complete").on("click", () => { + frm.trigger("complete_job_card"); + }); + } + + // ── Timer tick ──────────────────────────────────────────────────── + const timer_el = wrapper.find(".jcd-stopwatch"); + const pad = (n) => String(n).padStart(2, "0"); + const update_stopwatch = (secs) => { + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = Math.floor(secs % 60); + timer_el.text(`${pad(h)}:${pad(m)}:${pad(s)}`); + }; + + let current_increment = frm.events.get_current_time(frm); + update_stopwatch(current_increment); + + if (is_actively_running) { + frm._jcd_timer_interval = setInterval(() => { + current_increment += 1; + update_stopwatch(current_increment); }, 1000); } - frm.dashboard.refresh(); - const timer = ` -
- 00 - : - 00 - : - 00 -
`; - - if (frappe.utils.is_xs()) { - frm.dashboard.add_comment(timer, "white", true); - section = frm.layout.wrapper.find(".form-message-container"); - } else { - section = frm.toolbar.page.add_inner_message(timer); - } - - let currentIncrement = frm.events.get_current_time(frm); - if (frm.doc.time_logs?.length && frm.doc.time_logs[cint(frm.doc.time_logs.length) - 1].to_time) { - updateStopwatch(currentIncrement); - } else if (frm.doc.status == "On Hold") { - updateStopwatch(currentIncrement); - } else { - initialiseTimer(); - } + return is_timer_running; }, get_current_time(frm) { @@ -713,22 +787,26 @@ frappe.ui.form.on("Job Card", { return current_time; }, - hide_timer: function (frm) { - frm.toolbar.page.inner_toolbar.find(".stopwatch").remove(); + hide_timer(frm) { + if (frm._jcd_timer_interval) { + clearInterval(frm._jcd_timer_interval); + frm._jcd_timer_interval = null; + } + $(frm.fields_dict["job_card_dashboard"].wrapper).empty(); }, - for_quantity: function (frm) { + for_quantity(frm) { frm.doc.items = []; frm.call({ method: "get_required_items", doc: frm.doc, - callback: function () { + callback() { refresh_field("items"); }, }); }, - make_material_request: function (frm) { + make_material_request(frm) { frappe.model.open_mapped_doc({ method: "erpnext.manufacturing.doctype.job_card.job_card.make_material_request", frm: frm, @@ -736,7 +814,7 @@ frappe.ui.form.on("Job Card", { }); }, - make_stock_entry: function (frm) { + make_stock_entry(frm) { frappe.model.open_mapped_doc({ method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry", frm: frm, @@ -744,11 +822,7 @@ frappe.ui.form.on("Job Card", { }); }, - timer: function (frm) { - return ``; - }, - - set_total_completed_qty: function (frm) { + set_total_completed_qty(frm) { frm.doc.total_completed_qty = 0; frm.doc.time_logs.forEach((d) => { if (d.completed_qty) { @@ -757,10 +831,9 @@ frappe.ui.form.on("Job Card", { }); if (frm.doc.total_completed_qty && frm.doc.for_quantity > frm.doc.total_completed_qty) { - let flt_precision = precision("for_quantity", frm.doc); - let process_loss_qty = + const flt_precision = precision("for_quantity", frm.doc); + const process_loss_qty = flt(frm.doc.for_quantity, flt_precision) - flt(frm.doc.total_completed_qty, flt_precision); - frm.set_value("process_loss_qty", process_loss_qty); } @@ -777,8 +850,8 @@ frappe.ui.form.on("Job Card", { }); frappe.ui.form.on("Job Card Time Log", { - completed_qty: function (frm, cdt, cdn) { - let row = locals[cdt][cdn]; + completed_qty(frm, cdt, cdn) { + const row = locals[cdt][cdn]; if (!row.completed_qty) { frappe.model.set_value(row.doctype, row.name, { time_in_mins: 0, @@ -795,12 +868,8 @@ function get_seconds_diff(d1, d2) { } function get_last_completed_row(time_logs) { - let completed_rows = time_logs.filter((d) => d.to_time); - - if (completed_rows?.length) { - let last_completed_row = completed_rows[completed_rows.length - 1]; - return last_completed_row; - } + const completed_rows = time_logs.filter((d) => d.to_time); + return completed_rows[completed_rows.length - 1]; } function get_last_row(time_logs) { diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 1e4af027c30..69a156009b1 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -7,25 +7,34 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "section_break_smqo", + "job_card_dashboard", + "section_break_fsba", + "work_order", + "column_break_uqjq", + "production_item", + "column_break_qrpg", + "for_quantity", + "column_break_yecz", + "bom_no", + "section_break_oisd", "company", "naming_series", - "work_order", - "employee", "column_break_4", "posting_date", - "project", - "bom_no", - "is_subcontracted", "semi_finished_good__finished_good_section", "finished_good", - "production_item", - "semi_fg_bom", - "total_completed_qty", "column_break_mcnb", - "for_quantity", - "transferred_qty", - "manufactured_qty", + "semi_fg_bom", + "section_break_folk", + "pending_qty", + "column_break_cyjw", "process_loss_qty", + "total_completed_qty", + "section_break_wpjf", + "transferred_qty", + "column_break_lgte", + "manufactured_qty", "production_section", "operation", "source_warehouse", @@ -36,6 +45,7 @@ "workstation_type", "workstation", "target_warehouse", + "employee", "section_break_8", "items", "quality_inspection_section", @@ -72,8 +82,10 @@ "item_name", "requested_qty", "is_paused", + "is_subcontracted", "track_semi_finished_goods", "column_break_20", + "project", "remarks", "section_break_dfoc", "status", @@ -626,12 +638,64 @@ "fieldname": "secondary_items_section", "fieldtype": "Tab Break", "label": "Secondary Items" + }, + { + "fieldname": "section_break_folk", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "column_break_cyjw", + "fieldtype": "Column Break" + }, + { + "allow_on_submit": 1, + "fieldname": "pending_qty", + "fieldtype": "Float", + "label": "Pending Qty" + }, + { + "fieldname": "section_break_wpjf", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_lgte", + "fieldtype": "Column Break" + }, + { + "fieldname": "job_card_dashboard", + "fieldtype": "HTML" + }, + { + "fieldname": "section_break_oisd", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_uqjq", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_qrpg", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_yecz", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_smqo", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "section_break_fsba", + "fieldtype": "Section Break" } ], "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2026-05-12 12:17:17.750857", + "modified": "2026-05-21 18:37:05.688342", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 9716ae49dc0..137788346c2 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -103,6 +103,7 @@ class JobCard(Document): operation_id: DF.Data | None operation_row_id: DF.Int operation_row_number: DF.Literal[None] + pending_qty: DF.Float posting_date: DF.Date | None process_loss_qty: DF.Float production_item: DF.Link | None @@ -881,7 +882,9 @@ class JobCard(Document): precision = self.precision("total_completed_qty") total_completed_qty = flt( - flt(self.total_completed_qty, precision) + flt(self.process_loss_qty, precision) + flt(self.total_completed_qty, precision) + + flt(self.process_loss_qty, precision) + + flt(self.pending_qty, precision) ) if self.for_quantity and flt(total_completed_qty, precision) != flt(self.for_quantity, precision): @@ -928,8 +931,10 @@ class JobCard(Document): self.process_loss_qty = 0.0 if self.total_completed_qty and self.for_quantity > self.total_completed_qty: - self.process_loss_qty = flt(self.for_quantity, precision) - flt( - self.total_completed_qty, precision + self.process_loss_qty = ( + flt(self.for_quantity, precision) + - flt(self.total_completed_qty, precision) + - flt(self.pending_qty, precision) ) def update_work_order(self): @@ -943,13 +948,14 @@ class JobCard(Document): ): return - for_quantity, time_in_mins, process_loss_qty = 0, 0, 0 + for_quantity, time_in_mins, process_loss_qty, pending_qty = 0, 0, 0, 0 data = self.get_current_operation_data() if data and len(data) > 0: for_quantity = flt(data[0].completed_qty) time_in_mins = flt(data[0].time_in_mins) process_loss_qty = flt(data[0].process_loss_qty) + pending_qty = flt(data[0].pending_qty) wo = frappe.get_doc("Work Order", self.work_order) @@ -957,8 +963,8 @@ class JobCard(Document): self.update_corrective_in_work_order(wo) elif self.operation_id: - self.validate_produced_quantity(for_quantity, process_loss_qty, wo) - self.update_work_order_data(for_quantity, process_loss_qty, time_in_mins, wo) + self.validate_produced_quantity(for_quantity, process_loss_qty, pending_qty, wo) + self.update_work_order_data(for_quantity, process_loss_qty, pending_qty, time_in_mins, wo) def update_semi_finished_good_details(self): if self.operation_id: @@ -987,11 +993,11 @@ class JobCard(Document): wo.flags.ignore_validate_update_after_submit = True wo.save() - def validate_produced_quantity(self, for_quantity, process_loss_qty, wo): + def validate_produced_quantity(self, for_quantity, process_loss_qty, pending_qty, wo): if self.docstatus < 2: return - if wo.produced_qty > for_quantity + process_loss_qty: + if wo.produced_qty > for_quantity + process_loss_qty + pending_qty: first_part_msg = _( "The {0} {1} is used to calculate the valuation cost for the finished good {2}." ).format(frappe.bold(_("Job Card")), frappe.bold(self.name), frappe.bold(self.production_item)) @@ -1004,7 +1010,7 @@ class JobCard(Document): _("{0} {1}").format(first_part_msg, second_part_msg), JobCardCancelError, title=_("Error") ) - def update_work_order_data(self, for_quantity, process_loss_qty, time_in_mins, wo): + def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo): workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate") jc = frappe.qb.DocType("Job Card") jctl = frappe.qb.DocType("Job Card Time Log") @@ -1026,6 +1032,7 @@ class JobCard(Document): if data.get("name") == self.operation_id: data.completed_qty = for_quantity data.process_loss_qty = process_loss_qty + data.pending_qty = pending_qty data.actual_operation_time = time_in_mins data.actual_start_time = time_data[0].start_time if time_data else None data.actual_end_time = time_data[0].end_time if time_data else None @@ -1051,6 +1058,7 @@ class JobCard(Document): {"SUM": "total_time_in_mins", "as": "time_in_mins"}, {"SUM": "total_completed_qty", "as": "completed_qty"}, {"SUM": "process_loss_qty", "as": "process_loss_qty"}, + {"SUM": "pending_qty", "as": "pending_qty"}, ], filters={ "docstatus": 1, @@ -1445,10 +1453,19 @@ class JobCard(Document): if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) - if kwargs.end_time: - if kwargs.for_quantity: - self.for_quantity = kwargs.for_quantity + if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0: + frappe.throw(_("Pending quantity cannot be negative.")) + if flt(kwargs.process_loss_qty) and flt(kwargs.process_loss_qty) < 0: + frappe.throw(_("Process loss quantity cannot be negative.")) + + if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) > self.for_quantity: + frappe.throw(_("Pending quantity cannot be greater than the for quantity.")) + + self.pending_qty = flt(kwargs.pending_qty) + self.process_loss_qty = flt(kwargs.process_loss_qty) + + if kwargs.end_time: self.add_time_logs( to_time=kwargs.end_time, completed_qty=kwargs.qty, diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 4965f6eebec..7174347d5bb 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -720,6 +720,7 @@ class TestJobCard(ERPNextTestSuite): ) jc.time_logs[0].completed_qty = 8 + jc.pending_qty = 0.0 jc.save() jc.submit() diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 8bc4485785c..81047a8c391 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -167,18 +167,19 @@ class WorkOrder(Document): self.set_onload("backflush_raw_materials_based_on", based_on) def show_create_job_card_button(self): - operation_details = frappe._dict( - frappe.get_all( - "Job Card", - fields=["operation", {"SUM": "for_quantity"}], - filters={"docstatus": ("<", 2), "work_order": self.name}, - as_list=1, - group_by="operation_id", - ) + jc_doctype = frappe.qb.DocType("Job Card") + query = ( + frappe.qb.from_(jc_doctype) + .select(jc_doctype.operation_id, Sum(jc_doctype.for_quantity - IfNull(jc_doctype.pending_qty, 0))) + .where((jc_doctype.docstatus < 2) & (jc_doctype.work_order == self.name)) + .groupby(jc_doctype.operation_id) ) + operation_details = query.run(as_list=1) + operation_details = frappe._dict(operation_details) + for d in self.operations: - job_card_qty = self.qty - flt(operation_details.get(d.operation)) + job_card_qty = self.qty - flt(operation_details.get(d.name)) if job_card_qty > 0: return True diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 89ed830116b..918f2b21847 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2025-04-09 12:12:19.824560", "doctype": "DocType", "editable_grid": 1, @@ -10,6 +11,7 @@ "status", "completed_qty", "process_loss_qty", + "pending_qty", "column_break_4", "bom", "workstation_type", @@ -301,13 +303,20 @@ "fieldname": "quality_inspection_required", "fieldtype": "Check", "label": "Quality Inspection Required" + }, + { + "fieldname": "pending_qty", + "fieldtype": "Float", + "label": "Pending Qty", + "no_copy": 1, + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-30 17:20:08.874381", + "modified": "2026-05-20 13:01:21.827200", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py index 2e45434f94b..8950fd6b320 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py @@ -32,6 +32,7 @@ class WorkOrderOperation(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + pending_qty: DF.Float planned_end_time: DF.Datetime | None planned_operating_cost: DF.Currency planned_start_time: DF.Datetime | None