diff --git a/erpnext/manufacturing/page/shop_floor/shop_floor.py b/erpnext/manufacturing/page/shop_floor/shop_floor.py index 16b3fb1a84d..dd4bce405c0 100644 --- a/erpnext/manufacturing/page/shop_floor/shop_floor.py +++ b/erpnext/manufacturing/page/shop_floor/shop_floor.py @@ -3,6 +3,7 @@ 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 pypika.terms import ExistsCriterion from erpnext.manufacturing.doctype.workstation.workstation import ( get_status_color, @@ -59,10 +60,11 @@ TODAY_SESSION_FIELDS = [ # 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. +# Maps the manager buckets to the underlying Work Order statuses. "open" spans pending AND +# in-progress: starting a job card flips the Work Order to In Process, and separate tabs made +# it jump tabs on the next refresh — the operator would lose the card they were working on. WORK_ORDER_STATUS_GROUPS = { - "in_progress": ["In Process"], - "pending": ["Not Started", "Submitted", "Stock Reserved", "Stock Partially Reserved"], + "open": ["In Process", "Not Started", "Submitted", "Stock Reserved", "Stock Partially Reserved"], "completed": ["Completed"], } @@ -300,7 +302,7 @@ def get_work_orders( search: str | None = None, with_job_cards_only: bool | int = 0, ): - """Paginated Work Orders for one manager bucket (in_progress / pending / completed), + """Paginated Work Orders for one manager bucket (open / 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) @@ -308,8 +310,7 @@ def get_work_orders( """ frappe.has_permission("Work Order", "read", throw=True) - statuses = WORK_ORDER_STATUS_GROUPS.get(status_group) - if not statuses: + if status_group not in WORK_ORDER_STATUS_GROUPS: frappe.throw(_("Invalid status group: {0}").format(status_group)) start = cint(start) @@ -320,7 +321,7 @@ def get_work_orders( 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) + query = _apply_work_order_filters(frappe.qb.from_(wo), wo, status_group, search, with_job_cards_only) work_orders = ( query.select(*[wo[field] for field in WORK_ORDER_FIELDS]) .orderby(wo.planned_start_date, order=order) @@ -328,7 +329,7 @@ def get_work_orders( .offset(start) ).run(as_dict=True) - total = _count_work_orders(statuses, search, with_job_cards_only) + total = _count_work_orders(status_group, search, with_job_cards_only) _enrich_work_orders(work_orders) @@ -340,9 +341,29 @@ def get_work_orders( } -def _apply_work_order_filters(query, wo, statuses, search, with_job_cards_only): +def _has_job_cards_criterion(wo, docstatus): + jc = frappe.qb.DocType("Job Card") + return ExistsCriterion( + frappe.qb.from_(jc).select(jc.name).where((jc.work_order == wo.name) & (jc.docstatus == docstatus)) + ) + + +def _bucket_criterion(wo, status_group): + """The floor is done with a Work Order once every job card is submitted, even though the + Work Order stays "In Process" until the finished goods are received. Such operationally + complete orders belong on the Completed tab — not stranded under Pending / In Progress.""" + open_statuses = WORK_ORDER_STATUS_GROUPS["open"] + all_job_cards_done = _has_job_cards_criterion(wo, 1) & _has_job_cards_criterion(wo, 0).negate() + if status_group == "completed": + return (wo.status == "Completed") | (wo.status.isin(open_statuses) & all_job_cards_done) + return wo.status.isin(open_statuses) & ( + _has_job_cards_criterion(wo, 1).negate() | _has_job_cards_criterion(wo, 0) + ) + + +def _apply_work_order_filters(query, wo, status_group, 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))) + query = query.where((wo.docstatus == 1) & _bucket_criterion(wo, status_group)) if search: like = f"%{search}%" query = query.where(wo.name.like(like) | wo.production_item.like(like) | wo.item_name.like(like)) @@ -353,10 +374,10 @@ def _apply_work_order_filters(query, wo, statuses, search, with_job_cards_only): return query -def _count_work_orders(statuses: list[str], search: str | None, with_job_cards_only: int = 0) -> int: +def _count_work_orders(status_group: 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) + query = _apply_work_order_filters(frappe.qb.from_(wo), wo, status_group, search, with_job_cards_only) return cint(query.select(Count("*")).run()[0][0]) diff --git a/erpnext/public/js/shop_floor/shop_floor.js b/erpnext/public/js/shop_floor/shop_floor.js index 0ff1fbf8630..41e20dce104 100644 --- a/erpnext/public/js/shop_floor/shop_floor.js +++ b/erpnext/public/js/shop_floor/shop_floor.js @@ -1,7 +1,7 @@ // 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. +// • manager — a paginated board of work orders bucketed Pending / In Progress and 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. // @@ -21,8 +21,7 @@ const JC_STATUS_COLORS = { }; const MANAGER_BUCKETS = [ - { key: "in_progress", label: __("In Progress"), dot: "orange" }, - { key: "pending", label: __("Pending"), dot: "blue" }, + { key: "open", label: __("Pending / In Progress"), dot: "orange" }, { key: "completed", label: __("Completed"), dot: "green" }, ]; @@ -43,7 +42,7 @@ class ShopFloor { // View state. this.view = "operator"; // overwritten once context loads - this.active_bucket = "in_progress"; + this.active_bucket = "open"; 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; @@ -79,6 +78,7 @@ class ShopFloor {
+ @@ -111,6 +111,29 @@ class ShopFloor { 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()); + this.wrapper.find(".sf-btn-theme").on("click", () => this.toggle_theme()); + this.update_theme_button(); + } + + // Kiosk-friendly light/dark switch: flips the standard desk theme and persists it on the + // User (same as the Ctrl+Shift+G switcher), so the choice survives reloads and follows the + // operator's login on any device. + toggle_theme() { + const next = frappe.ui.get_current_theme() === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme-mode", next); + frappe.ui.set_theme(next); + frappe.xcall("frappe.core.doctype.user.user.switch_theme", { + theme: next.charAt(0).toUpperCase() + next.slice(1), + }); + this.update_theme_button(); + } + + update_theme_button() { + const dark = frappe.ui.get_current_theme() === "dark"; + this.wrapper + .find(".sf-btn-theme") + .html(dark ? "☀" : "☾") + .attr("title", dark ? __("Switch to Light Theme") : __("Switch to Dark Theme")); } render_shell_controls() { @@ -1249,8 +1272,7 @@ class ShopFloor { return; case "1": case "2": - case "3": - if (this.view === "manager") { + if (this.view === "manager" && MANAGER_BUCKETS[cint(e.key) - 1]) { this.switch_bucket(MANAGER_BUCKETS[cint(e.key) - 1].key); e.preventDefault(); } @@ -1369,7 +1391,7 @@ class ShopFloor { ["r", __("Refresh")], ["b", __("Scan job card")], ["g then m / o", __("Switch Board / Operator view")], - ["1 / 2 / 3", __("Switch board tab")], + ["1 / 2", __("Switch board tab")], ["↑ / ↓ or j / k", __("Move selection")], ["Enter", __("Open work order / run primary action")], ["Esc", __("Close detail / blur search")], @@ -1547,6 +1569,7 @@ class ShopFloor { .sf-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-muted); white-space: nowrap; cursor: pointer; margin: 0; user-select: none; } .sf-toggle input { cursor: pointer; width: 15px; height: 15px; margin: 0; } .sf-topbar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + .sf-btn-theme { font-size: 15px; line-height: 1; min-width: 30px; } .sf-title { font-size: 18px; font-weight: 700; color: var(--text-color); } .sf-view-toggle { display: inline-flex; border: 1px solid var(--border-color); border-radius: var(--border-radius); overflow: hidden; } @@ -1609,8 +1632,17 @@ class ShopFloor { outline: none; } .sf-wo-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,0.08); transform: translateY(-1px); } - .sf-wo-card.sf-selected { border-color: var(--primary); } - .sf-wo-card.sf-focused { box-shadow: 0 0 0 2px var(--primary); } + [data-theme="dark"] .sf-wo-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,0.45); } + /* --primary is a fixed near-black (#171717) that the dark theme never remaps, so a ring + drawn with it disappears on dark cards. Light theme keeps the dark ring; dark theme + needs an accent colour — a light-gray ring on gray cards is still too subtle. */ + .sf-wo-card.sf-selected { border-color: var(--primary-color, var(--primary)); } + .sf-wo-card.sf-focused { box-shadow: 0 0 0 2px var(--primary-color, var(--primary)); } + [data-theme="dark"] .sf-wo-card.sf-selected { border-color: var(--blue-500, #2490ef); } + [data-theme="dark"] .sf-wo-card.sf-focused { + box-shadow: 0 0 0 3px var(--blue-500, #2490ef); + border-color: transparent; + } .sf-wo-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } .sf-wo-image { width: 66px; height: 66px; aspect-ratio: 1 / 1; align-self: center; border-radius: 12px; overflow: hidden; background: var(--bg-color); border: 1px solid var(--border-color); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sf-wo-image img { width: 100%; height: 100%; object-fit: cover; } @@ -1635,6 +1667,7 @@ class ShopFloor { .sf-wo-progress-label { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; } .sf-wo-progress-count { font-weight: 600; color: var(--text-color); font-variant-numeric: tabular-nums; } .sf-progress { display: flex; height: 10px; border-radius: 6px; background: var(--gray-300, #d1d5db); overflow: hidden; } + [data-theme="dark"] .sf-progress { background: var(--gray-700, #374151); } .sf-progress-seg { height: 100%; transition: width 0.3s ease; } .sf-seg-done { background: var(--green-400, #9ae6b4); } .sf-seg-wip { background: var(--orange-400, #fbd38d); } diff --git a/erpnext/public/js/templates/shop_floor_template.html b/erpnext/public/js/templates/shop_floor_template.html index 62e9184cef6..a606e956270 100644 --- a/erpnext/public/js/templates/shop_floor_template.html +++ b/erpnext/public/js/templates/shop_floor_template.html @@ -104,7 +104,7 @@ .mes-job-id { color: var(--text-muted); font-size: 14px; - /* Match the indicator-pill's fixed 20px height so the Job Card id sits vertically + /* Match the fixed 20px height of the indicator pill so the Job Card id sits vertically centered against the status pill in the meta row (otherwise the pill reads higher). */ line-height: 20px; } @@ -389,7 +389,7 @@ .mes-row { display: flex; align-items: center; - gap: 16px; + gap: 12px; padding: 11px 18px; background: var(--fg-color); border-bottom: 1px solid var(--border-color); @@ -400,14 +400,23 @@ .mes-row:hover { background: var(--bg-color); } .mes-row-status { flex: 0 0 150px; display: flex; align-items: center; gap: 6px; } .mes-row-status .indicator-pill { white-space: nowrap; } - .mes-row-id { flex: 0 0 120px; color: var(--text-muted); font-size: 14px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .mes-row-id { flex: 0 0 110px; color: var(--text-muted); font-size: 14px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mes-row-id a { color: var(--text-muted); } /* Item cell: thumbnail + name on one line, so it lines up with the status/id row. */ .mes-row-item { flex: 1 1 0; min-width: 0; display: flex; align-items: center; gap: 10px; } + /* One fixed box for every list row thumbnail (Up Next, Completed Operations, session + lists) — image and initials fallback are locked to the same 32x32 so the rows always + line up regardless of the intrinsic image size. NOTE: never use single quotes or + double curly braces in the text parts of this template — the microtemplate compiler + cannot escape them. */ .mes-row-image { width: 32px; height: 32px; - flex-shrink: 0; + min-width: 32px; + max-width: 32px; + min-height: 32px; + max-height: 32px; + flex: 0 0 32px; border-radius: var(--border-radius); overflow: hidden; background: var(--bg-color); @@ -416,8 +425,13 @@ align-items: center; justify-content: center; } - .mes-row-image img { width: 100%; height: 100%; object-fit: cover; } + .mes-row-image img { width: 32px; height: 32px; object-fit: cover; display: block; } .mes-row-image-fallback { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; font-size: 11px; font-weight: 600; color: var(--text-muted); @@ -435,7 +449,7 @@ } /* Operation as its own column (was previously stacked under the item name). */ .mes-row-operation { - flex: 0 0 140px; + flex: 0 0 110px; color: var(--text-muted); font-size: 14px; overflow: hidden; @@ -454,7 +468,7 @@ and vertically centers with the rest of the row. */ .mes-row-qty-uom { color: var(--text-muted); font-size: 13px; margin-left: 2px; } .mes-row-info { - flex: 0 0 140px; + flex: 0 0 120px; text-align: right; font-size: 14px; color: var(--text-muted); @@ -462,7 +476,9 @@ } .mes-row-info--ready { color: var(--text-color); } .mes-row-info--warn { color: var(--orange-500, #d97706); } - .mes-row-action { flex: 0 0 auto; text-align: right; } + /* Fixed column widths so rows align across sections (Up Next, Completed Operations, + session lists) even when a row has no info text or action button. */ + .mes-row-action { flex: 0 0 150px; text-align: right; } .mes-row-action .btn { font-size: 14px; padding: 5px 12px; min-width: 96px; white-space: nowrap; } @media (max-width: 768px) { @@ -871,7 +887,7 @@
{% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %}
-
{{ jc.finished_good || jc.production_item }}
+
{{ jc.finished_good || jc.production_item }}
{{ jc.operation }}
{{ jc.name }}
@@ -912,7 +928,7 @@
{% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %}
-
{{ jc.finished_good || jc.production_item }}
+
{{ jc.finished_good || jc.production_item }}
{{ jc.operation }}
{{ jc.name }}
@@ -932,7 +948,7 @@ {% } %} - + {% if (queue.length > 0) { %}
@@ -954,7 +970,7 @@
{% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %}
-
{{ jc.finished_good || jc.production_item }}
+
{{ jc.finished_good || jc.production_item }}
{{ jc.operation }}
{{ jc.name }}
@@ -1004,7 +1020,7 @@
{% if (jc.item_image) { %}{% } else { %}{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}{% } %}
-
{{ jc.finished_good || jc.production_item }}
+
{{ jc.finished_good || jc.production_item }}
{{ jc.operation }}
{{ jc.name }}
@@ -1020,7 +1036,7 @@ {% } %} - + {% if (today_sessions && today_sessions.length > 0) { %}
@@ -1042,7 +1058,7 @@
{% if (s.item_image) { %}{% } else { %}{{ frappe.get_abbr(s.finished_good || s.production_item, 2) }}{% } %}
-
{{ s.finished_good || s.production_item }}
+
{{ s.finished_good || s.production_item }}
{{ s.operation }}
{{ s.name }}