fix: dark theme shop floor (#56887)

This commit is contained in:
rohitwaghchaure
2026-07-05 16:33:08 +05:30
committed by GitHub
parent 00e6b44701
commit 7844905e21
3 changed files with 106 additions and 36 deletions

View File

@@ -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])

View File

@@ -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 {
<div class="sf-topbar-left"></div>
<div class="sf-topbar-center"></div>
<div class="sf-topbar-right">
<button class="btn btn-default btn-sm sf-btn-theme"></button>
<button class="btn btn-default btn-sm sf-btn-home" title="${__("Home")}">
${frappe.utils.icon("home", "sm")}
</button>
@@ -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); }

View File

@@ -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 @@
<div class="mes-row-image">
{% if (jc.item_image) { %}<img src="{{ jc.item_image }}" alt="">{% } else { %}<span class="mes-row-image-fallback">{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}</span>{% } %}
</div>
<div class="mes-row-title">{{ jc.finished_good || jc.production_item }}</div>
<div class="mes-row-title" title="{{ jc.finished_good || jc.production_item }}">{{ jc.finished_good || jc.production_item }}</div>
</div>
<div class="mes-row-operation">{{ jc.operation }}</div>
<div class="mes-row-id"><a href="/app/job-card/{{ jc.name }}">{{ jc.name }}</a></div>
@@ -912,7 +928,7 @@
<div class="mes-row-image">
{% if (jc.item_image) { %}<img src="{{ jc.item_image }}" alt="">{% } else { %}<span class="mes-row-image-fallback">{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}</span>{% } %}
</div>
<div class="mes-row-title">{{ jc.finished_good || jc.production_item }}</div>
<div class="mes-row-title" title="{{ jc.finished_good || jc.production_item }}">{{ jc.finished_good || jc.production_item }}</div>
</div>
<div class="mes-row-operation">{{ jc.operation }}</div>
<div class="mes-row-id"><a href="/app/job-card/{{ jc.name }}">{{ jc.name }}</a></div>
@@ -932,7 +948,7 @@
</div>
{% } %}
<!-- Up Next — only when there's something to queue -->
<!-- Up Next — only when the queue has pending job cards -->
{% if (queue.length > 0) { %}
<div class="mes-list-card">
<div class="mes-list-card-header">
@@ -954,7 +970,7 @@
<div class="mes-row-image">
{% if (jc.item_image) { %}<img src="{{ jc.item_image }}" alt="">{% } else { %}<span class="mes-row-image-fallback">{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}</span>{% } %}
</div>
<div class="mes-row-title">{{ jc.finished_good || jc.production_item }}</div>
<div class="mes-row-title" title="{{ jc.finished_good || jc.production_item }}">{{ jc.finished_good || jc.production_item }}</div>
</div>
<div class="mes-row-operation">{{ jc.operation }}</div>
<div class="mes-row-id"><a href="/app/job-card/{{ jc.name }}">{{ jc.name }}</a></div>
@@ -1004,7 +1020,7 @@
<div class="mes-row-image">
{% if (jc.item_image) { %}<img src="{{ jc.item_image }}" alt="">{% } else { %}<span class="mes-row-image-fallback">{{ frappe.get_abbr(jc.finished_good || jc.production_item, 2) }}</span>{% } %}
</div>
<div class="mes-row-title">{{ jc.finished_good || jc.production_item }}</div>
<div class="mes-row-title" title="{{ jc.finished_good || jc.production_item }}">{{ jc.finished_good || jc.production_item }}</div>
</div>
<div class="mes-row-operation">{{ jc.operation }}</div>
<div class="mes-row-id"><a href="/app/job-card/{{ jc.name }}">{{ jc.name }}</a></div>
@@ -1020,7 +1036,7 @@
</div>
{% } %}
<!-- Today's Sessions -->
<!-- Sessions recorded today -->
{% if (today_sessions && today_sessions.length > 0) { %}
<div class="mes-list-card">
<div class="mes-list-card-header">
@@ -1042,7 +1058,7 @@
<div class="mes-row-image">
{% if (s.item_image) { %}<img src="{{ s.item_image }}" alt="">{% } else { %}<span class="mes-row-image-fallback">{{ frappe.get_abbr(s.finished_good || s.production_item, 2) }}</span>{% } %}
</div>
<div class="mes-row-title">{{ s.finished_good || s.production_item }}</div>
<div class="mes-row-title" title="{{ s.finished_good || s.production_item }}">{{ s.finished_good || s.production_item }}</div>
</div>
<div class="mes-row-operation">{{ s.operation }}</div>
<div class="mes-row-id"><a href="/app/job-card/{{ s.name }}">{{ s.name }}</a></div>