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(`
+ `);
+
+ // ── 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)