`,
+ },
+ ],
+ primary_action_label: __("Submit Inspection"),
+ primary_action: () => {
+ const readings = [];
+ let missing = false;
+ params.forEach((p, i) => {
+ if (p.numeric) {
+ const val = dialog.$wrapper.find(`.mes-qc-reading[data-idx="${i}"]`).val();
+ if (val === "" || val === undefined || val === null) missing = true;
+ readings.push({ specification: p.specification, reading_value: val });
+ } else {
+ if (!state[i]) missing = true;
+ readings.push({
+ specification: p.specification,
+ status: state[i],
+ reading_value: "",
+ });
+ }
+ });
+ if (missing) {
+ frappe.msgprint(__("Please complete every check before submitting the inspection."));
+ return;
+ }
+ dialog.hide();
+ frappe.call({
+ method: "erpnext.manufacturing.page.shop_floor.shop_floor.submit_quality_inspection",
+ args: { job_card: jc.name, readings: JSON.stringify(readings) },
+ freeze: true,
+ freeze_message: __("Recording inspection..."),
+ callback: (r) => {
+ const res = r.message || {};
+ if (res.status === "Rejected") {
+ // Don't auto-proceed on a rejected inspection — the server gate may block the
+ // submit anyway (per Stock Settings), and the operator should decide next steps.
+ frappe.msgprint({
+ title: __("Inspection Rejected"),
+ indicator: "red",
+ message: __(
+ "Quality Inspection {0} is Rejected. Resolve the issue or follow your rejection process before submitting the job card.",
+ [res.name || ""]
+ ),
+ });
+ me.reload();
+ return;
+ }
+ if (on_pass) on_pass();
+ },
+ });
+ },
+ });
+
+ dialog.show();
+ // Pass/Fail toggles for qualitative parameters.
+ dialog.$wrapper.find(".mes-qc-passfail button").on("click", function () {
+ const $btn = $(this);
+ const $grp = $btn.closest(".mes-qc-passfail");
+ $grp.find("button").removeClass("active");
+ $btn.addClass("active");
+ state[$grp.attr("data-idx")] = $btn.attr("data-val");
+ });
+ }
+
+ prompt_manufacture_entry(jc_name) {
+ const me = this;
+ const dialog = new frappe.ui.Dialog({
+ title: __("Job Card Submitted"),
+ fields: [
+ {
+ fieldtype: "HTML",
+ options: `
+
+
+ ${__("Job card {0} has been submitted.", [frappe.utils.escape_html(jc_name)])}
+
+
+ ${__("Create a Manufacture stock entry for the finished goods?")}
+
+
+ `,
+ },
+ ],
+ primary_action_label: __("Make Manufacture Entry"),
+ primary_action: () => {
+ dialog.hide();
+ me.make_manufacture_entry(jc_name);
+ },
+ secondary_action_label: __("Skip"),
+ secondary_action: () => dialog.hide(),
+ });
+ dialog.show();
+ this.bind_enter_submit(dialog);
+ }
+
+ submit_job_card(jc_name) {
+ const me = this;
+ frappe.confirm(__("Submit job card {0}? This finalizes the job card.", [jc_name]), () => {
+ frappe.call({
+ method: "erpnext.manufacturing.page.shop_floor.shop_floor.submit_job_card",
+ args: { job_card: jc_name },
+ freeze: true,
+ freeze_message: __("Submitting job card..."),
+ callback: () => me.reload(),
+ });
+ });
+ }
+
+ make_manufacture_entry(jc_name) {
+ frappe.call({
+ method: "erpnext.manufacturing.page.shop_floor.shop_floor.make_manufacture_stock_entry",
+ args: { job_card: jc_name },
+ freeze: true,
+ freeze_message: __("Preparing stock entry..."),
+ callback: (r) => {
+ if (r.message && r.message.name) {
+ window.open(`/app/stock-entry/${encodeURIComponent(r.message.name)}`, "_blank");
+ }
+ },
+ });
+ }
+
+ transfer_materials(jc_name) {
+ if (!jc_name) return;
+ frappe.call({
+ method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry",
+ args: { source_name: jc_name },
+ callback: (r) => {
+ const doc = frappe.model.sync(r.message);
+ frappe.set_route("Form", doc[0].doctype, doc[0].name);
+ },
+ });
+ }
+
+ update_job_card(job_card, method, data, on_success) {
+ const me = this;
+ frappe.call({
+ method: "erpnext.manufacturing.doctype.workstation.workstation.update_job_card",
+ args: {
+ job_card: job_card,
+ method: method,
+ start_time: data.start_time || "",
+ employees: data.employees || [],
+ end_time: data.end_time || "",
+ qty: data.qty || 0,
+ for_quantity: data.for_quantity || 0,
+ pending_qty: data.pending_qty || 0,
+ process_loss_qty: data.process_loss_qty || 0,
+ auto_submit: data.auto_submit || 0,
+ },
+ freeze: true,
+ freeze_message: __("Updating job card..."),
+ callback: () => {
+ me.reload();
+ if (on_success) on_success();
+ },
+ });
+ }
+
+ // ── Timers ────────────────────────────────────────────────────────────────
+ start_timer_for(jc, $container) {
+ let elapsed = this.elapsed_seconds(jc);
+ this.render_timer(jc.name, elapsed, $container);
+ this.timer_intervals[jc.name] = setInterval(() => {
+ elapsed += 1;
+ this.render_timer(jc.name, elapsed, $container);
+ }, 1000);
+ }
+
+ elapsed_seconds(jc) {
+ let total = 0;
+ for (const log of jc.time_logs || []) {
+ if (log.to_time) {
+ if (log.time_in_mins) {
+ total += flt(log.time_in_mins, 2) * 60;
+ } else {
+ total += moment(log.to_time).diff(log.from_time, "seconds");
+ }
+ } else {
+ total += moment().diff(log.from_time, "seconds");
+ }
+ }
+ return total;
+ }
+
+ render_timer(jc_name, seconds, $container) {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds - h * 3600) / 60);
+ const s = cint(seconds - h * 3600 - m * 60);
+ const pad = (n) => (n < 10 ? "0" + n : String(n));
+
+ const scope = $container || this.wrapper;
+ const timer = scope.find(`.mes-job-timer[data-job-card="${jc_name}"]`);
+ timer.find(".h").text(pad(h));
+ timer.find(".m").text(pad(m));
+ timer.find(".s").text(pad(s));
+ }
+
+ // ── Realtime + lifecycle ───────────────────────────────────────────────────
+ bind_realtime() {
+ frappe.realtime.on("update_workstation_status", (data) => {
+ if (data && data.name === this.op_state.workstation) {
+ this.reload();
+ }
+ });
+ }
+
+ bind_lifecycle() {
+ // Frappe has no on_page_hide hook, so toggle immersive mode + keyboard binding on
+ // route changes ourselves.
+ this._route_handler = () => {
+ const on_page = (frappe.get_route_str() || "").startsWith("shop-floor");
+ if (on_page) {
+ $(document.body).addClass("shop-floor-active");
+ this.bind_keys();
+ } else {
+ $(document.body).removeClass("shop-floor-active");
+ this.unbind_keys();
+ this.clear_timers();
+ }
+ };
+ frappe.router.on("change", this._route_handler);
+ }
+
+ on_show() {
+ $(document.body).addClass("shop-floor-active");
+ this.bind_keys();
+ // Cached re-navigation (e.g. the Work Order "Shop Floor" button) lands here with fresh
+ // route_options; init() handles the very first load before we're initialized.
+ if (this.initialized) this.apply_route_options();
+ }
+
+ // ── Keyboard ────────────────────────────────────────────────────────────────
+ bind_keys() {
+ $(document).off("keydown.shopfloor");
+ $(document).on("keydown.shopfloor", (e) => this.handle_key(e));
+ }
+
+ unbind_keys() {
+ $(document).off("keydown.shopfloor");
+ }
+
+ is_typing(e) {
+ const tag = (e.target.tagName || "").toLowerCase();
+ return tag === "input" || tag === "textarea" || tag === "select" || e.target.isContentEditable;
+ }
+
+ handle_key(e) {
+ // Let dialogs own the keyboard while open.
+ if ($(".modal:visible").length) return;
+
+ const typing = this.is_typing(e);
+
+ // Escape works even while typing (blur the search / close the detail pane).
+ if (e.key === "Escape") {
+ if (typing) {
+ e.target.blur();
+ return;
+ }
+ if (this.view === "manager" && this.selected_wo) {
+ this.close_wo();
+ e.preventDefault();
+ }
+ return;
+ }
+
+ if (typing) return;
+
+ switch (e.key) {
+ case "?":
+ this.show_help();
+ e.preventDefault();
+ return;
+ case "/":
+ this.topbar_center.find(".sf-search-input").focus();
+ e.preventDefault();
+ return;
+ case "r":
+ this.refresh();
+ e.preventDefault();
+ return;
+ case "b":
+ this.open_scanner();
+ e.preventDefault();
+ return;
+ case "1":
+ case "2":
+ if (this.view === "manager" && MANAGER_BUCKETS[cint(e.key) - 1]) {
+ this.switch_bucket(MANAGER_BUCKETS[cint(e.key) - 1].key);
+ e.preventDefault();
+ }
+ return;
+ }
+
+ // View switch chord: "g" then "m"/"o".
+ if (e.key === "g") {
+ this._g_pending = true;
+ setTimeout(() => (this._g_pending = false), 600);
+ return;
+ }
+ if (this._g_pending && (e.key === "m" || e.key === "o")) {
+ this._g_pending = false;
+ if (this.can_manage) this.set_view(e.key === "m" ? "manager" : "operator");
+ return;
+ }
+
+ // Navigation.
+ if (e.key === "ArrowDown" || e.key === "j") {
+ this.move_focus(1);
+ e.preventDefault();
+ return;
+ }
+ if (e.key === "ArrowUp" || e.key === "k") {
+ this.move_focus(-1);
+ e.preventDefault();
+ return;
+ }
+ if (e.key === "Enter") {
+ this.activate_focus();
+ e.preventDefault();
+ return;
+ }
+
+ // Job actions on the focused card — reuse the rendered buttons.
+ const map = {
+ s: ".mes-btn-start, .mes-btn-resume",
+ p: ".mes-btn-pause, .mes-btn-resume",
+ e: ".mes-btn-end-session",
+ t: ".mes-btn-transfer",
+ };
+ if (e.key === "S" && e.shiftKey) {
+ this.click_job_action(".mes-btn-submit");
+ e.preventDefault();
+ return;
+ }
+ if (map[e.key]) {
+ this.click_job_action(map[e.key]);
+ e.preventDefault();
+ }
+ }
+
+ // Job actions act on the focused job card (operator view); when the focus is on a board
+ // work order (manager view with the detail open) they fall back to the detail's active job.
+ click_job_action(selector) {
+ const $el = this.focused_el();
+ if ($el && $el.attr("data-kind") === "job") {
+ const $btn = $el.find(selector).filter(":visible").first();
+ if ($btn.length) {
+ $btn.trigger("click");
+ return;
+ }
+ }
+ const scope = this.current_op_container();
+ if (scope && scope.length) {
+ const $btn = scope.find(selector).filter(":visible").first();
+ if ($btn.length) $btn.trigger("click");
+ }
+ }
+
+ focusables() {
+ // Manager always navigates the board work orders — even with the detail open, so the
+ // arrow keys switch work orders. The standalone operator view navigates its job cards.
+ const scope = this.view === "manager" ? this.board_container : this.current_op_container();
+ if (!scope || !scope.length) return $();
+ return scope.find("[data-sf-focusable]");
+ }
+
+ move_focus(delta) {
+ const $items = this.focusables();
+ if (!$items.length) return;
+ this.focus_index = Math.max(0, Math.min($items.length - 1, this.focus_index + delta));
+ $items.removeClass("sf-focused");
+ const $target = $items.eq(this.focus_index);
+ $target.addClass("sf-focused");
+ $target[0].scrollIntoView({ block: "nearest", behavior: "smooth" });
+ // Browsing work orders with the detail already open → switch the detail to the focused one.
+ if (this.view === "manager" && this.selected_wo && $target.attr("data-kind") === "wo") {
+ this.open_wo($target.attr("data-name"));
+ }
+ }
+
+ focused_el() {
+ const $items = this.focusables();
+ if (this.focus_index < 0 || this.focus_index >= $items.length) return null;
+ return $items.eq(this.focus_index);
+ }
+
+ activate_focus() {
+ const $el = this.focused_el();
+ if (!$el) return;
+ if ($el.attr("data-kind") === "wo") {
+ this.open_wo($el.attr("data-name"));
+ } else {
+ // First visible primary button drives the job card (Start / Resume / End Session).
+ const $btn = $el.find(".btn-primary:visible").first();
+ if ($btn.length) $btn.trigger("click");
+ }
+ }
+
+ show_help() {
+ const rows = [
+ ["?", __("Show this help")],
+ ["/", __("Search work orders")],
+ ["r", __("Refresh")],
+ ["b", __("Scan job card")],
+ ["g then m / o", __("Switch Board / Operator view")],
+ ["1 / 2", __("Switch board tab")],
+ ["↑ / ↓ or j / k", __("Move selection")],
+ ["Enter", __("Open work order / run primary action")],
+ ["Esc", __("Close detail / blur search")],
+ ["s", __("Start / Resume job")],
+ ["p", __("Pause / Resume job")],
+ ["e", __("End session for active job")],
+ ["t", __("Transfer materials")],
+ ["Shift + S", __("Submit focused job card")],
+ ];
+ const html = `
${rows
+ .map((r) => `
${r[0]}${r[1]}
`)
+ .join("")}
`;
+ const d = new frappe.ui.Dialog({
+ title: __("Keyboard Shortcuts"),
+ fields: [{ fieldtype: "HTML", options: html }],
+ });
+ d.show();
+ }
+
+ // ── Scanner ──────────────────────────────────────────────────────────────
+ open_scanner() {
+ const me = this;
+ const dialog = new frappe.ui.Dialog({
+ title: __("Scan Job Card"),
+ fields: [
+ {
+ label: __("Scan or enter Job Card"),
+ fieldname: "job_card",
+ fieldtype: "Data",
+ options: "Barcode",
+ },
+ ],
+ primary_action_label: __("Continue"),
+ primary_action: (values) => {
+ if (!values.job_card) return;
+ dialog.hide();
+ me.handle_scanned_job_card(values.job_card);
+ },
+ });
+ dialog.show();
+ this.bind_enter_submit(dialog);
+ }
+
+ handle_scanned_job_card(job_card) {
+ const me = this;
+ const jc = (this.job_cards || []).find((j) => j.name === job_card);
+ if (jc) {
+ me.route_scanned_action(jc);
+ return;
+ }
+ frappe.db.get_value("Job Card", job_card, ["status", "is_paused", "docstatus"]).then((r) => {
+ const data = r && r.message;
+ if (!data || !data.status) {
+ frappe.msgprint(__("Job Card {0} was not found.", [job_card]));
+ return;
+ }
+ if (cint(data.docstatus) === 1) {
+ frappe.msgprint(__("Job Card {0} is already submitted.", [job_card]));
+ } else if (cint(data.is_paused)) {
+ me.resume_job(job_card);
+ } else if (data.status === "Work In Progress") {
+ frappe.msgprint(
+ __(
+ "Job Card {0} is already running. Open its machine or work order to pause or complete it.",
+ [job_card]
+ )
+ );
+ } else if (data.status === "Completed") {
+ me.submit_job_card(job_card);
+ } else {
+ me.start_job(job_card);
+ }
+ });
+ }
+
+ route_scanned_action(jc) {
+ const me = this;
+ if (jc.docstatus === 1) {
+ frappe.msgprint(__("Job Card {0} is already submitted.", [jc.name]));
+ return;
+ }
+ if (jc.status === "Completed") {
+ me.submit_job_card(jc.name);
+ return;
+ }
+ if (jc.is_paused) {
+ me.resume_job(jc.name);
+ return;
+ }
+ const last_log = jc.time_logs && jc.time_logs.length ? jc.time_logs[jc.time_logs.length - 1] : null;
+ const is_running = !!(last_log && !last_log.to_time);
+ if (is_running) {
+ me.prompt_running_action(jc);
+ } else {
+ me.start_job(jc.name);
+ }
+ }
+
+ prompt_running_action(jc) {
+ const me = this;
+ const dialog = new frappe.ui.Dialog({
+ title: __("Job {0} is running", [jc.name]),
+ fields: [
+ {
+ fieldtype: "HTML",
+ options: `
+
+ ${__("{0} is already in progress. Pause it or complete the session.", [
+ frappe.utils.escape_html(jc.finished_good || jc.production_item || jc.name),
+ ])}
+