diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c30dfda..61142b52 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/Admin/BomManagementController.php b/backend/app/Http/Controllers/Web/Admin/BomManagementController.php
index ea414612..a6476d49 100644
--- a/backend/app/Http/Controllers/Web/Admin/BomManagementController.php
+++ b/backend/app/Http/Controllers/Web/Admin/BomManagementController.php
@@ -33,7 +33,7 @@ public function index(ProductType $productType, ProcessTemplate $processTemplate
->where('id', '!=', $productType->id)
->orderBy('name')
->get(['id', 'code', 'name', 'unit_of_measure']);
- $steps = $processTemplate->steps()->orderBy('step_number')->get();
+ $steps = $processTemplate->steps()->with('workstation')->orderBy('step_number')->get();
return Inertia::render('admin/process-templates/Bom', [
'productType' => $productType->only('id', 'name'),
@@ -88,6 +88,10 @@ public function index(ProductType $productType, ProcessTemplate $processTemplate
'name' => $s->name,
'kind' => $s->kind,
'config' => $s->config,
+ // Node captions for the routing graph above the table.
+ 'is_optional' => (bool) $s->is_optional,
+ 'variant_group' => $s->variant_group,
+ 'workstation' => $s->workstation ? ['id' => $s->workstation->id, 'name' => $s->workstation->name] : null,
]),
]);
}
diff --git a/backend/app/Http/Controllers/Web/Operator/LineController.php b/backend/app/Http/Controllers/Web/Operator/LineController.php
index 8696e6a2..0bd85149 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 2974825f..73c027eb 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/lang/en.json b/backend/lang/en.json
index c8c1781f..68c2cf1e 100644
--- a/backend/lang/en.json
+++ b/backend/lang/en.json
@@ -6876,5 +6876,6 @@
"Module \":name\" requires OpenMES :required; this installation is :current.": "Module \":name\" requires OpenMES :required; this installation is :current.",
"Module \":name\" could not be uninstalled: :msg": "Module \":name\" could not be uninstalled: :msg",
"Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.",
- "Failed to import settings - nothing from the file was saved. :message": "Failed to import settings - nothing from the file was saved. :message"
+ "Failed to import settings - nothing from the file was saved. :message": "Failed to import settings - nothing from the file was saved. :message",
+ "Click a step to show only its components; click it again to show all. The number on a step is how many components it consumes.": "Click a step to show only its components; click it again to show all. The number on a step is how many components it consumes."
}
diff --git a/backend/lang/pl.json b/backend/lang/pl.json
index 9985b3b6..eae55ca5 100644
--- a/backend/lang/pl.json
+++ b/backend/lang/pl.json
@@ -6876,5 +6876,6 @@
"Module \":name\" requires OpenMES :required; this installation is :current.": "Moduł \":name\" wymaga OpenMES :required; ta instalacja to :current.",
"Module \":name\" could not be uninstalled: :msg": "Nie udało się odinstalować modułu \":name\": :msg",
"Module \":name\" uninstalled. Its database tables were left in place; reinstalling keeps the data.": "Moduł \":name\" odinstalowany. Jego tabele w bazie zostały nienaruszone; ponowna instalacja zachowa dane.",
- "Failed to import settings - nothing from the file was saved. :message": "Import ustawień nie powiódł się - nic z pliku nie zostało zapisane. :message"
+ "Failed to import settings - nothing from the file was saved. :message": "Import ustawień nie powiódł się - nic z pliku nie zostało zapisane. :message",
+ "Click a step to show only its components; click it again to show all. The number on a step is how many components it consumes.": "Kliknij krok, aby pokazać tylko jego komponenty; kliknij ponownie, aby pokazać wszystkie. Liczba na kroku to liczba komponentów, które zużywa."
}
diff --git a/backend/resources/js/Pages/admin/process-templates/Bom.jsx b/backend/resources/js/Pages/admin/process-templates/Bom.jsx
index b3267c7b..37592e95 100644
--- a/backend/resources/js/Pages/admin/process-templates/Bom.jsx
+++ b/backend/resources/js/Pages/admin/process-templates/Bom.jsx
@@ -1,9 +1,10 @@
-import { useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import { __ } from '../../../lib/i18n';
import { Head, router, useForm, usePage } from '@inertiajs/react';
import { Button, Dropdown, Modal, SegmentedControl } from '@openmes/ui';
import AppLayout from '../../../layouts/AppLayout';
import ResourceTable from '../../../components/ResourceTable';
+import RoutingGraph from '../../../components/flow/RoutingGraph';
const TYPE_COLORS = {
raw_material: 'bg-om-downtime-bg text-om-downtime',
@@ -16,6 +17,11 @@ function trackingLabel(value) {
return value == null ? '—' : __(labels[value] ?? value);
}
+function consumedAtLabel(value) {
+ const labels = { start: 'Start of step', during: 'During step', end: 'End of step' };
+ return value == null ? '—' : __(labels[value] ?? value);
+}
+
function typeColorClass(code) {
return TYPE_COLORS[code] ?? 'bg-om-chip text-om-ink';
}
@@ -314,6 +320,24 @@ export default function ProcessTemplatesBom() {
const remove = (row) => router.delete(`${templateHref}/bom/${row.id}`, { preserveScroll: true });
+ // The dropdown and the graph drive the same filter, and both keep it in the
+ // URL so a link to one step's components (?step_id=) survives a reload.
+ const applyStepFilter = (value) => {
+ setStepFilter(value);
+ router.get(`${templateHref}/bom`, value ? { step_id: value } : {}, { preserveState: true, preserveScroll: true, replace: true });
+ };
+
+ // Components per step, shown on each node so the graph doubles as an
+ // overview of where material enters the routing.
+ const countByStep = useMemo(() => {
+ const counts = {};
+ bomItems.forEach((row) => {
+ if (row.template_step_id != null) counts[row.template_step_id] = (counts[row.template_step_id] ?? 0) + 1;
+ });
+ return counts;
+ }, [bomItems]);
+ const nodeBadge = useCallback((step) => countByStep[step.id] ?? 0, [countByStep]);
+
const columns = useMemo(() => [
{
key: 'component_name',
@@ -378,7 +402,9 @@ export default function ProcessTemplatesBom() {
{
key: 'consumed_at',
label: 'Consumed At',
- render: (row) => {row.consumed_at},
+ // Search and the column filter match the translated label, not the stored enum.
+ value: (row) => consumedAtLabel(row.consumed_at),
+ render: (row) => {consumedAtLabel(row.consumed_at)},
},
{
key: 'tracking_type',
@@ -429,6 +455,24 @@ export default function ProcessTemplatesBom() {
<>