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 }}
@@ -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 }}
@@ -932,7 +948,7 @@
{% } %}
-
+
{% if (queue.length > 0) { %}
{{ jc.operation }}
@@ -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 }}
@@ -1020,7 +1036,7 @@
{% } %}
-
+
{% if (today_sessions && today_sessions.length > 0) { %}