From d2a793b03be65fdc3b22b2428fee5d4af8552fbc Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Wed, 20 May 2026 23:18:20 +0530 Subject: [PATCH] refactor: better timer and complete button (cherry picked from commit 1be92f6d05ef3bb5416f61ba433fc6ecda52f2ce) # Conflicts: # erpnext/manufacturing/doctype/job_card/job_card.js --- .../doctype/job_card/job_card.js | 347 +++++++++++------- .../doctype/job_card/job_card.json | 48 ++- .../doctype/job_card/job_card.py | 9 + 3 files changed, 257 insertions(+), 147 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index ac544b0fe0c..e6d9df9d08c 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -134,7 +134,16 @@ frappe.ui.form.on("Job Card", { const { doc } = frm; const has_items = doc.items && doc.items.length; +<<<<<<< HEAD >>>>>>> 0a215b0717 (refactor: job_card.js code for better readability) +======= + // Clear any running timer tick from a previous render. + if (frm._jcd_timer_interval) { + clearInterval(frm._jcd_timer_interval); + frm._jcd_timer_interval = null; + } + +>>>>>>> 1be92f6d05 (refactor: better timer and complete button) frm.trigger("make_fields_read_only"); if (!frm.is_new() && doc.__onload?.work_order_closed) { @@ -309,103 +318,10 @@ frappe.ui.form.on("Job Card", { } }, - // Renders the correct action button (Start / Resume / Pause + Complete) based on job state. + // 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) { - const { doc } = frm; - - 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; - - if (!has_remaining_qty || !materials_ready) return false; - - 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 && doc.time_logs?.length) { - last_row = get_last_row(doc.time_logs); - } - - const no_time_logs_yet = !doc.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; - - if (should_show_start) { - frm.events.add_start_job_button(frm); - return false; - } - - if (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(); - }, - }); - }); - return false; - } - - // Job is actively running — show Pause and Complete buttons. - 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 (qty_yet_to_manufacture > 0) { - if (!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"); - return true; - } - - frm.trigger("make_dashboard"); - return false; - }, - - add_start_job_button(frm) { - frm.add_custom_button(__("Start Job"), () => { - const from_time = frappe.datetime.now_datetime(); - const has_no_employee = (frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee; - - 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); - } - }); + return frm.events.make_dashboard(frm, has_items); }, complete_job_card(frm) { @@ -555,7 +471,6 @@ frappe.ui.form.on("Job Card", { args: { start_time, employees }, callback() { frm.reload_doc(); - frm.trigger("make_dashboard"); }, }); }, @@ -737,60 +652,210 @@ frappe.ui.form.on("Job Card", { } }, - make_dashboard(frm) { - if (frm.doc.__islocal) return; + make_dashboard(frm, has_items) { + if (frm.doc.__islocal) return false; frm.dashboard.refresh(); - const timer_html = ` -
- 00 - : - 00 - : - 00 -
`; - - let section; - if (frappe.utils.is_xs()) { - frm.dashboard.add_comment(timer_html, "white", true); - section = frm.layout.wrapper.find(".form-message-container"); - } else { - section = frm.toolbar.page.add_inner_message(timer_html); + // Clear any previously running timer tick before re-rendering. + if (frm._jcd_timer_interval) { + clearInterval(frm._jcd_timer_interval); + frm._jcd_timer_interval = null; } + const wrapper = $(frm.fields_dict["job_card_dashboard"].wrapper); + wrapper.empty(); + + 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); + } + + 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 = (increment) => { - const hours = Math.floor(increment / 3600); - const minutes = Math.floor((increment - hours * 3600) / 60); - const seconds = Math.floor(flt(increment - hours * 3600 - minutes * 60, 2)); - - section.find(".hours").text(pad(hours)); - section.find(".minutes").text(pad(minutes)); - section.find(".seconds").text(pad(seconds)); + 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); - const start_timer = () => { - setInterval(() => { + if (is_actively_running) { + frm._jcd_timer_interval = setInterval(() => { current_increment += 1; update_stopwatch(current_increment); }, 1000); - }; - - const { time_logs, status } = frm.doc; - const last_log_complete = time_logs?.length && time_logs[cint(time_logs.length) - 1].to_time; - - if (last_log_complete) { - update_stopwatch(current_increment); - } else if (status == "On Hold") { - update_stopwatch(current_increment); - } else { - start_timer(); } + + return is_timer_running; }, get_current_time(frm) { @@ -812,7 +877,11 @@ frappe.ui.form.on("Job Card", { }, hide_timer(frm) { - frm.toolbar.page.inner_toolbar.find(".stopwatch").remove(); + 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(frm) { @@ -842,10 +911,6 @@ frappe.ui.form.on("Job Card", { }); }, - timer(frm) { - return ``; - }, - set_total_completed_qty(frm) { frm.doc.total_completed_qty = 0; frm.doc.time_logs.forEach((d) => { diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index b9236d2ba00..69a156009b1 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -7,20 +7,26 @@ "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", - "production_item", - "employee", "column_break_4", "posting_date", - "work_order", - "bom_no", "semi_finished_good__finished_good_section", "finished_good", "column_break_mcnb", "semi_fg_bom", "section_break_folk", - "for_quantity", "pending_qty", "column_break_cyjw", "process_loss_qty", @@ -39,6 +45,7 @@ "workstation_type", "workstation", "target_warehouse", + "employee", "section_break_8", "items", "quality_inspection_section", @@ -654,12 +661,41 @@ { "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-20 14:05:46.205365", + "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 0c625c78a17..137788346c2 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1453,6 +1453,15 @@ class JobCard(Document): if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) + 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)