diff --git a/backend/resources/js/components/flow/RoutingGraph.jsx b/backend/resources/js/components/flow/RoutingGraph.jsx
index 30e9a301a..a1f356044 100644
--- a/backend/resources/js/components/flow/RoutingGraph.jsx
+++ b/backend/resources/js/components/flow/RoutingGraph.jsx
@@ -44,7 +44,7 @@ const STATUS_DOT = {
};
function StepNode({ data, selected, isConnectable }) {
- const { step } = data;
+ const { step, badge } = data;
const status = step.status ? STATUS_DOT[step.status] ?? STATUS_DOT.PENDING : null;
return (
{step.step_number}
-
+
{step.name}
{status && }
@@ -65,6 +65,9 @@ function StepNode({ data, selected, isConnectable }) {
{step.is_optional ? ` · ${__('Optional')}` : ''}
+ {badge != null && (
+
{badge}
+ )}
);
@@ -91,7 +94,21 @@ function firstError(json) {
return json.message ?? null;
}
-export default function RoutingGraph({ steps, links: initialLinks, baseUrl, compact = false, height = 340, selectedId = null, onSelectStep }) {
+// How many steps a focused view frames: the selected one plus its neighbours.
+const FOCUS_WINDOW = 3;
+
+/**
+ * The steps to frame around `selectedId` — a run of FOCUS_WINDOW in step order,
+ * shifted at either end so the first step shows 1–2–3, not an empty left half.
+ */
+function focusNodeIds(steps, selectedId) {
+ const i = steps.findIndex((s) => s.id === selectedId);
+ if (i < 0) return null;
+ const start = Math.max(0, Math.min(i - 1, steps.length - FOCUS_WINDOW));
+ return steps.slice(start, start + FOCUS_WINDOW).map((s) => ({ id: String(s.id) }));
+}
+
+export default function RoutingGraph({ steps, links: initialLinks, baseUrl, compact = false, height = 340, selectedId = null, onSelectStep, nodeBadge, focusSelected = false }) {
// The backend advertises the link editor by sending a links array; while it
// doesn't (the DAG-routing backend hasn't shipped), the graph is read-only:
// the implicit chain renders, but nothing can be drawn or removed.
@@ -113,6 +130,24 @@ export default function RoutingGraph({ steps, links: initialLinks, baseUrl, comp
const positions = useMemo(() => layout(steps, pairs), [structureKey]); // eslint-disable-line react-hooks/exhaustive-deps
const [nodes, setNodes, onNodesChange] = useNodesState([]);
+
+ // Opt-in: zoom to the selected step's neighbourhood instead of the whole
+ // routing, which on a long line shrinks every node past readability.
+ // Clearing the selection zooms back out to everything.
+ const flowRef = useRef(null);
+ const fitOptions = useMemo(() => {
+ const focus = focusSelected ? focusNodeIds(steps, selectedId) : null;
+ return focus ? { nodes: focus, padding: 0.15, maxZoom: 1.5 } : { padding: 0.2, maxZoom: 1.1 };
+ }, [focusSelected, steps, selectedId]);
+ const firstFitRef = useRef(true);
+ useEffect(() => {
+ if (!focusSelected) return;
+ // The first frame is ReactFlow's own `fitView` on mount; after that,
+ // follow the selection with a short pan so it's clear where you went.
+ if (firstFitRef.current) { firstFitRef.current = false; return; }
+ const id = requestAnimationFrame(() => flowRef.current?.fitView({ ...fitOptions, duration: 300 }));
+ return () => cancelAnimationFrame(id);
+ }, [fitOptions, focusSelected]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
useEffect(() => {
@@ -132,7 +167,9 @@ export default function RoutingGraph({ steps, links: initialLinks, baseUrl, comp
deletable: false,
position: (structureChanged ? null : prevById[String(s.id)]?.position) ?? positions[s.id] ?? { x: 0, y: 0 },
selected: selectedId != null && s.id === selectedId,
- data: { step: s },
+ // Optional per-page annotation in the node's corner (e.g. the
+ // BOM page's component count); null hides it.
+ data: { step: s, badge: nodeBadge ? nodeBadge(s) : null },
}));
});
setEdges((prev) => {
@@ -163,7 +200,7 @@ export default function RoutingGraph({ steps, links: initialLinks, baseUrl, comp
data: { tone: 'muted', dashed: true },
}));
});
- }, [steps, links, pairs, explicit, positions, structureKey, selectedId, setNodes, setEdges]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [steps, links, pairs, explicit, positions, structureKey, selectedId, nodeBadge, setNodes, setEdges]); // eslint-disable-line react-hooks/exhaustive-deps
const onConnect = useCallback(async ({ source, target }) => {
if (!source || !target) return;
@@ -222,7 +259,8 @@ export default function RoutingGraph({ steps, links: initialLinks, baseUrl, comp
deleteKeyCode={editable ? ['Backspace', 'Delete'] : null}
elementsSelectable
fitView
- fitViewOptions={{ padding: 0.2, maxZoom: 1.1 }}
+ fitViewOptions={fitOptions}
+ onInit={(instance) => { flowRef.current = instance; }}
proOptions={{ hideAttribution: true }}
minZoom={0.2}
>
diff --git a/backend/tests/Feature/BomTest.php b/backend/tests/Feature/BomTest.php
index 2bb085984..48155d59e 100644
--- a/backend/tests/Feature/BomTest.php
+++ b/backend/tests/Feature/BomTest.php
@@ -3,12 +3,14 @@
namespace Tests\Feature;
use App\Models\BomItem;
+use App\Models\Line;
use App\Models\Material;
use App\Models\MaterialType;
use App\Models\ProcessTemplate;
use App\Models\ProductType;
use App\Models\TemplateStep;
use App\Models\User;
+use App\Models\Workstation;
use App\Services\Material\BomService;
use App\Services\Material\MaterialSyncService;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -128,6 +130,30 @@ public function test_admin_can_view_bom(): void
$response->assertStatus(200);
}
+ public function test_bom_page_sends_step_captions_for_the_routing_graph(): void
+ {
+ $productType = ProductType::factory()->create();
+ $template = ProcessTemplate::factory()->create(['product_type_id' => $productType->id]);
+ $workstation = Workstation::factory()->create(['name' => 'Press 1', 'line_id' => Line::factory()]);
+ $step = TemplateStep::factory()->create([
+ 'process_template_id' => $template->id,
+ 'step_number' => 1,
+ 'workstation_id' => $workstation->id,
+ 'is_optional' => true,
+ 'variant_group' => 'A',
+ ]);
+
+ $this->actingAs($this->admin)
+ ->get(route('admin.product-types.process-templates.bom', [$productType, $template]))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->component('admin/process-templates/Bom')
+ ->where('steps.0.id', $step->id)
+ ->where('steps.0.workstation.name', 'Press 1')
+ ->where('steps.0.is_optional', true)
+ ->where('steps.0.variant_group', 'A'));
+ }
+
public function test_admin_can_add_bom_item(): void
{
$productType = ProductType::factory()->create();
From a363da4b6b307cf1f17d47a96b97611c1afdb0b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mateusz=20=C5=81uczy=C5=84ski?=
Date: Wed, 30 Sep 2026 15:29:47 +0200
Subject: [PATCH 2/2] fix: if only one lane, open instantly
---
CHANGELOG.md | 1 +
.../Web/Operator/LineController.php | 34 ++++++++++---
.../Http/Middleware/HandleInertiaRequests.php | 4 ++
backend/resources/js/Pages/operator/Queue.jsx | 11 ++--
.../js/Pages/operator/Workstation.jsx | 15 +++---
.../resources/js/layouts/OperatorLayout.jsx | 16 +++---
.../Web/Operator/OperatorContextInUrlTest.php | 50 +++++++++++++++++++
7 files changed, 108 insertions(+), 23 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c30dfda7..61142b52b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
### Changed
+- Operator line selection: an operator assigned to a single line goes straight to it (keeping the bench already picked on that line) instead of a picker with one card, and the "Switch line" / "Change line" buttons are hidden since they would only land back on the same line. The bench is still chosen from the queue's workstation filter.
- Settings → Import no longer sends a live update per imported row. A plant file with its production scenario touches a thousand rows or more, each update a synchronous call to the websocket server after the commit; on a slower host that ran the request past its time limit and answered an empty error 500, although the data had been saved. Open screens pick the new data up when they next load, as after a data import. A failed import now names its reason on the page (the screen is admin-only) instead of a generic message, and a log that cannot be written no longer turns that answer into an empty 500.
- The audit log page no longer trips a PHP deprecation on rows that name no entity (a login, a settings change), and the `deprecations` log channel is declared in `config/logging.php`: under Octane the channel the framework added at runtime never reached the log manager, so every deprecation notice ended in "Log [deprecations] is not defined" through the emergency logger.
diff --git a/backend/app/Http/Controllers/Web/Operator/LineController.php b/backend/app/Http/Controllers/Web/Operator/LineController.php
index 8696e6a23..0bd851492 100644
--- a/backend/app/Http/Controllers/Web/Operator/LineController.php
+++ b/backend/app/Http/Controllers/Web/Operator/LineController.php
@@ -32,7 +32,23 @@ public function index(Request $request)
}
// Operators see only assigned lines
- $lines = $user->lines()->where('is_active', true)->with('workstations')->get()
+ $assigned = $user->lines()->where('is_active', true)->with('workstations')->get();
+
+ // One line leaves nothing to choose: open it straight away. The bench stays
+ // switchable from the queue, so keep the one already picked on this line.
+ if ($assigned->count() === 1) {
+ $line = $assigned->first();
+ $keep = $request->session()->get('selected_line_id') == $line->id
+ ? $request->session()->get('selected_workstation_id')
+ : null;
+ $workstationId = $keep && $line->workstations->where('is_active', true)->contains('id', $keep)
+ ? $keep
+ : null;
+
+ return $this->land($request, $line, $workstationId);
+ }
+
+ $lines = $assigned
->map(fn ($line) => [
'id' => $line->id,
'name' => $line->name,
@@ -76,21 +92,27 @@ public function select(Request $request)
}
}
- // Store selected line and workstation in session
- $request->session()->put('selected_line_id', $lineId);
+ return $this->land($request, Line::find($lineId), $workstationId);
+ }
+
+ /**
+ * Remember the line and bench in the session and open the bench's first screen.
+ */
+ private function land(Request $request, Line $line, $workstationId)
+ {
+ $request->session()->put('selected_line_id', $line->id);
$request->session()->put('selected_workstation_id', $workstationId);
- $line = Line::find($lineId);
// The bench's first screen: a packing bench opens on packing, an assembly
// bench on the line's default production view.
$screens = app(\App\Services\Production\OperatorScreens::class);
$route = $screens->landingRoute(
$screens->for($request->user(), $workstationId ? \App\Models\Workstation::find($workstationId) : null),
- $line?->default_operator_view ?? 'queue',
+ $line->default_operator_view ?? 'queue',
);
// The choice rides in the address, so the page can be bookmarked or
// shared and opens on this line and bench.
- return redirect()->route($route, ['line' => $lineId, 'workstation' => $workstationId ?: 'all']);
+ return redirect()->route($route, ['line' => $line->id, 'workstation' => $workstationId ?: 'all']);
}
}
diff --git a/backend/app/Http/Middleware/HandleInertiaRequests.php b/backend/app/Http/Middleware/HandleInertiaRequests.php
index 2974825f3..73c027ebc 100644
--- a/backend/app/Http/Middleware/HandleInertiaRequests.php
+++ b/backend/app/Http/Middleware/HandleInertiaRequests.php
@@ -84,6 +84,10 @@ public function share(Request $request): array
->filter('operator.can_logout', true, ['user' => $user]),
'operatorHooks' => fn () => app(\App\Extension\HookRegistry::class)
->renderMany(['display.operator.layout'], ['user' => $user]),
+ // With a single line, select-line opens it directly, so a "switch
+ // line" button would only land back where the operator already is.
+ 'operatorCanSwitchLine' => fn () => $user !== null
+ && $user->lines()->where('is_active', true)->count() > 1,
'csrf_token' => fn () => csrf_token(),
'appVersion' => fn () => config('version.current'),
// i18n: the active locale + the switcher's options. The frontend
diff --git a/backend/resources/js/Pages/operator/Queue.jsx b/backend/resources/js/Pages/operator/Queue.jsx
index 69b13971b..f738c2279 100644
--- a/backend/resources/js/Pages/operator/Queue.jsx
+++ b/backend/resources/js/Pages/operator/Queue.jsx
@@ -685,6 +685,7 @@ export default function Queue() {
lineWorkstations = [],
downtimeReasons = [],
activeDowntime = null,
+ operatorCanSwitchLine = true,
} = usePage().props;
// Persist view preference in localStorage
@@ -799,10 +800,12 @@ export default function Queue() {
-