`,
- },
- ],
- 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),
- ])}
-