From 8093e447460af129c1778d05e7b72b609422f051 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sat, 4 Jul 2026 13:31:37 +0530 Subject: [PATCH] feat: shop floor interface for operators (#55551) * feat: shop floor interface for operators * fix: documentation * fix: UI/UX for shop floor * fix: shop floor query and OEE edge cases from review - Push the draft / To Manufacture condition into the Job Card query (or_filters) so a busy workstation's submitted history cannot fill the row limit and hide active drafts - Clamp the OEE quality factor at zero when process loss exceeds completed qty Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../doctype/job_card/job_card.json | 4 +- .../doctype/job_card/job_card.py | 6 + .../doctype/operation/operation.json | 9 +- .../doctype/operation/operation.py | 1 + .../doctype/work_order/work_order.js | 9 + .../doctype/workstation/workstation.js | 547 +----- .../doctype/workstation/workstation.json | 19 - .../doctype/workstation/workstation.py | 73 +- .../manufacturing/page/shop_floor/__init__.py | 0 .../page/shop_floor/shop_floor.js | 26 + .../page/shop_floor/shop_floor.json | 32 + .../page/shop_floor/shop_floor.py | 812 ++++++++ erpnext/patches.txt | 1 + .../patches/v16_0/create_shop_floor_roles.py | 5 + erpnext/public/js/erpnext.bundle.js | 2 + .../js/plant_floor_visual/visual_plant.js | 8 + erpnext/public/js/shop_floor/shop_floor.js | 1665 +++++++++++++++++ .../js/templates/shop_floor_template.html | 1059 +++++++++++ .../visual_plant_floor_template.html | 2 +- erpnext/setup/install.py | 11 + erpnext/workspace_sidebar/manufacturing.json | 84 +- 21 files changed, 3740 insertions(+), 635 deletions(-) create mode 100644 erpnext/manufacturing/page/shop_floor/__init__.py create mode 100644 erpnext/manufacturing/page/shop_floor/shop_floor.js create mode 100644 erpnext/manufacturing/page/shop_floor/shop_floor.json create mode 100644 erpnext/manufacturing/page/shop_floor/shop_floor.py create mode 100644 erpnext/patches/v16_0/create_shop_floor_roles.py create mode 100644 erpnext/public/js/shop_floor/shop_floor.js create mode 100644 erpnext/public/js/templates/shop_floor_template.html diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index c215aee42d4..554e6f2395a 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -272,7 +272,7 @@ "fieldtype": "Select", "label": "Status", "no_copy": 1, - "options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted", + "options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nTo Manufacture\nCancelled\nCompleted", "read_only": 1 }, { @@ -695,7 +695,7 @@ "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2026-06-19 17:39:42.293242", + "modified": "2026-06-20 17:39:42.293242", "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 2937a9df616..c796ff7877b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -131,6 +131,7 @@ class JobCard(Document): "Material Transferred", "On Hold", "Submitted", + "To Manufacture", "Cancelled", "Completed", ] @@ -1302,8 +1303,13 @@ class JobCard(Document): self.update_workstation_status() def set_finished_good_status(self): + # Only reached for a submitted job card (docstatus == 1) with a finished good, see set_status(). if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity: self.status = "Completed" + elif (self.total_completed_qty + self.process_loss_qty) >= self.for_quantity: + # Production is done and the card is submitted, but the finished goods have not been + # booked into stock yet (Manufacture Stock Entry pending) — distinct from active WIP. + self.status = "To Manufacture" elif self.transferred_qty > 0 or self.skip_material_transfer: self.status = "Work In Progress" diff --git a/erpnext/manufacturing/doctype/operation/operation.json b/erpnext/manufacturing/doctype/operation/operation.json index 0c569bbb47a..818ed32a3d0 100644 --- a/erpnext/manufacturing/doctype/operation/operation.json +++ b/erpnext/manufacturing/doctype/operation/operation.json @@ -20,7 +20,8 @@ "sub_operations", "total_operation_time", "section_break_4", - "description" + "description", + "work_instruction" ], "fields": [ { @@ -43,6 +44,12 @@ "in_preview": 1, "label": "Description" }, + { + "description": "Shown to operators on the Shop Floor. Supports rich text and embedded images for step-by-step guidance.", + "fieldname": "work_instruction", + "fieldtype": "Text Editor", + "label": "Work Instructions" + }, { "collapsible": 1, "fieldname": "sub_operations_section", diff --git a/erpnext/manufacturing/doctype/operation/operation.py b/erpnext/manufacturing/doctype/operation/operation.py index 3ab95f48efc..4d1cbf114d0 100644 --- a/erpnext/manufacturing/doctype/operation/operation.py +++ b/erpnext/manufacturing/doctype/operation/operation.py @@ -25,6 +25,7 @@ class Operation(Document): quality_inspection_template: DF.Link | None sub_operations: DF.Table[SubOperation] total_operation_time: DF.Float + work_instruction: DF.TextEditor | None workstation: DF.Link | None # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index e6a39ab203e..3b839a5b17c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -203,6 +203,15 @@ frappe.ui.form.on("Work Order", { } } + let pending_ops = frm.doc?.operations?.filter((op) => op.completed_qty < frm.doc.qty); + // Jump to the operator Shop Floor view, pre-filtered to this work order. + if (frm.doc.docstatus === 1 && frm.doc.status !== "Closed" && pending_ops && pending_ops.length > 0) { + frm.add_custom_button(__("Operator Dashboard"), () => { + frappe.route_options = { work_order: frm.doc.name }; + frappe.set_route("shop-floor"); + }); + } + if (frm.doc.status == "Completed") { if (frm.doc.__onload.backflush_raw_materials_based_on == "Material Transferred for Manufacture") { frm.add_custom_button( diff --git a/erpnext/manufacturing/doctype/workstation/workstation.js b/erpnext/manufacturing/doctype/workstation/workstation.js index 3282e4f0ca7..72ba2a3a62e 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.js +++ b/erpnext/manufacturing/doctype/workstation/workstation.js @@ -12,17 +12,14 @@ frappe.ui.form.on("Workstation", { refresh(frm) { frm.trigger("set_illustration_image"); - frm.trigger("prepapre_dashboard"); - }, - prepapre_dashboard(frm) { - let $parent = $(frm.fields_dict["workstation_dashboard"].wrapper); - $parent.empty(); - - let workstation_dashboard = new WorkstationDashboard({ - wrapper: $parent, - frm: frm, - }); + if (!frm.is_new()) { + // Operator workflow now lives on the Shop Floor page; jump there filtered to this machine. + frm.add_custom_button(__("Shop Floor"), () => { + frappe.route_options = { workstation: frm.doc.name }; + frappe.set_route("shop-floor"); + }); + } }, onload(frm) { @@ -80,533 +77,3 @@ frappe.tour["Workstation"] = [ ), }, ]; - -class WorkstationDashboard { - constructor({ wrapper, frm }) { - this.$wrapper = $(wrapper); - this.frm = frm; - - this.prepapre_dashboard(); - } - - prepapre_dashboard() { - frappe.call({ - method: "erpnext.manufacturing.doctype.workstation.workstation.get_job_cards", - args: { - workstation: this.frm.doc.name, - }, - callback: (r) => { - if (r.message) { - this.job_cards = r.message; - this.render_job_cards(); - } - }, - }); - } - - render_job_cards() { - this.template = frappe.render_template("workstation_job_card", { - data: this.job_cards, - }); - - this.timer_job_cards = {}; - this.$wrapper.html(this.template); - this.setup_qrcode_fields(); - this.prepare_timer(); - this.setup_menu_actions(); - this.toggle_job_card(); - this.bind_events(); - } - - setup_qrcode_fields() { - this.start_job_qrcode = frappe.ui.form.make_control({ - df: { - label: __("Start Job"), - fieldtype: "Data", - options: "Barcode", - placeholder: __("Scan Job Card Qrcode"), - }, - parent: this.$wrapper.find(".qrcode-fields"), - render_input: true, - }); - - this.start_job_qrcode.$wrapper.addClass("form-column col-sm-6"); - - this.start_job_qrcode.$input.on("input", (e) => { - clearTimeout(this.start_job_qrcode_search); - this.start_job_qrcode_search = setTimeout(() => { - let job_card = this.start_job_qrcode.get_value(); - if (job_card) { - this.validate_job_card(job_card, "Open", (job_card, qty) => { - this.start_job(job_card); - }); - - this.start_job_qrcode.set_value(""); - } - }, 300); - }); - - this.complete_job_qrcode = frappe.ui.form.make_control({ - df: { - label: __("Complete Job"), - fieldtype: "Data", - options: "Barcode", - placeholder: __("Scan Job Card Qrcode"), - }, - parent: this.$wrapper.find(".qrcode-fields"), - render_input: true, - }); - - this.complete_job_qrcode.$input.on("input", (e) => { - clearTimeout(this.complete_job_qrcode_search); - this.complete_job_qrcode_search = setTimeout(() => { - let job_card = this.complete_job_qrcode.get_value(); - if (job_card) { - this.validate_job_card(job_card, "Work In Progress", (job_card, qty) => { - this.complete_job(job_card, qty); - }); - - this.complete_job_qrcode.set_value(""); - } - }, 300); - }); - - this.complete_job_qrcode.$wrapper.addClass("form-column col-sm-6"); - } - - validate_job_card(job_card, status, callback) { - frappe.call({ - method: "erpnext.manufacturing.doctype.workstation.workstation.validate_job_card", - args: { - job_card: job_card, - status: status, - }, - callback(r) { - callback(job_card, r.message); - }, - }); - } - - setup_menu_actions() { - let me = this; - this.job_cards.forEach((data) => { - me.menu_btns = me.$wrapper.find(`.job-card-link[data-name='${data.name}']`); - - $(me.menu_btns).find(".btn-resume").hide(); - $(me.menu_btns).find(".btn-pause").hide(); - $(me.menu_btns).find(".btn-complete .btn").attr("disabled", true); - - if ( - data.for_quantity + data.process_loss_qty > data.total_completed_qty && - (data.skip_material_transfer || - data.transferred_qty >= data.for_quantity + data.process_loss_qty || - !data.finished_good) - ) { - if (!data.time_logs?.length) { - $(me.menu_btns).find(".btn-start").show(); - } else if (data.is_paused) { - $(me.menu_btns).find(".btn-start").hide(); - $(me.menu_btns).find(".btn-resume").show(); - } else if (data.for_quantity - data.manufactured_qty > 0) { - $(me.menu_btns).find(".btn-start").hide(); - if (!data.is_paused) { - $(me.menu_btns).find(".btn-pause").show(); - } - - $(me.menu_btns).find(".btn-complete").show(); - $(me.menu_btns).find(".btn-complete .btn").attr("disabled", false); - } - } - }); - } - - toggle_job_card() { - this.$wrapper.find(".collapse-indicator-job").on("click", (e) => { - $(e.currentTarget) - .closest(".form-dashboard-section") - .find(".section-body-job-card") - .toggleClass("hide"); - if ( - $(e.currentTarget) - .closest(".form-dashboard-section") - .find(".section-body-job-card") - .hasClass("hide") - ) - $(e.currentTarget).html(frappe.utils.icon("chevron-down", "sm", "mb-1")); - else $(e.currentTarget).html(frappe.utils.icon("chevron-up", "sm", "mb-1")); - }); - } - - bind_events() { - let me = this; - - this.$wrapper.find(".btn-transfer-materials").on("click", (e) => { - let job_card = $(e.currentTarget).closest("ul").attr("data-job-card"); - this.make_material_request(job_card); - }); - - this.$wrapper.find(".btn-start").on("click", (e) => { - let job_card = $(e.currentTarget).closest("div").attr("data-job-card"); - this.start_job(job_card); - }); - - this.$wrapper.find(".btn-pause").on("click", (e) => { - let job_card = $(e.currentTarget).closest("div").attr("data-job-card"); - me.update_job_card(job_card, "pause_job", { - end_time: frappe.datetime.now_datetime(), - }); - }); - - this.$wrapper.find(".btn-resume").on("click", (e) => { - let job_card = $(e.currentTarget).closest("div").attr("data-job-card"); - me.update_job_card(job_card, "resume_job", { - start_time: frappe.datetime.now_datetime(), - }); - }); - - this.$wrapper.find(".btn-complete").on("click", (e) => { - let job_card = $(e.currentTarget).closest("div").attr("data-job-card"); - let for_quantity = $(e.currentTarget).attr("data-qty"); - me.complete_job(job_card, for_quantity); - }); - } - - start_job(job_card) { - let me = this; - - let fields = this.get_fields_for_employee(); - - this.employee_dialog = frappe.prompt(fields, (values) => { - me.update_job_card(job_card, "start_timer", values); - }); - - let default_employee = this.job_cards[0]?.user_employee; - if (default_employee) { - this.employee_dialog.fields_dict.employees.df.data.push({ - employee: default_employee, - }); - this.employee_dialog.fields_dict.employees.grid.refresh(); - } - } - - complete_job(job_card, for_quantity) { - frappe.prompt( - { - fieldname: "qty", - label: __("Completed Quantity"), - fieldtype: "Float", - reqd: 1, - default: flt(for_quantity || 0), - }, - (data) => { - if (flt(data.qty) <= 0) { - frappe.throw(__("Quantity should be greater than 0")); - } - - this.update_job_card(job_card, "complete_job_card", { - qty: flt(data.qty), - end_time: frappe.datetime.now_datetime(), - auto_submit: 1, - }); - }, - __("Enter Value"), - __("Submit") - ); - } - - get_fields_for_employee() { - let me = this; - - return [ - { - label: __("Start Time"), - fieldname: "start_time", - fieldtype: "Datetime", - default: frappe.datetime.now_datetime(), - }, - { - label: __("Employee"), - fieldname: "employee", - fieldtype: "Link", - options: "Employee", - change() { - let employee = this.get_value(); - let employees = me.employee_dialog.fields_dict.employees.df.data; - - if (employee) { - let employee_exists = employees.find((d) => d.employee === employee); - - if (!employee_exists) { - me.employee_dialog.fields_dict.employees.df.data.push({ - employee: employee, - }); - - me.employee_dialog.fields_dict.employees.grid.refresh(); - } - } - }, - }, - { fieldtype: "Section Break" }, - { - label: __("Employees"), - fieldname: "employees", - fieldtype: "Table", - data: [], - cannot_add_rows: 1, - cannot_delete_rows: 1, - fields: [ - { - label: __("Employee"), - fieldname: "employee", - fieldtype: "Link", - options: "Employee", - in_list_view: 1, - }, - ], - }, - ]; - } - - update_job_card(job_card, method, data) { - let 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, - auto_submit: data.auto_submit || 0, - }, - callback: () => { - $.each(me.timer_job_cards, (index, value) => { - clearInterval(value); - }); - - me.frm.reload_doc(); - }, - }); - } - - make_material_request(job_card) { - let me = this; - frappe.call({ - method: "erpnext.manufacturing.doctype.workstation.workstation.get_raw_materials", - args: { - job_card: job_card, - }, - callback: (r) => { - if (r.message) { - me.prepare_materials_modal(r.message, job_card, (job_card) => { - frappe.call({ - method: "erpnext.manufacturing.doctype.job_card.mapper.make_stock_entry", - args: { - source_name: job_card, - }, - callback: (r) => { - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", doc[0].doctype, doc[0].name); - }, - }); - }); - } - }, - }); - } - - prepare_materials_modal(raw_materials, job_card, callback) { - let fields = this.get_raw_material_fields(raw_materials); - - this.materials_dialog = new frappe.ui.Dialog({ - title: "Raw Materials", - fields: fields, - size: "large", - primary_action_label: __("Make Transfer Entry"), - primary_action: () => { - this.materials_dialog.hide(); - callback(job_card); - }, - }); - - raw_materials.forEach((row) => { - this.materials_dialog.fields_dict.items.df.data.push(row); - }); - - this.materials_dialog.fields_dict.items.grid.refresh(); - this.materials_dialog.show(); - } - - get_raw_material_fields(raw_materials) { - return [ - { - label: __("Warehouse"), - fieldname: "warehouse", - fieldtype: "Link", - options: "Warehouse", - read_only: 1, - default: raw_materials[0].warehouse, - }, - { fieldtype: "Column Break" }, - { - label: __("Skip Material Transfer"), - fieldname: "skip_material_transfer", - fieldtype: "Check", - read_only: 1, - default: raw_materials[0].skip_material_transfer, - }, - { fieldtype: "Section Break" }, - { - label: __("Raw Materials"), - fieldname: "items", - fieldtype: "Table", - cannot_add_rows: 1, - cannot_delete_rows: 1, - data: [], - size: "extra-large", - fields: [ - { - label: __("Item Code"), - fieldname: "item_code", - fieldtype: "Link", - options: "Item", - in_list_view: 1, - read_only: 1, - columns: 2, - }, - { - label: __("UOM"), - fieldname: "uom", - fieldtype: "Link", - options: "UOM", - in_list_view: 1, - read_only: 1, - columns: 1, - }, - { - label: __("Reqired Qty"), - fieldname: "required_qty", - fieldtype: "Float", - in_list_view: 1, - read_only: 1, - columns: 2, - }, - { - label: __("Transferred Qty"), - fieldname: "transferred_qty", - fieldtype: "Float", - in_list_view: 1, - read_only: 1, - columns: 2, - }, - { - label: __("Available Qty"), - fieldname: "stock_qty", - fieldtype: "Float", - in_list_view: 1, - read_only: 1, - columns: 2, - }, - { - label: __("Available"), - fieldname: "material_availability_status", - fieldtype: "Check", - in_list_view: 1, - read_only: 1, - columns: 1, - }, - ], - }, - ]; - } - - prepare_timer() { - this.job_cards.forEach((data) => { - if (data.time_logs?.length) { - data._current_time = this.get_current_time(data); - if (data.time_logs[cint(data.time_logs.length) - 1].to_time || data.is_paused) { - this.updateStopwatch(data); - } else { - this.initialiseTimer(data); - } - } - }); - } - - update_job_card_details() { - let color_map = { - Pending: "var(--bg-blue)", - "In Process": "var(--bg-yellow)", - Submitted: "var(--bg-blue)", - Open: "var(--bg-gray)", - Closed: "var(--bg-green)", - "Work In Progress": "var(--bg-orange)", - }; - - this.job_cards.forEach((data) => { - let job_card_selector = this.$wrapper.find(` - [data-name='${data.name}']`); - - $(job_card_selector).find(".job-card-status").text(data.status); - - ["blue", "gray", "green", "orange", "yellow"].forEach((color) => { - $(job_card_selector).find(".job-card-status").removeClass(color); - }); - - $(job_card_selector).find(".job-card-status").addClass(data.status_color); - $(job_card_selector).find(".job-card-status").css("backgroundColor", color_map[data.status]); - }); - } - - initialiseTimer(data) { - let timeout = setInterval(() => { - data._current_time += 1; - this.updateStopwatch(data); - }, 1000); - - this.timer_job_cards[data.name] = timeout; - } - - updateStopwatch(data) { - let increment = data._current_time; - let hours = Math.floor(increment / 3600); - let minutes = Math.floor((increment - hours * 3600) / 60); - let seconds = cint(increment - hours * 3600 - minutes * 60); - - let job_card_selector = `[data-job-card='${data.name}']`; - let timer_selector = this.$wrapper.find(job_card_selector); - - $(timer_selector) - .find(".hours") - .text(hours < 10 ? "0" + hours.toString() : hours.toString()); - $(timer_selector) - .find(".minutes") - .text(minutes < 10 ? "0" + minutes.toString() : minutes.toString()); - $(timer_selector) - .find(".seconds") - .text(seconds < 10 ? "0" + seconds.toString() : seconds.toString()); - } - - get_current_time(data) { - let current_time = 0.0; - data.time_logs.forEach((d) => { - if (d.to_time) { - if (d.time_in_mins) { - current_time += flt(d.time_in_mins, 2) * 60; - } else { - current_time += this.get_seconds_diff(d.to_time, d.from_time); - } - } else { - current_time += this.get_seconds_diff(frappe.datetime.now_datetime(), d.from_time); - } - }); - - return current_time; - } - - get_seconds_diff(d1, d2) { - return moment(d1).diff(d2, "seconds"); - } -} diff --git a/erpnext/manufacturing/doctype/workstation/workstation.json b/erpnext/manufacturing/doctype/workstation/workstation.json index 81b83d9066b..7189dee0b30 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.json +++ b/erpnext/manufacturing/doctype/workstation/workstation.json @@ -8,9 +8,6 @@ "document_type": "Setup", "engine": "InnoDB", "field_order": [ - "dashboard_tab", - "section_break_mqqv", - "workstation_dashboard", "details_tab", "workstation_name", "workstation_type", @@ -173,12 +170,6 @@ "fieldtype": "Float", "label": "Total Working Hours" }, - { - "depends_on": "eval:!doc.__islocal", - "fieldname": "dashboard_tab", - "fieldtype": "Tab Break", - "label": "Job Cards" - }, { "fieldname": "details_tab", "fieldtype": "Tab Break", @@ -190,16 +181,6 @@ "label": "Connections", "show_dashboard": 1 }, - { - "fieldname": "workstation_dashboard", - "fieldtype": "HTML", - "label": "Workstation Dashboard" - }, - { - "fieldname": "section_break_mqqv", - "fieldtype": "Section Break", - "hide_border": 1 - }, { "default": "0", "fieldname": "disabled", diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 8069a476e15..64be85f6a2f 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -250,77 +250,6 @@ class Workstation(Document): return doc -@frappe.whitelist() -def get_job_cards(workstation: str): - if frappe.has_permission("Job Card", "read"): - jc_data = frappe.get_all( - "Job Card", - fields=[ - "name", - "production_item", - "work_order", - "operation", - "total_completed_qty", - "for_quantity", - "process_loss_qty", - "finished_good", - "transferred_qty", - "status", - "expected_start_date", - "expected_end_date", - "time_required", - "wip_warehouse", - "skip_material_transfer", - "backflush_from_wip_warehouse", - "is_paused", - "manufactured_qty", - ], - filters={ - "workstation": workstation, - "is_subcontracted": 0, - "docstatus": ("<", 2), - "status": ["not in", ["Completed", "Stopped"]], - }, - order_by="expected_start_date, expected_end_date", - limit=10, - ) - - job_cards = [row.name for row in jc_data] - time_logs = get_time_logs(job_cards) - - allow_excess_transfer = frappe.db.get_single_value( - "Manufacturing Settings", "job_card_excess_transfer" - ) - - user_employee = frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name") - - for row in jc_data: - if row.status == "Open": - row.status = "Not Started" - - item_code = row.finished_good or row.production_item - row.fg_uom = frappe.get_cached_value("Item", item_code, "stock_uom") - - row.status_colour = get_status_color(row.status) - row.job_card_link = f""" - {row.name} - """ - - row.operation_link = f""" - {row.operation} - """ - row.work_order_link = get_link_to_form("Work Order", row.work_order) - - row.time_logs = time_logs.get(row.name, []) - row.make_material_request = False - if row.for_quantity > row.transferred_qty or allow_excess_transfer: - row.make_material_request = True - - row.user_employee = user_employee - - return jc_data - - def get_status_color(status): color_map = { "Pending": "blue", @@ -328,7 +257,9 @@ def get_status_color(status): "Submitted": "blue", "Open": "gray", "Closed": "green", + "Completed": "green", "Work In Progress": "orange", + "To Manufacture": "purple", } return color_map.get(status, "blue") diff --git a/erpnext/manufacturing/page/shop_floor/__init__.py b/erpnext/manufacturing/page/shop_floor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/manufacturing/page/shop_floor/shop_floor.js b/erpnext/manufacturing/page/shop_floor/shop_floor.js new file mode 100644 index 00000000000..8aae5efab24 --- /dev/null +++ b/erpnext/manufacturing/page/shop_floor/shop_floor.js @@ -0,0 +1,26 @@ +frappe.pages["shop-floor"].on_page_load = function (wrapper) { + const page = frappe.ui.make_app_page({ + parent: wrapper, + title: __("Shop Floor"), + single_column: true, + // Kiosk feel — drop the desk sidebar so the floor view owns the screen. + hide_sidebar: true, + }); + + frappe.shop_floor = new frappe.ui.ShopFloor( + { wrapper: $(wrapper).find(".layout-main-section") }, + wrapper.page + ); +}; + +// Pick up filters passed in via frappe.route_options (e.g. the "Shop Floor" button on Work Order) +// and switch the body into immersive (full-screen) mode while the page is shown. +// on_page_show fires on every navigation, so it also works when the page is already cached. +// (Frappe has no on_page_hide hook — the class itself drops the body class + keyboard binding +// on the next route change, see ShopFloor.bind_lifecycle.) +frappe.pages["shop-floor"].on_page_show = function () { + $(document.body).addClass("shop-floor-active"); + if (frappe.shop_floor && frappe.shop_floor.on_show) { + frappe.shop_floor.on_show(); + } +}; diff --git a/erpnext/manufacturing/page/shop_floor/shop_floor.json b/erpnext/manufacturing/page/shop_floor/shop_floor.json new file mode 100644 index 00000000000..61425c52b51 --- /dev/null +++ b/erpnext/manufacturing/page/shop_floor/shop_floor.json @@ -0,0 +1,32 @@ +{ + "content": null, + "creation": "2026-05-30 10:00:00", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-06-05 00:24:12.148085", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "shop-floor", + "owner": "Administrator", + "page_name": "shop-floor", + "roles": [ + { + "role": "Manufacturing User" + }, + { + "role": "Manufacturing Manager" + }, + { + "role": "Shop Floor Manager" + }, + { + "role": "Shop Floor User" + } + ], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0, + "title": "Shop Floor" +} diff --git a/erpnext/manufacturing/page/shop_floor/shop_floor.py b/erpnext/manufacturing/page/shop_floor/shop_floor.py new file mode 100644 index 00000000000..16b3fb1a84d --- /dev/null +++ b/erpnext/manufacturing/page/shop_floor/shop_floor.py @@ -0,0 +1,812 @@ +import frappe +from frappe import _ +from frappe.query_builder import Order +from frappe.query_builder.functions import Count, Date +from frappe.utils import cint, flt, get_datetime, getdate, now_datetime, time_diff_in_seconds + +from erpnext.manufacturing.doctype.workstation.workstation import ( + get_status_color, + get_time_logs, +) +from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import ( + get_template_details, +) + +JOB_CARD_FIELDS = [ + "name", + "docstatus", + "production_item", + "work_order", + "operation", + "total_completed_qty", + "for_quantity", + "process_loss_qty", + "finished_good", + "transferred_qty", + "status", + "expected_start_date", + "expected_end_date", + "time_required", + "wip_warehouse", + "skip_material_transfer", + "backflush_from_wip_warehouse", + "is_paused", + "manufactured_qty", + "is_subcontracted", + "workstation", + "sequence_id", + "bom_no", + "operation_id", + "quality_inspection", + "quality_inspection_template", +] + +TODAY_SESSION_FIELDS = [ + "name", + "docstatus", + "production_item", + "finished_good", + "operation", + "total_completed_qty", + "for_quantity", + "process_loss_qty", + "total_time_in_mins", + "status", + "modified", +] + +# Roles that unlock the Shop Floor manager board (work-order overview). Anyone else gets the +# operator view. System Manager is included so admins always see the full picture. +MANAGER_ROLES = {"Shop Floor Manager", "Manufacturing Manager", "System Manager"} + +# Maps the three manager buckets to the underlying Work Order statuses. +WORK_ORDER_STATUS_GROUPS = { + "in_progress": ["In Process"], + "pending": ["Not Started", "Submitted", "Stock Reserved", "Stock Partially Reserved"], + "completed": ["Completed"], +} + +WORK_ORDER_FIELDS = [ + "name", + "production_item", + "item_name", + "qty", + "produced_qty", + "status", + "planned_start_date", + "sales_order", + "bom_no", +] + + +@frappe.whitelist() +def submit_job_card(job_card: str): + """Submit a draft job card whose quantity has already been recorded via End Session.""" + frappe.has_permission("Job Card", "submit", throw=True) + jc = frappe.get_doc("Job Card", job_card) + if jc.docstatus == 0: + jc.submit() + return {"name": jc.name, "docstatus": jc.docstatus} + + +def _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time): + """Record the session qty + close the active time log via Job Card's complete_job_card. + + auto_submit=0 so the backend doesn't auto-create+submit a Manufacture Stock Entry — that's + a separate manual step driven by the post-submit prompt. Returns the reloaded doc. + """ + frappe.has_permission("Job Card", "write", throw=True) + + doc = frappe.get_doc("Job Card", job_card) + doc.run_method( + "complete_job_card", + qty=flt(qty), + for_quantity=flt(for_quantity), + pending_qty=flt(pending_qty), + process_loss_qty=flt(process_loss_qty), + end_time=end_time, + auto_submit=0, + ) + doc.reload() + return doc + + +@frappe.whitelist() +def save_and_continue( + job_card: str, + qty: float, + for_quantity: float, + pending_qty: float, + process_loss_qty: float, + end_time: str, +): + """Record the session qty + close the time log, then mark the JC as paused + so the MES keeps it in the active slot for the next session.""" + frappe.has_permission("Job Card", "write", throw=True) + doc = _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time) + if doc.docstatus == 0: + doc.db_set("is_paused", 1) + return {"name": doc.name} + + +@frappe.whitelist() +def complete_and_submit( + job_card: str, + qty: float, + for_quantity: float, + pending_qty: float, + process_loss_qty: float, + end_time: str, +): + """Record the session qty + close the time log + submit the JC.""" + frappe.has_permission("Job Card", "submit", throw=True) + doc = _record_session(job_card, qty, for_quantity, pending_qty, process_loss_qty, end_time) + if doc.docstatus == 0: + doc.submit() + return {"name": doc.name, "finished_good": doc.finished_good} + + +@frappe.whitelist() +def make_manufacture_stock_entry(job_card: str): + """Build a "Manufacture" Stock Entry for the finished goods and save as draft. + + Mirrors the Job Card form's "Make Stock Entry" button — uses the doc's own + make_stock_entry_for_semi_fg_item (purpose="Manufacture", job card linked) rather than + the generic make_stock_entry, which would produce a "Material Transfer for Manufacture". + Returns the draft SE name so the client can open it in a new tab. + """ + frappe.has_permission("Job Card", "read", throw=True) + frappe.has_permission("Stock Entry", "submit", throw=True) + doc = frappe.get_doc("Job Card", job_card) + se = doc.make_stock_entry_for_semi_fg_item(auto_submit=False) + return {"name": se.get("name")} + + +@frappe.whitelist() +def get_quality_inspection_checklist(job_card: str): + """Template parameters for the inline quality check an operator fills before submitting a + job card. Returns the resolved template, its parameter rows, any already-linked inspection, + and the item being inspected. + """ + frappe.has_permission("Job Card", "read", throw=True) + + jc = frappe.db.get_value( + "Job Card", + job_card, + [ + "quality_inspection", + "quality_inspection_template", + "operation", + "production_item", + "finished_good", + ], + as_dict=True, + ) + if not jc: + frappe.throw(_("Job Card {0} not found").format(job_card)) + + template = jc.quality_inspection_template + if not template and jc.operation: + template = frappe.get_cached_value("Operation", jc.operation, "quality_inspection_template") + + parameters = [] + for p in get_template_details(template): + parameters.append( + { + "specification": p.specification, + "value": p.value, + "numeric": cint(p.numeric), + "min_value": p.min_value, + "max_value": p.max_value, + "formula_based_criteria": cint(p.formula_based_criteria), + "acceptance_formula": p.acceptance_formula, + } + ) + + return { + "template": template, + "parameters": parameters, + "existing": jc.quality_inspection, + "item_code": jc.finished_good or jc.production_item, + } + + +@frappe.whitelist() +def submit_quality_inspection(job_card: str, readings: str | None = None): + """Create + submit an In-Process Quality Inspection for the job card and link it back, so the + standard Job Card.validate_inspection() gate passes when the card is submitted. + + `readings` is a JSON list of {specification, status, reading_value} captured inline. For numeric + / formula parameters the measured value is stored and the Quality Inspection auto-evaluates + pass/fail against min/max (or the formula); for qualitative parameters the operator's explicit + Accepted/Rejected is taken as authoritative (manual_inspection). The QI's own validation then + sets the overall Accepted/Rejected status. + """ + frappe.has_permission("Job Card", "write", throw=True) + frappe.has_permission("Quality Inspection", "submit", throw=True) + + jc = frappe.get_doc("Job Card", job_card) + + # Idempotent: if a submitted inspection is already linked, don't create another. + if jc.quality_inspection: + existing = frappe.db.get_value( + "Quality Inspection", jc.quality_inspection, ["status", "docstatus"], as_dict=True + ) + if existing and existing.docstatus == 1: + return {"name": jc.quality_inspection, "status": existing.status} + + template = jc.quality_inspection_template + if not template and jc.operation: + template = frappe.get_cached_value("Operation", jc.operation, "quality_inspection_template") + if not template: + frappe.throw(_("No Quality Inspection Template is configured for this operation.")) + + reading_map = {r.get("specification"): r for r in (frappe.parse_json(readings) or [])} + + qi = frappe.new_doc("Quality Inspection") + qi.inspection_type = "In Process" + qi.reference_type = "Job Card" + qi.reference_name = job_card + qi.item_code = jc.finished_good or jc.production_item + qi.bom_no = jc.bom_no + qi.quality_inspection_template = template + qi.inspected_by = frappe.session.user + qi.get_item_specification_details() # load readings from the template + + for reading in qi.readings: + entry = reading_map.get(reading.specification) + if not entry: + continue + value = entry.get("reading_value") + if reading.numeric or reading.formula_based_criteria: + # Measured value → let the Quality Inspection judge it against min/max or the formula. + if value not in (None, ""): + reading.reading_value = value + reading.reading_1 = value + else: + # Qualitative check → the operator's explicit pass/fail wins. + reading.manual_inspection = 1 + reading.status = entry.get("status") or "Accepted" + if value not in (None, ""): + reading.reading_value = value + + qi.insert() + qi.submit() # validate() → inspect_and_set_status() sets the overall Accepted/Rejected status + + # Link explicitly: the QI's own back-reference matches on production_item, which can differ + # from the operation's finished_good, so we set it directly to be safe. + jc.db_set("quality_inspection", qi.name) + return {"name": qi.name, "status": qi.status} + + +@frappe.whitelist() +def get_shop_floor_context(): + """Which experience to render — the manager board or the operator view — plus the + signed-in operator's Employee (so Start Job can pre-fill them).""" + roles = set(frappe.get_roles()) + can_manage = bool(roles & MANAGER_ROLES) + return { + "role_view": "manager" if can_manage else "operator", + "can_manage": can_manage, + "user_employee": frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name"), + } + + +@frappe.whitelist() +def get_work_orders( + status_group: str, + start: int = 0, + page_length: int = 20, + search: str | None = None, + with_job_cards_only: bool | int = 0, +): + """Paginated Work Orders for one manager bucket (in_progress / pending / completed), + each decorated with a job-card status breakdown for the card's progress chip. + + When `with_job_cards_only` is set, only Work Orders that have at least one (non-cancelled) + Job Card are returned — the board's opt-in "With job cards only" toggle. + """ + frappe.has_permission("Work Order", "read", throw=True) + + statuses = WORK_ORDER_STATUS_GROUPS.get(status_group) + if not statuses: + frappe.throw(_("Invalid status group: {0}").format(status_group)) + + start = cint(start) + page_length = cint(page_length) or 20 + with_job_cards_only = cint(with_job_cards_only) + + # Active buckets show the oldest-planned first (work the floor next); completed shows newest first. + order = Order.desc if status_group == "completed" else Order.asc + + wo = frappe.qb.DocType("Work Order") + query = _apply_work_order_filters(frappe.qb.from_(wo), wo, statuses, search, with_job_cards_only) + work_orders = ( + query.select(*[wo[field] for field in WORK_ORDER_FIELDS]) + .orderby(wo.planned_start_date, order=order) + .limit(page_length) + .offset(start) + ).run(as_dict=True) + + total = _count_work_orders(statuses, search, with_job_cards_only) + + _enrich_work_orders(work_orders) + + return { + "work_orders": work_orders, + "total": cint(total), + "start": start, + "page_length": page_length, + } + + +def _apply_work_order_filters(query, wo, statuses, search, with_job_cards_only): + """Shared WHERE clauses for the board's row + count queries (bucket, search, job-card toggle).""" + query = query.where((wo.docstatus == 1) & (wo.status.isin(statuses))) + if search: + like = f"%{search}%" + query = query.where(wo.name.like(like) | wo.production_item.like(like) | wo.item_name.like(like)) + if with_job_cards_only: + jc = frappe.qb.DocType("Job Card") + wo_with_job_cards = frappe.qb.from_(jc).select(jc.work_order).where(jc.docstatus < 2) + query = query.where(wo.name.isin(wo_with_job_cards)) + return query + + +def _count_work_orders(statuses: list[str], search: str | None, with_job_cards_only: int = 0) -> int: + """Total Work Orders in a bucket (drives pagination), honouring the same filters as the rows.""" + wo = frappe.qb.DocType("Work Order") + query = _apply_work_order_filters(frappe.qb.from_(wo), wo, statuses, search, with_job_cards_only) + return cint(query.select(Count("*")).run()[0][0]) + + +def _enrich_work_orders(work_orders: list[dict]) -> None: + """Attach item/workstation image, status colour, % complete and a job-card breakdown to each row.""" + wo_names = [row.name for row in work_orders] + jc_counts = _get_job_card_status_counts(wo_names) + workstation_map = _get_current_workstation_map(wo_names) + for row in work_orders: + row.item_image = ( + frappe.get_cached_value("Item", row.production_item, "image") if row.production_item else None + ) + row.status_colour = get_status_color(row.status) + row.per_completed = round(flt(row.produced_qty) / flt(row.qty) * 100, 1) if flt(row.qty) else 0 + counts = jc_counts.get(row.name, {}) + row.job_card_status = counts.get("by_status", {}) + row.total_operations = counts.get("total", 0) + row.completed_operations = counts.get("completed", 0) + row.in_progress_operations = counts.get("in_progress", 0) + # Two segments for the card's progress bar: green (done) + orange (in progress); the rest + # of the track stays grey (pending / not started). + row.per_operations = ( + round(row.completed_operations / row.total_operations * 100, 1) if row.total_operations else 0 + ) + row.per_in_progress = ( + round(row.in_progress_operations / row.total_operations * 100, 1) if row.total_operations else 0 + ) + # Current/active operation's workstation (name + Active-Status image) for the card header. + workstation = workstation_map.get(row.name, {}) + row.workstation = workstation.get("workstation") + row.workstation_name = workstation.get("workstation_name") + row.workstation_image = workstation.get("image") + row.current_operation = workstation.get("operation") + + +def _get_current_workstation_map(wo_names: list[str]) -> dict[str, dict]: + """Map each Work Order to its current operation's workstation (name + Active-Status image). + + The "current" operation is the first not-yet-completed operation in the routing (by idx); + if every operation is complete, the last one is used so finished cards still show a workstation. + """ + if not wo_names: + return {} + + operations = frappe.get_all( + "Work Order Operation", + filters={"parent": ["in", wo_names]}, + fields=["parent", "idx", "operation", "status", "workstation"], + order_by="parent, idx", + ) + + ops_by_wo: dict[str, list] = {} + for op in operations: + ops_by_wo.setdefault(op.parent, []).append(op) + + chosen_by_wo = {} + workstations = set() + for wo_name, ops in ops_by_wo.items(): + current = next((op for op in ops if (op.status or "") != "Completed"), ops[-1]) + if current.workstation: + chosen_by_wo[wo_name] = current + workstations.add(current.workstation) + + ws_details = {} + if workstations: + for ws in frappe.get_all( + "Workstation", + filters={"name": ["in", list(workstations)]}, + fields=["name", "workstation_name", "on_status_image"], + ): + ws_details[ws.name] = ws + + result = {} + for wo_name, op in chosen_by_wo.items(): + detail = ws_details.get(op.workstation, {}) + result[wo_name] = { + "workstation": op.workstation, + "workstation_name": detail.get("workstation_name") or op.workstation, + "image": detail.get("on_status_image"), + "operation": op.operation, + } + return result + + +def _get_job_card_status_counts(wo_names: list[str]) -> dict[str, dict]: + """One batched query → {work_order: {by_status: {status: n}, total, completed}} for the WO cards.""" + if not wo_names: + return {} + + rows = frappe.get_all( + "Job Card", + filters={"work_order": ["in", wo_names], "docstatus": ["<", 2]}, + fields=["work_order", "status"], + ) + result: dict[str, dict] = {} + for row in rows: + entry = result.setdefault( + row.work_order, {"by_status": {}, "total": 0, "completed": 0, "in_progress": 0} + ) + status = "Not Started" if (row.status or "Open") == "Open" else row.status + entry["by_status"][status] = entry["by_status"].get(status, 0) + 1 + entry["total"] += 1 + # "To Manufacture" = operation done, only the Manufacture Stock Entry is pending — count it + # as completed so the work order's progress bar reflects the finished operation. + if status in ("Completed", "Submitted", "To Manufacture"): + entry["completed"] += 1 + elif status == "Work In Progress": + entry["in_progress"] += 1 + return result + + +@frappe.whitelist() +def get_data(workstation: str | None = None, work_order: str | None = None): + """ + Returns job-card data for the Shop Floor page. + + When `work_order` is set it wins over `workstation` and the result spans every + operation of that Work Order (including subcontracted job cards). When only + `workstation` is set, the result is the open + in-progress job cards for that + workstation, excluding subcontracted. + """ + if not (workstation or work_order): + return {"job_cards": [], "capacity": 1, "mode": None} + if not frappe.has_permission("Job Card", "read"): + return {"job_cards": [], "capacity": 1, "mode": None} + + filters, mode = _build_job_card_filters(workstation, work_order) + jc_data = _fetch_job_cards(filters, mode) + _enrich_job_cards(jc_data) + + capacity, oee = 1, None + if mode == "workstation": + capacity = frappe.db.get_value("Workstation", workstation, "production_capacity") or 1 + oee = get_workstation_oee(workstation) + + return { + "job_cards": jc_data, + "capacity": capacity, + "mode": mode, + "oee": oee, + "user_employee": frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name"), + "today_sessions": get_today_sessions(workstation, work_order), + } + + +def _build_job_card_filters(workstation, work_order): + """Filters + mode for the job-card query. work_order spans all ops; workstation is operator view.""" + filters = {"docstatus": ("<", 2)} + if work_order: + filters["work_order"] = work_order + return filters, "work_order" + + filters["workstation"] = workstation + filters["is_subcontracted"] = 0 + filters["status"] = ["!=", "Stopped"] + return filters, "workstation" + + +def _fetch_job_cards(filters, mode): + """Job cards matching filters. In workstation mode only drafts matter — submitted JCs are + done from MES's perspective and missed ones are picked up via the standard Job Card list. + + In work_order mode the whole routing is shown (incl. completed/submitted job cards), ordered + by the operation sequence so the operator reads them in manufacturing order. + """ + order_by = ( + "sequence_id asc, expected_start_date, expected_end_date" + if mode == "work_order" + else "expected_start_date, expected_end_date" + ) + # Drafts are the operator's working set; submitted "To Manufacture" cards are also kept so + # the station shows what still needs a Manufacture Stock Entry (its own section, client-side). + # This must be part of the query, not a post-filter: a busy workstation's history would + # otherwise fill the row limit with old submitted cards and hide the active drafts. + or_filters = [["docstatus", "=", 0], ["status", "=", "To Manufacture"]] if mode == "workstation" else None + return frappe.get_all( + "Job Card", + fields=JOB_CARD_FIELDS, + filters=filters, + or_filters=or_filters, + order_by=order_by, + limit=50, + ) + + +def _enrich_job_cards(jc_data): + """Decorate every row with display + material-availability data for the page.""" + job_card_names = [row.name for row in jc_data] + time_logs = get_time_logs(job_card_names) if job_card_names else {} + allow_excess_transfer = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer") + for row in jc_data: + _enrich_job_card_row(row, time_logs, allow_excess_transfer) + + +def _enrich_job_card_row(row, time_logs, allow_excess_transfer): + """Attach status label, item image/uom, time logs and material availability to one row.""" + if row.status == "Open": + row.status = "Not Started" + + item_code = row.finished_good or row.production_item + row.fg_uom = frappe.get_cached_value("Item", item_code, "stock_uom") if item_code else None + row.item_image = frappe.get_cached_value("Item", item_code, "image") if item_code else None + row.status_colour = get_status_color(row.status) + row.time_logs = time_logs.get(row.name, []) + row.make_material_request = bool(row.for_quantity > row.transferred_qty or allow_excess_transfer) + # Required vs transferred + on-hand in source — operator sees shortages before starting work. + row.materials = get_job_card_materials(row.name) + # Guided execution: per-operation work instructions + quality-check state for the card. + row.instructions = _get_operation_instructions(row.operation) + row.qc = _get_job_card_qc(row) + + +def _get_operation_instructions(operation: str | None) -> dict | None: + """Description + rich Work Instructions from the Operation master, for the card's + Instructions panel. Returns None when the operation has neither, so the panel stays hidden. + + `work_instruction` is a Text Editor field (HTML) — Frappe bleach-sanitizes it on save, so it + is safe to render as-is on the client. `description` is plain text and must be escaped there. + """ + if not operation: + return None + + op = frappe.get_cached_value("Operation", operation, ["description", "work_instruction"], as_dict=True) + if not op: + return None + + description = (op.description or "").strip() + work_instruction = (op.work_instruction or "").strip() + if not description and not work_instruction: + return None + return {"description": description, "work_instruction": work_instruction} + + +def _get_job_card_qc(row) -> dict: + """Quality-check state for a job card row: whether an inspection is required before submit, + which template to use, and any inspection already linked (name + status + docstatus). + + "Required" mirrors Job Card.validate_inspection() — BOM inspection_required AND the Work Order + Operation's quality_inspection_required. When only a template is configured the check is + offered but not enforced. + """ + required = bool( + row.get("bom_no") + and frappe.get_cached_value("BOM", row.bom_no, "inspection_required") + and row.get("operation_id") + and frappe.db.get_value("Work Order Operation", row.operation_id, "quality_inspection_required") + ) + + template = row.get("quality_inspection_template") + if not template and row.get("operation"): + template = frappe.get_cached_value("Operation", row.operation, "quality_inspection_template") + + info = { + "required": required, + "template": template, + "has_checklist": bool(template), + "name": None, + "status": None, + "docstatus": None, + } + if row.get("quality_inspection"): + qi = frappe.db.get_value( + "Quality Inspection", row.quality_inspection, ["name", "status", "docstatus"], as_dict=True + ) + if qi: + info.update({"name": qi.name, "status": qi.status, "docstatus": qi.docstatus}) + return info + + +def get_job_card_materials(job_card: str) -> list[dict]: + """Required vs transferred + on-hand stock for each raw material in the source warehouse. + + Powers the active-job Materials side panel — operator sees shortages before starting work. + """ + items = frappe.get_all( + "Job Card Item", + filters={"parent": job_card}, + fields=["item_code", "item_name", "source_warehouse", "required_qty", "transferred_qty", "uom"], + order_by="idx", + ) + if not items: + return [] + + on_hand_map = _get_on_hand_map(items) + return [_build_material_row(it, on_hand_map) for it in items] + + +def _get_on_hand_map(items) -> dict[tuple[str, str], float]: + """Map (item_code, warehouse) → on-hand qty via batched Bin lookups.""" + pairs = {(it.item_code, it.source_warehouse) for it in items if it.source_warehouse} + if not pairs: + return {} + + bin_rows = frappe.get_all( + "Bin", + filters={ + "item_code": ["in", list({p[0] for p in pairs})], + "warehouse": ["in", list({p[1] for p in pairs})], + }, + fields=["item_code", "warehouse", "actual_qty"], + ) + return {(b.item_code, b.warehouse): flt(b.actual_qty) for b in bin_rows} + + +def _build_material_row(it, on_hand_map) -> dict: + """One material entry with shortage + status pill for the side panel.""" + required = flt(it.required_qty) + transferred = flt(it.transferred_qty) + on_hand = on_hand_map.get((it.item_code, it.source_warehouse), 0.0) + shortage = max(required - transferred, 0.0) + if transferred >= required: + status = "ready" + elif on_hand >= shortage: + status = "available" + else: + status = "short" + return { + "item_code": it.item_code, + "item_name": it.item_name or it.item_code, + "source_warehouse": it.source_warehouse, + "required_qty": required, + "transferred_qty": transferred, + "on_hand_qty": on_hand, + "shortage": shortage, + "uom": it.uom or "", + "status": status, + } + + +def get_today_sessions(workstation: str | None, work_order: str | None) -> list[dict]: + """Submitted job cards finalized today — used for the bottom 'Today's Sessions' strip. + + Filtered on docstatus=1 only (draft/cancelled excluded). The status pill follows the + job card's own status (e.g. Work In Progress → orange, Completed → green). + """ + filters = _today_sessions_filters(workstation, work_order) + if filters is None: + return [] + + rows = frappe.get_all( + "Job Card", + filters=filters, + fields=TODAY_SESSION_FIELDS, + order_by="modified desc", + limit=10, + ) + for r in rows: + item_code = r.finished_good or r.production_item + r.item_image = frappe.get_cached_value("Item", item_code, "image") if item_code else None + r.status_colour = get_status_color(r.status) + return rows + + +def _today_sessions_filters(workstation, work_order) -> dict | None: + """Submitted-today filter scoped to a work order or workstation; None if neither given. + + "To Manufacture" cards are excluded — they aren't finalized yet (Manufacture Stock Entry + pending) and get their own section, so they shouldn't appear among finished sessions. + """ + filters = { + "docstatus": 1, + "modified": [">=", get_datetime(f"{getdate()} 00:00:00")], + "status": ["!=", "To Manufacture"], + } + if work_order: + filters["work_order"] = work_order + elif workstation: + filters["workstation"] = workstation + else: + return None + return filters + + +def get_workstation_oee(workstation: str) -> dict | None: + """ + OEE = Availability X Performance X Quality, computed for today only. + + Caveat: without a downtime-reason capture step, Availability is just + (actual_run_time / scheduled_time) — it cannot distinguish planned breaks + from unplanned breakdowns. The number is directional, not audit-grade. + """ + today = getdate() + scheduled_min = flt(frappe.db.get_value("Workstation", workstation, "total_working_hours")) * 60 + actual_run_min, ideal_min = _get_run_and_ideal_minutes(workstation, today) + completed_jcs = _get_completed_jcs_today(workstation, today) + + # No activity at all today — nothing to display. + if actual_run_min == 0 and not completed_jcs: + return None + return _build_oee(scheduled_min, actual_run_min, ideal_min, completed_jcs) + + +def _get_run_and_ideal_minutes(workstation, today) -> tuple[float, float]: + """Sum actual run minutes (clipped to today) and the ideal minutes for produced qty.""" + today_start = get_datetime(f"{today} 00:00:00") + today_end = get_datetime(f"{today} 23:59:59") + now = now_datetime() + actual_run_min = 0.0 + ideal_min = 0.0 + for log in _get_oee_time_logs(workstation, today_start, today_end): + # Clip the log's interval to today's window for fair attribution. + start = max(get_datetime(log.from_time), today_start) + end = min(get_datetime(log.to_time) if log.to_time else now, today_end) + if end > start: + actual_run_min += time_diff_in_seconds(end, start) / 60 + if log.completed_qty and log.for_quantity and log.time_required: + ideal_min += (flt(log.time_required) / flt(log.for_quantity)) * flt(log.completed_qty) + return actual_run_min, ideal_min + + +def _get_oee_time_logs(workstation, today_start, today_end) -> list[dict]: + """Job Card time logs whose interval overlaps today's window.""" + tl = frappe.qb.DocType("Job Card Time Log") + jc = frappe.qb.DocType("Job Card") + return ( + frappe.qb.from_(tl) + .inner_join(jc) + .on(jc.name == tl.parent) + .select(tl.from_time, tl.to_time, tl.completed_qty, jc.for_quantity, jc.time_required) + .where(jc.workstation == workstation) + .where(jc.docstatus < 2) + .where(tl.from_time <= today_end) + .where(tl.to_time.isnull() | (tl.to_time >= today_start)) + ).run(as_dict=True) + + +def _get_completed_jcs_today(workstation, today) -> list[dict]: + """Job cards completed/submitted today — process loss is finalized at submission.""" + jc = frappe.qb.DocType("Job Card") + return ( + frappe.qb.from_(jc) + .select(jc.total_completed_qty, jc.process_loss_qty) + .where(jc.workstation == workstation) + .where(jc.status.isin(["Completed", "Submitted"])) + .where(Date(jc.modified) == today) + ).run(as_dict=True) + + +def _build_oee(scheduled_min, actual_run_min, ideal_min, completed_jcs) -> dict: + """Combine the three OEE factors into the response payload.""" + total_completed = sum(flt(j.total_completed_qty) for j in completed_jcs) + total_loss = sum(flt(j.process_loss_qty) for j in completed_jcs) + availability = min(actual_run_min / scheduled_min, 1.0) if scheduled_min > 0 else None + performance = min(ideal_min / actual_run_min, 1.0) if actual_run_min > 0 else 0.0 + quality = max(total_completed - total_loss, 0.0) / total_completed if total_completed > 0 else 1.0 + # OEE requires all three factors; without a schedule, Availability is unknown. + oee_val = round(availability * performance * quality * 100, 1) if availability is not None else None + return { + "oee": oee_val, + "availability": round(availability * 100, 1) if availability is not None else None, + "performance": round(performance * 100, 1), + "quality": round(quality * 100, 1), + } diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9b13bbfefd1..5a6f2222435 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -493,3 +493,4 @@ erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb erpnext.patches.v16_0.set_default_close_opportunity_after_days execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) erpnext.patches.v16_0.backfill_pick_list_transferred_qty +erpnext.patches.v16_0.create_shop_floor_roles diff --git a/erpnext/patches/v16_0/create_shop_floor_roles.py b/erpnext/patches/v16_0/create_shop_floor_roles.py new file mode 100644 index 00000000000..6376c87567d --- /dev/null +++ b/erpnext/patches/v16_0/create_shop_floor_roles.py @@ -0,0 +1,5 @@ +from erpnext.setup.install import create_shop_floor_roles + + +def execute(): + create_shop_floor_roles() diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 221e3a62c6a..ec579c459da 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -9,6 +9,8 @@ import "./utils/serial_no_batch_selector"; import "./payment/payments"; import "./templates/visual_plant_floor_template.html"; import "./plant_floor_visual/visual_plant"; +import "./templates/shop_floor_template.html"; +import "./shop_floor/shop_floor"; import "./controllers/taxes_and_totals"; import "./controllers/transaction"; import "./templates/item_selector.html"; diff --git a/erpnext/public/js/plant_floor_visual/visual_plant.js b/erpnext/public/js/plant_floor_visual/visual_plant.js index 75b1aa3479f..de85680a6db 100644 --- a/erpnext/public/js/plant_floor_visual/visual_plant.js +++ b/erpnext/public/js/plant_floor_visual/visual_plant.js @@ -145,6 +145,14 @@ class VisualPlantFloor { }); $(template).appendTo(this.wrapper.find(".plant-floor-container")); + + this.wrapper.find(".workstation-image").on("click", (e) => { + let workstation_name = $(e.currentTarget) + .closest(".workstation-wrapper") + .attr("data-workstation"); + frappe.route_options = { workstation: workstation_name }; + frappe.set_route("shop-floor"); + }); } prepare_menu() { diff --git a/erpnext/public/js/shop_floor/shop_floor.js b/erpnext/public/js/shop_floor/shop_floor.js new file mode 100644 index 00000000000..0ff1fbf8630 --- /dev/null +++ b/erpnext/public/js/shop_floor/shop_floor.js @@ -0,0 +1,1665 @@ +// Shop Floor — an immersive, keyboard-first operator/manager interface. +// +// Two experiences share one app shell (see get_shop_floor_context on the server): +// • manager — a paginated board of work orders bucketed In Progress / Pending / Completed. +// Drilling into a work order opens its job cards in the operator pane. +// • operator — a focused workstation/work-order view to start, pause, complete and submit jobs. +// +// The whole surface is driveable from the keyboard (press ? for the cheat sheet) so an operator +// at a terminal never needs the mouse. + +// Job Card status → indicator colour, mirrored from workstation.get_status_color so the manager +// board can paint per-operation chips without a round-trip. +const JC_STATUS_COLORS = { + Completed: "green", + Submitted: "blue", + "Work In Progress": "orange", + "Material Transferred": "yellow", + "On Hold": "red", + Open: "gray", + "Not Started": "gray", +}; + +const MANAGER_BUCKETS = [ + { key: "in_progress", label: __("In Progress"), dot: "orange" }, + { key: "pending", label: __("Pending"), dot: "blue" }, + { key: "completed", label: __("Completed"), dot: "green" }, +]; + +const PAGE_LENGTH = 20; + +class ShopFloor { + constructor({ wrapper }, page) { + this.wrapper = $(wrapper); + this.page = page; + this.timer_intervals = {}; + this.capacity = 1; + this.mode = null; + // Remembers each Materials panel's open/closed state (keyed by job card) so it + // survives re-renders — otherwise a reload right after a click resets the panel. + this.materials_open = {}; + // Same idea for the per-operation Work Instructions panel. + this.instructions_open = {}; + + // View state. + this.view = "operator"; // overwritten once context loads + this.active_bucket = "in_progress"; + this.with_job_cards_only = true; // board default: hide WOs that have no job cards + this.buckets = {}; // key -> { rows, total, start, loaded } + this.selected_wo = null; + this.focus_index = -1; + this.op_state = { workstation: null, work_order: null }; + + this.make(); + this.bind_realtime(); + this.bind_lifecycle(); + this.init(); + } + + init() { + frappe.call("erpnext.manufacturing.page.shop_floor.shop_floor.get_shop_floor_context").then((r) => { + const ctx = r.message || {}; + this.view = ctx.role_view === "manager" ? "manager" : "operator"; + this.can_manage = !!ctx.can_manage; + this.user_employee = ctx.user_employee || null; + this.render_shell_controls(); + this.render_view(); + this.bind_keys(); + this.initialized = true; + this.apply_route_options(); + }); + } + + // ── App shell ──────────────────────────────────────────────────────────── + make() { + this.wrapper.append(` + ${this.styles()} +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+
+ `); + + this.app = this.wrapper.find(".sf-app"); + this.topbar_left = this.wrapper.find(".sf-topbar-left"); + this.topbar_center = this.wrapper.find(".sf-topbar-center"); + this.body = this.wrapper.find(".sf-body"); + this.board_container = this.wrapper.find(".sf-board"); + this.detail_container = this.wrapper.find(".sf-detail"); + this.op_container = this.wrapper.find(".sf-operator"); + + this.wrapper.find(".sf-btn-home").on("click", () => (window.location.href = "/app")); + this.wrapper.find(".sf-btn-refresh").on("click", () => this.refresh()); + this.wrapper.find(".sf-btn-scan").on("click", () => this.open_scanner()); + this.wrapper.find(".sf-btn-help").on("click", () => this.show_help()); + } + + render_shell_controls() { + this.topbar_left.empty(); + this.topbar_center.empty(); + + // View toggle — only managers can flip between the board and a bare operator view. + const toggle = this.can_manage + ? `
+ + +
` + : ""; + + if (this.view === "manager") { + this.topbar_left.html(` + ${__("Shop Floor")} + ${toggle} +
+ ${MANAGER_BUCKETS.map( + (b) => `` + ).join("")} +
+ `); + this.topbar_center.html(` + + + `); + + this.topbar_left.find(".sf-tab").on("click", (e) => { + this.switch_bucket($(e.currentTarget).attr("data-bucket")); + }); + let timer = null; + this.topbar_center.find(".sf-search-input").on("input", (e) => { + const val = e.target.value; + clearTimeout(timer); + timer = setTimeout(() => this.search_work_orders(val), 300); + }); + this.topbar_center.find(".sf-jc-toggle").on("change", (e) => { + this.toggle_job_cards_only(e.target.checked); + }); + } else { + this.topbar_left.html(`${__("Shop Floor")}${toggle}`); + this.build_operator_filters(); + } + + this.topbar_left.find(".sf-view-btn").on("click", (e) => { + this.set_view($(e.currentTarget).attr("data-view")); + }); + } + + build_operator_filters() { + this.topbar_center.html('
'); + const $filters = this.topbar_center.find(".sf-filters"); + + this.workstation_filter = frappe.ui.form.make_control({ + df: { + fieldtype: "Link", + options: "Workstation", + fieldname: "workstation", + placeholder: __("Machine"), + onchange: () => this.load_operator(), + }, + parent: $filters, + render_input: true, + }); + this.workstation_filter.$wrapper.addClass("sf-filter-control"); + + this.work_order_filter = frappe.ui.form.make_control({ + df: { + fieldtype: "Link", + options: "Work Order", + fieldname: "work_order", + placeholder: __("Work Order"), + onchange: () => this.load_operator(), + }, + parent: $filters, + render_input: true, + }); + this.work_order_filter.$wrapper.addClass("sf-filter-control"); + } + + set_view(view) { + if (!view || view === this.view) return; + this.view = view; + this.selected_wo = null; + this.focus_index = -1; + this.render_shell_controls(); + this.render_view(); + } + + render_view() { + const manager = this.view === "manager"; + this.board_container.toggle(manager); + this.detail_container.toggle(manager && !!this.selected_wo); + this.op_container.toggle(!manager); + this.body.toggleClass("detail-open", manager && !!this.selected_wo); + + if (manager) { + this.load_bucket(this.active_bucket); + } else { + this.load_operator(); + } + } + + // ── Manager board ──────────────────────────────────────────────────────── + switch_bucket(bucket) { + if (!bucket || bucket === this.active_bucket) return; + this.active_bucket = bucket; + this.selected_wo = null; + this.focus_index = -1; + this.topbar_left.find(".sf-tab").removeClass("active"); + this.topbar_left.find(`.sf-tab[data-bucket="${bucket}"]`).addClass("active"); + this.detail_container.hide(); + this.body.removeClass("detail-open"); + this.load_bucket(bucket); + } + + search_work_orders(term) { + this.search_term = term; + // Re-query every bucket from scratch on the next visit; reload the active one now. + this.buckets = {}; + this.load_bucket(this.active_bucket); + } + + toggle_job_cards_only(checked) { + this.with_job_cards_only = !!checked; + // Filter changes every bucket's contents + counts; drop caches and clear stale counts. + this.buckets = {}; + this.topbar_left.find(".sf-tab-count").text(""); + this.load_bucket(this.active_bucket); + } + + load_bucket(bucket, append = false) { + const state = this.buckets[bucket] || { rows: [], total: 0, start: 0, loaded: false }; + const start = append ? state.start : 0; + + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_work_orders", + args: { + status_group: bucket, + start: start, + page_length: PAGE_LENGTH, + search: this.search_term || null, + with_job_cards_only: this.with_job_cards_only ? 1 : 0, + }, + callback: (r) => { + const data = r.message || {}; + const rows = data.work_orders || []; + this.buckets[bucket] = { + rows: append ? state.rows.concat(rows) : rows, + total: cint(data.total), + start: start + rows.length, + loaded: true, + }; + this.update_tab_count(bucket); + if (bucket === this.active_bucket) this.render_board(); + }, + }); + } + + update_tab_count(bucket) { + const state = this.buckets[bucket]; + if (!state) return; + this.topbar_left.find(`[data-bucket-count="${bucket}"]`).text(state.total ? state.total : ""); + } + + render_board() { + const state = this.buckets[this.active_bucket] || { rows: [], total: 0 }; + this.focus_index = -1; + + if (!state.rows.length) { + this.board_container.html(`
${__("No work orders here.")}
`); + return; + } + + const cards = state.rows.map((wo) => this.work_order_card(wo)).join(""); + const more = + state.rows.length < state.total + ? `` + : `
${__("Showing all {0}", [state.total])}
`; + + this.board_container.html( + `
${cards}
${more}
` + ); + + this.board_container.find(".sf-wo-card").on("click", (e) => { + this.open_wo($(e.currentTarget).attr("data-name")); + }); + this.board_container + .find(".sf-load-more") + .on("click", () => this.load_bucket(this.active_bucket, true)); + } + + work_order_card(wo) { + const item = wo.item_name || wo.production_item; + + // Hero image = the current operation's workstation. No item-image fallback — when the + // workstation has no image uploaded we show its initials, never the product image. + const image = wo.workstation_image + ? `` + : `${frappe.get_abbr(wo.workstation_name || item, 2)}`; + + const workstation_line = wo.workstation_name + ? `
🏭 ${frappe.utils.escape_html( + wo.workstation_name + )}${wo.current_operation ? ` · ${frappe.utils.escape_html(wo.current_operation)}` : ""}
` + : ""; + + // Operations bar: green segment (done) + orange segment (in progress); grey track = pending. + const done_pct = Math.min(cint(wo.per_operations), 100); + const wip_pct = Math.min(cint(wo.per_in_progress), 100 - done_pct); + + return ` +
+
+
${image}
+
+
${frappe.utils.escape_html(item)}
+ ${workstation_line} +
+ + ${wo.name} +
+
+
+
+
+ ${__("Operations")} + ${cint(wo.completed_operations)} / ${cint(wo.total_operations)} +
+
+
+
+
+
+
+ `; + } + + open_wo(name) { + if (!name) return; + this.selected_wo = name; + this.op_state = { workstation: null, work_order: name }; + this.detail_container.show(); + this.body.addClass("detail-open"); + this.board_container + .find(".sf-wo-card") + .removeClass("sf-selected") + .filter(`[data-name="${name}"]`) + .addClass("sf-selected"); + // The detail pane reuses the operator rendering for a single work order. + this.detail_container.html(` +
+ + ${frappe.utils.escape_html(name)} + ${__("Open")} +
+
+ `); + this.detail_container.find(".sf-detail-back").on("click", () => this.close_wo()); + this.op_container_target = this.detail_container.find(".sf-detail-body"); + this.load_operator_data(this.op_container_target, { work_order: name }); + } + + close_wo() { + this.selected_wo = null; + this.op_container_target = null; + this.detail_container.hide().empty(); + this.body.removeClass("detail-open"); + this.board_container.find(".sf-wo-card").removeClass("sf-selected"); + } + + // ── Operator pane ────────────────────────────────────────────────────────── + // Resolves the container the operator content renders into: the standalone operator + // view, or the manager's drill-down detail pane. + current_op_container() { + return this.view === "manager" ? this.op_container_target : this.op_container; + } + + load_operator() { + const workstation = this.workstation_filter ? this.workstation_filter.get_value() : null; + const work_order = this.work_order_filter ? this.work_order_filter.get_value() : null; + this.op_state = { workstation, work_order }; + + if (!workstation && !work_order) { + this.clear_timers(); + this.op_container.html( + `
${__("Select a machine or work order to begin")}
` + ); + return; + } + this.load_operator_data(this.op_container, { workstation, work_order }); + } + + load_operator_data($container, { workstation, work_order }) { + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_data", + args: { + workstation: work_order ? null : workstation, + work_order: work_order || null, + }, + callback: (r) => { + const data = r.message || {}; + this.job_cards = data.job_cards || []; + this.capacity = cint(data.capacity) || 1; + this.mode = data.mode || (work_order ? "work_order" : "workstation"); + this.oee = data.oee || null; + if (data.user_employee) this.user_employee = data.user_employee; + this.today_sessions = data.today_sessions || []; + this.workstation = workstation; + this.work_order = work_order; + this.compute_state(); + this.render_operator($container); + }, + }); + } + + // Re-fetch whichever operator content is currently on screen (used after every action). + reload() { + if (this.view === "manager" && this.selected_wo) { + this.load_operator_data(this.op_container_target, { work_order: this.selected_wo }); + // Keep the board chips fresh too. + this.buckets = {}; + this.load_bucket(this.active_bucket); + } else if (this.view === "manager") { + this.load_bucket(this.active_bucket); + } else { + this.load_operator(); + } + } + + refresh() { + if (this.view === "manager") { + this.buckets = {}; + } + this.reload(); + } + + compute_state() { + this.active_jobs = []; + this.queue = []; + this.pending_submission = []; + this.completed = []; + // Submitted but the finished goods aren't booked yet (status "To Manufacture") — its own + // actionable section, kept out of Completed Operations / Today's Sessions. + this.to_manufacture = []; + + for (const jc of this.job_cards) { + // Same materials-ready rule as job_card.js make_dashboard. + jc._materials_ready = !!( + jc.skip_material_transfer || + flt(jc.transferred_qty) >= flt(jc.for_quantity) + flt(jc.process_loss_qty) || + !jc.finished_good + ); + + // Submitted JCs are historical from the Shop Floor's POV — only appear here in work_order + // mode (and, for "To Manufacture", in workstation mode too — see _fetch_job_cards). + if (jc.docstatus === 1) { + if (jc.status === "To Manufacture") { + this.to_manufacture.push(jc); + } else { + this.completed.push(jc); + } + continue; + } + + 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 && !jc.is_paused; + const is_paused = jc.is_paused; + + if (is_running || is_paused) { + this.active_jobs.push(jc); + } else if (jc.status === "Completed") { + // All qty accounted for but still draft — waiting on Submit. + this.pending_submission.push(jc); + } else { + this.queue.push(jc); + } + } + + // Slot rules — all active jobs are always shown; the grid (col-md-6) wraps them 2 per row. + // workstation mode: capacity-many slots, expanded to fit every active job (+ empty placeholders). + // work_order mode: one slot per active job (no empty placeholders). + let slot_count; + if (this.mode === "work_order") { + slot_count = this.active_jobs.length; + } else { + slot_count = Math.max(this.capacity, this.active_jobs.length, 1); + } + + this.slots = []; + for (let i = 0; i < slot_count; i++) { + this.slots.push(this.active_jobs[i] || null); + } + + // Auto-pick: when nothing is running, surface the next queue item in the slot. + if (this.active_jobs.length === 0 && this.queue.length > 0) { + const next_up = this.queue.shift(); + next_up._is_next_up = true; + this.slots[0] = next_up; + } + + this.summary = { + active_count: this.active_jobs.length, + // "To Manufacture" (submitted, qty done, but the Manufacture Stock Entry is still pending) + // isn't actually finished — count it as Pending, not Completed. + queue_count: this.queue.length + this.to_manufacture.length, + completed_count: this.completed.length + this.pending_submission.length, + capacity: this.capacity, + }; + } + + render_operator($container) { + this.clear_timers(); + $container.empty(); + + const html = frappe.render_template("shop_floor_template", { + workstation: this.workstation, + work_order: this.work_order, + mode: this.mode, + slots: this.slots, + active_jobs: this.active_jobs, + queue: this.queue, + pending_submission: this.pending_submission, + to_manufacture: this.to_manufacture, + completed: this.completed, + today_sessions: this.today_sessions || [], + summary: this.summary, + oee: this.oee, + }); + $container.html(html); + + // Restore each Materials panel to its remembered open/closed state. + $container.find(".mes-materials-inline").each((i, el) => { + const $el = $(el); + const name = $el.attr("data-job-card"); + if (!name) return; + if (name in this.materials_open) { + $el.toggleClass("is-open", this.materials_open[name]); + } else { + this.materials_open[name] = $el.hasClass("is-open"); + } + }); + + // Restore each Work Instructions panel to its remembered open/closed state. + $container.find(".mes-instructions-inline").each((i, el) => { + const $el = $(el); + const name = $el.attr("data-job-card"); + if (name && name in this.instructions_open) { + $el.toggleClass("is-open", this.instructions_open[name]); + } + }); + + this.bind_events($container); + + for (const jc of this.active_jobs) { + if (jc.is_paused) { + this.render_timer(jc.name, this.elapsed_seconds(jc), $container); + } else { + this.start_timer_for(jc, $container); + } + } + } + + clear_timers() { + for (const id of Object.values(this.timer_intervals)) { + clearInterval(id); + } + this.timer_intervals = {}; + } + + bind_events($container) { + const me = this; + + $container.find(".mes-materials-summary").on("click", function (e) { + if ($(e.target).closest(".mes-btn-transfer").length) return; + const $inline = $(this).closest(".mes-materials-inline"); + const open = !$inline.hasClass("is-open"); + $inline.toggleClass("is-open", open); + const name = $inline.attr("data-job-card"); + if (name) me.materials_open[name] = open; + }); + + $container.find(".mes-instructions-summary").on("click", function () { + const $inline = $(this).closest(".mes-instructions-inline"); + const open = !$inline.hasClass("is-open"); + $inline.toggleClass("is-open", open); + const name = $inline.attr("data-job-card"); + if (name) me.instructions_open[name] = open; + }); + + // Clicking a "QC Required" / "QC Available" pill runs the inline check ahead of End Session. + $container.find(".mes-qc-pill").on("click", function () { + const name = $(this).attr("data-job-card"); + const jc = (me.active_jobs || []).find((j) => j.name === name); + if (jc) me.run_quality_check(jc, () => me.reload()); + }); + + $container.find(".mes-btn-start").on("click", function () { + me.start_job($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-pause").on("click", function () { + me.pause_job($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-resume").on("click", function () { + me.resume_job($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-end-session").on("click", function () { + me.end_session($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-submit").on("click", function () { + me.submit_job_card($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-make-entry").on("click", function () { + me.make_manufacture_entry($(this).attr("data-job-card")); + }); + $container.find(".mes-btn-transfer").on("click", function (e) { + e.preventDefault(); + me.transfer_materials($(this).attr("data-job-card")); + }); + } + + // ── Operator actions (unchanged behaviour, reload() instead of load()) ───── + start_job(job_card) { + const me = this; + if (this.mode === "workstation" && this.active_jobs.length >= this.capacity) { + frappe.msgprint({ + title: __("Capacity Reached"), + message: __( + "This machine can run at most {0} job(s) in parallel. Pause or complete a running job before starting another.", + [this.capacity] + ), + indicator: "orange", + }); + return; + } + + const default_employee = this.user_employee; + const dialog = new frappe.ui.Dialog({ + title: __("Start Job"), + fields: [ + { + label: __("Start Time"), + fieldname: "start_time", + fieldtype: "Datetime", + default: frappe.datetime.now_datetime(), + }, + { fieldtype: "Section Break" }, + { + label: __("Employees"), + fieldname: "employees", + fieldtype: "Table", + data: default_employee ? [{ employee: default_employee }] : [], + fields: [ + { + label: __("Employee"), + fieldname: "employee", + fieldtype: "Link", + options: "Employee", + in_list_view: 1, + }, + ], + }, + ], + primary_action_label: __("Start"), + primary_action: (values) => { + dialog.hide(); + me.update_job_card(job_card, "start_timer", { + start_time: values.start_time, + employees: values.employees || [], + }); + }, + }); + dialog.show(); + this.bind_enter_submit(dialog); + } + + // Make a dialog fully keyboard-operable: Enter triggers the primary action, so an operator + // never has to reach for the mouse. Enter is left alone inside multi-line fields and while an + // autocomplete (Link/Select) dropdown is open, so it can still pick a value. + bind_enter_submit(dialog) { + dialog.$wrapper.on("keydown.sfenter", (e) => { + if (e.key !== "Enter" || e.shiftKey) return; + if ($(e.target).is("textarea")) return; + if ($(".awesomplete > ul:not([hidden])").length) return; + const $btn = dialog.get_primary_btn(); + if ( + $btn && + $btn.length && + $btn.is(":visible") && + !$btn.hasClass("disabled") && + !$btn.prop("disabled") + ) { + e.preventDefault(); + e.stopPropagation(); + $btn.trigger("click"); + } + }); + } + + pause_job(jc_name) { + this.update_job_card(jc_name, "pause_job", { end_time: frappe.datetime.now_datetime() }); + } + + resume_job(jc_name) { + this.update_job_card(jc_name, "resume_job", { start_time: frappe.datetime.now_datetime() }); + } + + end_session(jc_name) { + const me = this; + const jc = this.active_jobs.find((j) => j.name === jc_name); + if (!jc) return; + + let pending = flt(jc.for_quantity) - flt(jc.total_completed_qty); + if (flt(jc.pending_qty) > 0) { + pending = flt(jc.pending_qty); + } + + const fields = [ + { + fieldtype: "Float", + label: __("Qty to Manufacture"), + fieldname: "for_quantity", + reqd: 1, + default: pending, + change() { + const d = me.session_dialog; + d.set_value("completed_qty", d.get_value("for_quantity")); + d.set_value("process_loss_qty", 0); + }, + }, + { + fieldtype: "Float", + label: __("Completed Quantity"), + fieldname: "completed_qty", + reqd: 1, + default: pending, + change() { + const d = me.session_dialog; + const remaining = flt(d.get_value("for_quantity")) - flt(d.get_value("completed_qty")); + if (remaining > 0 && remaining !== flt(d.get_value("pending_qty"))) { + d.set_value("pending_qty", remaining); + } + }, + }, + { + fieldtype: "Float", + label: __("Pending Quantity"), + fieldname: "pending_qty", + default: 0.0, + change() { + const d = me.session_dialog; + const pl = + flt(d.get_value("for_quantity")) - + flt(d.get_value("completed_qty")) - + flt(d.get_value("pending_qty")); + if (pl >= 0 && pl !== flt(d.get_value("process_loss_qty"))) { + d.set_value("process_loss_qty", pl); + } + }, + }, + { + fieldtype: "Float", + label: __("Process Loss Quantity"), + fieldname: "process_loss_qty", + default: 0.0, + change() { + const d = me.session_dialog; + const remaining = + flt(d.get_value("for_quantity")) - + flt(d.get_value("completed_qty")) - + flt(d.get_value("process_loss_qty")); + if (remaining >= 0 && remaining !== flt(d.get_value("pending_qty"))) { + d.set_value("pending_qty", remaining); + } + }, + }, + { fieldtype: "Section Break" }, + { + fieldtype: "Datetime", + label: __("End Time"), + fieldname: "end_time", + default: frappe.datetime.now_datetime(), + }, + ]; + + const get_payload = () => { + const data = me.session_dialog.get_values(); + if (!data) return null; + if (flt(data.completed_qty) <= 0) { + frappe.throw(__("Completed Quantity should be greater than 0")); + } + return { + job_card: jc.name, + qty: flt(data.completed_qty), + for_quantity: flt(data.for_quantity), + pending_qty: flt(data.pending_qty), + process_loss_qty: flt(data.process_loss_qty), + end_time: data.end_time, + }; + }; + + const save_and_continue = () => { + const args = get_payload(); + if (!args) return; + me.session_dialog.hide(); + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.save_and_continue", + args: args, + freeze: true, + freeze_message: __("Saving job card..."), + callback: () => me.reload(), + }); + }; + + const finalize_submit = (args) => { + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.complete_and_submit", + args: args, + freeze: true, + freeze_message: __("Submitting job card..."), + callback: (r) => { + me.reload(); + if (r.message && r.message.finished_good) { + me.prompt_manufacture_entry(jc.name); + } + }, + }); + }; + + const submit_session = () => { + const args = get_payload(); + if (!args) return; + me.session_dialog.hide(); + // Guided QC gate: a job card that requires inspection must pass an inline Quality Check + // before it is submitted (mirrors Job Card.validate_inspection on the server). Once the + // inspection is recorded, finalize the session submit. + if (jc.qc && jc.qc.required && jc.qc.status !== "Accepted") { + me.run_quality_check(jc, () => finalize_submit(args)); + } else { + finalize_submit(args); + } + }; + + me.session_dialog = new frappe.ui.Dialog({ + title: __("End Session"), + fields: fields, + primary_action_label: __("Submit"), + primary_action: submit_session, + secondary_action_label: __("Save & Continue"), + secondary_action: save_and_continue, + }); + me.session_dialog.show(); + me.bind_enter_submit(me.session_dialog); + } + + // ── Inline Quality Check ───────────────────────────────────────────────────── + // Fetch the operation's Quality Inspection template and open a guided pass/fail checklist. + // `on_pass` runs once the inspection has been recorded (and is not rejected). + run_quality_check(jc, on_pass) { + const me = this; + frappe.call({ + method: "erpnext.manufacturing.page.shop_floor.shop_floor.get_quality_inspection_checklist", + args: { job_card: jc.name }, + freeze: true, + freeze_message: __("Loading quality checklist..."), + callback: (r) => { + const info = r.message || {}; + if (!info.template || !(info.parameters || []).length) { + // Inspection is required but the operation has no template/parameters to fill — + // there is nothing to capture inline. Point the user at the configuration. + frappe.msgprint({ + title: __("Quality Inspection Template Missing"), + indicator: "orange", + message: __( + "This operation requires a Quality Inspection but no template with parameters is configured. Set a Quality Inspection Template on Operation {0} to inspect from the Shop Floor.", + [jc.operation || ""] + ), + }); + return; + } + me.show_qc_dialog(jc, info, on_pass); + }, + }); + } + + show_qc_dialog(jc, info, on_pass) { + const me = this; + const params = info.parameters || []; + // Per-row operator input, keyed by row index (avoids escaping issues with parameter names). + const state = {}; // idx -> "Accepted" | "Rejected" + + const rows = params + .map((p, i) => { + const spec = frappe.utils.escape_html(p.specification); + let criteria = ""; + if (p.numeric) { + const lo = p.min_value !== null && p.min_value !== undefined ? p.min_value : "−∞"; + const hi = p.max_value !== null && p.max_value !== undefined ? p.max_value : "∞"; + criteria = __("Acceptable range: {0} to {1}", [lo, hi]); + } else if (p.value) { + criteria = __("Expected: {0}", [frappe.utils.escape_html(p.value)]); + } + const control = p.numeric + ? `` + : ` + + + `; + return `
+
+
${spec}
+ ${criteria ? `
${criteria}
` : ""} +
+
${control}
+
`; + }) + .join(""); + + const dialog = new frappe.ui.Dialog({ + title: __("Quality Check"), + size: "large", + fields: [ + { + fieldtype: "HTML", + options: `
${__( + "Inspect {0} for job card {1}", + [frappe.utils.escape_html(info.item_code || ""), frappe.utils.escape_html(jc.name)] + )}
${rows}
`, + }, + ], + 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": + case "3": + if (this.view === "manager") { + 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 / 3", __("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), + ])} +
+ `, + }, + ], + primary_action_label: __("Complete"), + primary_action: () => { + dialog.hide(); + me.end_session(jc.name); + }, + secondary_action_label: __("Pause"), + secondary_action: () => { + dialog.hide(); + me.pause_job(jc.name); + }, + }); + dialog.show(); + this.bind_enter_submit(dialog); + } + + // ── Route options (e.g. the Work Order "Shop Floor" button) ──────────────── + apply_route_options() { + const opts = frappe.route_options; + if (!opts || (!opts.work_order && !opts.workstation)) { + return; + } + frappe.route_options = null; + + // A specific work order / machine was requested — show it in the operator view. + this.view = "operator"; + this.render_shell_controls(); + this.render_view(); + Promise.all([ + this.work_order_filter.set_value(opts.work_order || ""), + this.workstation_filter.set_value(opts.workstation || ""), + ]).then(() => this.load_operator()); + } + + // ── Styles ────────────────────────────────────────────────────────────────── + styles() { + return ``; + } +} + +frappe.ui.ShopFloor = ShopFloor; diff --git a/erpnext/public/js/templates/shop_floor_template.html b/erpnext/public/js/templates/shop_floor_template.html new file mode 100644 index 00000000000..62e9184cef6 --- /dev/null +++ b/erpnext/public/js/templates/shop_floor_template.html @@ -0,0 +1,1059 @@ + + + +
+ {{ mode === 'work_order' ? work_order : workstation }} + + + + {{ summary.active_count }}{% if (mode === 'workstation' && summary.capacity > 1) { %}/{{ summary.capacity }}{% } %} {{ __("Active") }} + + + + + {{ summary.queue_count }} {{ mode === 'work_order' ? __("Pending") : __("In Queue") }} + + + + + {{ summary.completed_count }} {{ __("Completed") }} + + {% if (mode === 'workstation' && oee) { %} + + + OEE {{ oee.oee !== null && oee.oee !== undefined ? oee.oee + '%' : '–' }} + + · {{ oee.availability !== null && oee.availability !== undefined ? 'A ' + oee.availability : 'A –' }} · P {{ oee.performance }} · Q {{ oee.quality }} + + + {% } %} +
+ + +{% if (slots.length > 0) { %} +
+ {% slots.forEach((slot) => { %} +
+
+ {% if (slot && slot._is_next_up) { %} +
+
+ {% if (slot.item_image) { %} + + {% } else { %} + {{ frappe.get_abbr(slot.finished_good || slot.production_item, 2) }} + {% } %} +
+
+
{{ slot.finished_good || slot.production_item }}
+
+ {% if (slot.is_subcontracted) { %} + {{ __("Subcontract") }} + {% } %} + {{ slot.name }} +
+
+ {{ slot.operation }} · {{ format_number(slot.for_quantity, null, 0) }} {{ slot.fg_uom || '' }} + {% if (slot.work_order) { %} · {{ slot.work_order }}{% } %} +
+
+
+ {% if (slot._materials_ready) { %} + + {% } else { %} + + {% } %} +
+
+ {% } else if (slot) { %} +
+
+
+
+ {% if (slot.item_image) { %} + + {% } else { %} + {{ frappe.get_abbr(slot.finished_good || slot.production_item, 2) }} + {% } %} +
+
+
{{ slot.finished_good || slot.production_item }}
+
+ {{ slot.name }} + {{ __(slot.status) }} + {% if (slot.is_subcontracted) { %} + {{ __("Subcontract") }} + {% } %} + {% if (slot.qc && slot.qc.status === 'Accepted') { %} + {{ __("QC Passed") }} + {% } else if (slot.qc && slot.qc.status === 'Rejected') { %} + {{ __("QC Rejected") }} + {% } else if (slot.qc && slot.qc.required) { %} + {{ __("QC Required") }} + {% } else if (slot.qc && slot.qc.has_checklist) { %} + {{ __("QC Available") }} + {% } %} +
+
+ {{ slot.operation }} · {{ format_number(slot.total_completed_qty, null, 0) }}/{{ format_number(slot.for_quantity, null, 0) }} {{ slot.fg_uom || '' }} + {% if (slot.work_order) { %} · {{ slot.work_order }}{% } %} +
+
+
+
+
+ 00:00:00 +
+
+ {% if (slot.is_paused) { %} + + {% } else { %} + + + {% } %} +
+
+
+
+ {% } else { %} +
+ {{ __("Slot available — start a job from the queue.") }} +
+ {% } %} + + {% if (slot && slot.materials && slot.materials.length > 0) { %} + {% var ready_n = slot.materials.filter((x) => x.status === 'ready').length; %} + {% var avail_n = slot.materials.filter((x) => x.status === 'available').length; %} + {% var short_n = slot.materials.filter((x) => x.status === 'short').length; %} +
+
+ + + {{ frappe.utils.icon("es-line-down", "sm") }} + {{ __("Materials") }} + {{ slot.materials.length }} + + {% if (ready_n > 0) { %}{% } %} + {% if (avail_n > 0) { %}{% } %} + {% if (short_n > 0) { %}{% } %} + + + {% if (slot.make_material_request && !slot._is_next_up) { %} + + {% } %} + +
+
+ {% slot.materials.forEach((m) => { %} + {% var color = m.status === 'ready' ? 'green' : (m.status === 'available' ? 'yellow' : 'red'); %} + {% var label = m.status === 'ready' ? __('Ready') : (m.status === 'available' ? __('Available') : __('Short')); %} +
+
+
{{ m.item_name }}
+
{{ m.item_code }}{% if (m.source_warehouse) { %} · {{ m.source_warehouse }}{% } %}
+
+
+
{{ format_number(m.transferred_qty, null, 2) }} / {{ format_number(m.required_qty, null, 2) }} {{ m.uom }}
+ {% if (m.status !== 'ready') { %} +
{{ __("In source") }}: {{ format_number(m.on_hand_qty, null, 2) }}
+ {% } %} +
+ {{ label }} +
+ {% }); %} +
+
+ {% } %} + + {% if (slot && slot.instructions) { %} +
+
+ {{ frappe.utils.icon("es-line-down", "sm") }} + {{ __("Work Instructions") }} +
+
+ {% if (slot.instructions.description) { %} + +
{{ frappe.utils.escape_html(slot.instructions.description) }}
+ {% } %} + {% if (slot.instructions.work_instruction) { %} + +
{%= slot.instructions.work_instruction %}
+ {% } %} +
+
+ {% } %} +
+
+ {% }); %} +
+{% } else if (!(completed && completed.length) && !(pending_submission && pending_submission.length) && !(to_manufacture && to_manufacture.length) && !(queue && queue.length) && !(today_sessions && today_sessions.length)) { %} +
+ {{ __("No active jobs and the queue is empty.") }} +
+{% } %} + + +{% if (pending_submission && pending_submission.length > 0) { %} +
+
+
+
{{ __("Ready to Submit") }}
+
+ {{ pending_submission.length === 1 ? __("1 draft job card awaiting submission") : __("{0} draft job cards awaiting submission", [pending_submission.length]) }} +
+
+
+
+ {% pending_submission.forEach((jc) => { %} +
+
+ {{ __("Qty Done") }} +
+
+
+ {% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %} +
+
{{ jc.finished_good || jc.production_item }}
+
+
{{ jc.operation }}
+ +
+ {{ format_number(jc.total_completed_qty, null, 0) }} / {{ format_number(jc.for_quantity, null, 0) }} + {{ jc.fg_uom || '' }} +
+
+
+ +
+
+ {% }); %} +
+
+{% } %} + + +{% if (to_manufacture && to_manufacture.length > 0) { %} +
+
+
+
{{ __("To Manufacture") }}
+
+ {{ to_manufacture.length === 1 ? __("1 job card awaiting Manufacture entry") : __("{0} job cards awaiting Manufacture entry", [to_manufacture.length]) }} +
+
+
+
+ {% to_manufacture.forEach((jc) => { %} +
+
+ {{ __("To Manufacture") }} +
+
+
+ {% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %} +
+
{{ jc.finished_good || jc.production_item }}
+
+
{{ jc.operation }}
+ +
+ {{ format_number(jc.total_completed_qty, null, 0) }} / {{ format_number(jc.for_quantity, null, 0) }} + {{ jc.fg_uom || '' }} +
+
+
+ +
+
+ {% }); %} +
+
+{% } %} + + +{% if (queue.length > 0) { %} +
+
+
+
{{ __("Up Next") }}
+
+ {{ queue.length === 1 ? __("1 pending job card") : __("{0} pending job cards", [queue.length]) }} +
+
+
+
+ {% queue.forEach((jc) => { %} +
+
+ {{ __(jc.status) }} + {% if (jc.is_subcontracted) { %}{{ __("Sub") }}{% } %} +
+
+
+ {% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %} +
+
{{ jc.finished_good || jc.production_item }}
+
+
{{ jc.operation }}
+ +
+ {{ format_number(jc.for_quantity, null, 0) }} + {{ jc.fg_uom || '' }} +
+
+ {{ jc._materials_ready ? __("Materials Ready") : __("Awaiting Transfer") }} +
+
+ {% if (jc._materials_ready) { %} + + {% } else { %} + + {% } %} +
+
+ {% }); %} +
+
+{% } %} + + +{% if (completed && completed.length > 0) { %} +
+
+
+
{{ __("Completed Operations") }}
+
+ {{ completed.length === 1 ? __("1 completed job card") : __("{0} completed job cards", [completed.length]) }} +
+
+
+
+ {% completed.forEach((jc) => { %} +
+
+ {{ __(jc.status) }} + {% if (jc.is_subcontracted) { %}{{ __("Sub") }}{% } %} +
+
+
+ {% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %} +
+
{{ jc.finished_good || jc.production_item }}
+
+
{{ jc.operation }}
+ +
+ {{ format_number(jc.total_completed_qty, null, 0) }} / {{ format_number(jc.for_quantity, null, 0) }} + {{ jc.fg_uom || '' }} +
+
+
+
+ {% }); %} +
+
+{% } %} + + +{% if (today_sessions && today_sessions.length > 0) { %} +
+
+
+
{{ __("Today's Sessions") }}
+
+ {{ today_sessions.length === 1 ? __("1 submitted today") : __("{0} submitted today", [today_sessions.length]) }} +
+
+
+
+ {% today_sessions.forEach((s) => { %} + {% var mins = cint(s.total_time_in_mins); var hh = Math.floor(mins / 60); var mm = mins % 60; var dur = mins ? (hh ? hh + 'h ' : '') + mm + 'm' : '—'; %} +
+
+ {{ __(s.status) }} +
+
+
+ {% if (s.item_image) { %}{% } else { %}{{ frappe.get_abbr(s.finished_good || s.production_item, 2) }}{% } %} +
+
{{ s.finished_good || s.production_item }}
+
+
{{ s.operation }}
+ +
+ {{ format_number(s.total_completed_qty, null, 0) }} / {{ format_number(s.for_quantity, null, 0) }} + {% if (cint(s.process_loss_qty) > 0) { %}
{{ __("Loss") }}: {{ format_number(s.process_loss_qty, null, 0) }}
{% } %} +
+
{{ __("Duration") }}: {{ dur }}
+
+
+ {% }); %} +
+
+{% } %} diff --git a/erpnext/public/js/templates/visual_plant_floor_template.html b/erpnext/public/js/templates/visual_plant_floor_template.html index 273a5406eeb..9e4e867fcc2 100644 --- a/erpnext/public/js/templates/visual_plant_floor_template.html +++ b/erpnext/public/js/templates/visual_plant_floor_template.html @@ -5,7 +5,7 @@ {{row.status}} -
+
{% if(row.status_image) { %} diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index a9604a53656..48dea538517 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -21,6 +21,8 @@ def after_install(): if not frappe.db.exists("Role", "Analytics"): frappe.get_doc({"doctype": "Role", "role_name": "Analytics"}).insert() + create_shop_floor_roles() + set_single_defaults() setup_repost_defaults() create_print_setting_custom_fields() @@ -50,6 +52,15 @@ def make_default_operations(): doc.insert(ignore_permissions=True) +def create_shop_floor_roles(): + """Roles that drive the Shop Floor page's two experiences (manager board vs operator view).""" + for role_name in ("Shop Floor Manager", "Shop Floor User"): + if not frappe.db.exists("Role", role_name): + frappe.get_doc({"doctype": "Role", "role_name": role_name, "desk_access": 1}).insert( + ignore_permissions=True + ) + + def set_single_defaults(): for dt in ( "Accounts Settings", diff --git a/erpnext/workspace_sidebar/manufacturing.json b/erpnext/workspace_sidebar/manufacturing.json index c952513398a..c824681aa6d 100644 --- a/erpnext/workspace_sidebar/manufacturing.json +++ b/erpnext/workspace_sidebar/manufacturing.json @@ -15,6 +15,7 @@ "label": "Home", "link_to": "Manufacturing", "link_type": "Workspace", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, @@ -27,42 +28,49 @@ "label": "Dashboard", "link_to": "Manufacturing", "link_type": "Dashboard", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 0, "collapsible": 1, + "default_workspace": 0, "icon": "list-tree", "indent": 0, "keep_closed": 0, "label": "BOM", "link_to": "BOM", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 0, "collapsible": 1, + "default_workspace": 0, "icon": "factory", "indent": 0, "keep_closed": 0, "label": "Work Order", "link_to": "Work Order", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 0, "collapsible": 1, + "default_workspace": 0, "icon": "person-standing", "indent": 0, "keep_closed": 0, "label": "Job Card", "link_to": "Job Card", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, @@ -75,85 +83,112 @@ "label": "Stock Entry", "link_to": "Stock Entry", "link_type": "DocType", + "open_in_new_tab": 0, + "show_arrow": 0, + "type": "Link" + }, + { + "default_workspace": 0, + "icon": "hammer", + "indent": 0, + "keep_closed": 0, + "label": "Shop Floor", + "link_to": "shop-floor", + "link_type": "Page", + "open_in_new_tab": 1, "show_arrow": 0, "type": "Link" }, { "child": 0, "collapsible": 1, + "default_workspace": 0, "icon": "rocket", "indent": 1, "keep_closed": 1, "label": "Material Planning", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Section Break" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "icon": "", "indent": 0, "keep_closed": 0, "label": "Item Lead Time", "link_to": "Item Lead Time", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Production Plan", "link_to": "Production Plan", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "icon": "", "indent": 0, "keep_closed": 0, "label": "Forecasting", "link_to": "Exponential Smoothing Forecasting", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Master Production Schedule", "link_to": "Master Production Schedule", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Sales Forecast", "link_to": "Sales Forecast", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Production Planning Report", "link_to": "Production Planning Report", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, @@ -165,268 +200,315 @@ "keep_closed": 1, "label": "Tools", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Section Break" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "BOM Creator", "link_to": "BOM Creator", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "BOM Update Tool", "link_to": "BOM Update Tool", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "BOM Comparison Tool", "link_to": "bom-comparison-tool", "link_type": "Page", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Downtime Entry", "link_to": "Downtime Entry", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 0, "collapsible": 1, + "default_workspace": 0, "icon": "notepad-text", "indent": 1, "keep_closed": 1, "label": "Reports", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Section Break" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Production Planning Report", "link_to": "Production Planning Report", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Work Order Summary", "link_to": "Work Order Summary", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Quality Inspection Summary", "link_to": "Quality Inspection Summary", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Downtime Analysis", "link_to": "Downtime Analysis", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Job Card Summary", "link_to": "Job Card Summary", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "BOM Search", "link_to": "BOM Search", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Production Analytics", "link_to": "Production Analytics", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "BOM Operations Time", "link_to": "BOM Operations Time", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Work Order Consumed Materials", "link_to": "Work Order Consumed Materials", "link_type": "Report", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 0, "collapsible": 1, + "default_workspace": 0, "icon": "database", "indent": 1, "keep_closed": 1, "label": "Setup", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Section Break" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "icon": "", "indent": 0, "keep_closed": 0, "label": "Item", "link_to": "Item", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "icon": "", "indent": 0, "keep_closed": 0, "label": "Warehouse", "link_to": "Warehouse", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Operation", "link_to": "Operation", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "icon": "", "indent": 0, "keep_closed": 0, "label": "Workstation", "link_to": "Workstation", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Workstation Type", "link_to": "Workstation Type", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Plant Floor", "link_to": "Plant Floor", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 1, "collapsible": 1, + "default_workspace": 0, "indent": 0, "keep_closed": 0, "label": "Routing", "link_to": "Routing", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" }, { "child": 0, "collapsible": 1, + "default_workspace": 0, "icon": "settings", "indent": 0, "keep_closed": 0, "label": "Settings", "link_to": "Manufacturing Settings", "link_type": "DocType", + "open_in_new_tab": 0, "show_arrow": 0, "type": "Link" } ], - "modified": "2026-07-03 11:01:50.260118", + "modified": "2026-07-03 23:08:12.319618", "modified_by": "Administrator", "module": "Manufacturing", "module_onboarding": "Manufacturing Onboarding",