From e040cff035954356586b871ab8ab6815773c3d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81uczy=C5=84ski?= Date: Wed, 30 Sep 2026 15:25:04 +0200 Subject: [PATCH 1/2] feat: bom view --- .../Web/Admin/BomManagementController.php | 6 ++- backend/lang/en.json | 3 +- backend/lang/pl.json | 3 +- .../js/Pages/admin/process-templates/Bom.jsx | 53 ++++++++++++++++--- .../js/Pages/admin/process-templates/Show.jsx | 1 + .../js/components/flow/RoutingGraph.jsx | 50 ++++++++++++++--- backend/tests/Feature/BomTest.php | 26 +++++++++ 7 files changed, 127 insertions(+), 15 deletions(-) diff --git a/backend/app/Http/Controllers/Web/Admin/BomManagementController.php b/backend/app/Http/Controllers/Web/Admin/BomManagementController.php index ea414612e..a6476d492 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/lang/en.json b/backend/lang/en.json index c8c1781f7..68c2cf1e2 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 9985b3b63..eae55ca5a 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 b3267c7bb..37592e95d 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() { <> + {steps.length > 0 && ( +
+ applyStepFilter(String(id) === stepFilter ? '' : String(id))} + nodeBadge={nodeBadge} + focusSelected + /> +

+ {__('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.')} +

+
+ )} + { - setStepFilter(value); - router.get(`${templateHref}/bom`, value ? { step_id: value } : {}, { preserveState: true, preserveScroll: true, replace: true }); - }} + onChange={applyStepFilter} options={[ { value: '', label: __('All steps') }, ...steps.map(step => ({ value: String(step.id), label: `#${step.step_number} ${step.name}` })), diff --git a/backend/resources/js/Pages/admin/process-templates/Show.jsx b/backend/resources/js/Pages/admin/process-templates/Show.jsx index f8d339759..ba4ca781e 100644 --- a/backend/resources/js/Pages/admin/process-templates/Show.jsx +++ b/backend/resources/js/Pages/admin/process-templates/Show.jsx @@ -1391,6 +1391,7 @@ export default function ProcessTemplatesShow() { baseUrl={`/admin/product-types/${productType.id}/process-templates/${processTemplate.id}`} selectedId={selectedStep?.id ?? null} onSelectStep={setSelectedStepId} + focusSelected />
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() {
- - {__("Change Line")} - + {operatorCanSwitchLine && ( + + {__("Change Line")} + + )}
diff --git a/backend/resources/js/Pages/operator/Workstation.jsx b/backend/resources/js/Pages/operator/Workstation.jsx index bceb168cc..95e6377bc 100644 --- a/backend/resources/js/Pages/operator/Workstation.jsx +++ b/backend/resources/js/Pages/operator/Workstation.jsx @@ -765,6 +765,7 @@ export default function Workstation() { machineStateOptions = [], selectedWorkstation = null, hooks = {}, + operatorCanSwitchLine = true, } = usePage().props; // Which configurable columns the reader keeps on, remembered per line in @@ -890,12 +891,14 @@ export default function Workstation() { )} - - {__("Change Line")} - + {operatorCanSwitchLine && ( + + {__("Change Line")} + + )} diff --git a/backend/resources/js/layouts/OperatorLayout.jsx b/backend/resources/js/layouts/OperatorLayout.jsx index 91f9fc388..b1f5c4ee8 100644 --- a/backend/resources/js/layouts/OperatorLayout.jsx +++ b/backend/resources/js/layouts/OperatorLayout.jsx @@ -21,7 +21,7 @@ export default function OperatorLayout({ children }) { const props = usePage().props; const { auth, line, selectedWorkstation, csrf_token, moduleNav, - operatorTabs = [], operatorCanLogout = true, operatorHooks = {}, + operatorTabs = [], operatorCanLogout = true, operatorHooks = {}, operatorCanSwitchLine = true, } = props; // Tabs an enabled module registered via MenuRegistry::addOperatorItem(). const moduleTabs = moduleNav?.operator ?? []; @@ -73,12 +73,14 @@ export default function OperatorLayout({ children }) { {__(tab.label)} ))} - - {__('Switch Line')} - + {operatorCanSwitchLine && ( + + {__('Switch Line')} + + )} )} diff --git a/backend/tests/Feature/Web/Operator/OperatorContextInUrlTest.php b/backend/tests/Feature/Web/Operator/OperatorContextInUrlTest.php index 7b9883135..216e795cc 100644 --- a/backend/tests/Feature/Web/Operator/OperatorContextInUrlTest.php +++ b/backend/tests/Feature/Web/Operator/OperatorContextInUrlTest.php @@ -110,4 +110,54 @@ public function test_a_line_the_operator_is_not_assigned_to_is_ignored(): void ->assertInertia(fn (Assert $page) => $page->where('line.id', $this->line->id)); $this->assertSame($this->line->id, (int) session('selected_line_id')); } + + public function test_an_operator_with_one_line_skips_the_line_picker(): void + { + $solo = User::factory()->create(); + $solo->assignRole('Operator'); + $solo->lines()->attach($this->otherLine->id); + + $this->actingAs($solo)->get(route('operator.select-line')) + ->assertRedirect(route('operator.queue', ['line' => $this->otherLine->id, 'workstation' => 'all'])); + $this->assertSame($this->otherLine->id, (int) session('selected_line_id')); + } + + public function test_skipping_the_picker_keeps_the_bench_already_chosen_on_that_line(): void + { + $solo = User::factory()->create(); + $solo->assignRole('Operator'); + $solo->lines()->attach($this->line->id); + $retired = Workstation::create(['line_id' => $this->line->id, 'code' => 'OLD-1', 'name' => 'Old', 'is_active' => false]); + + $this->actingAs($solo) + ->withSession(['selected_line_id' => $this->line->id, 'selected_workstation_id' => $this->bench->id]) + ->get(route('operator.select-line')) + ->assertRedirectContains("workstation={$this->bench->id}"); + + // A bench that is no longer active, or belongs to another line, is dropped. + $this->actingAs($solo) + ->withSession(['selected_line_id' => $this->line->id, 'selected_workstation_id' => $retired->id]) + ->get(route('operator.select-line')) + ->assertRedirect(route('operator.queue', ['line' => $this->line->id, 'workstation' => 'all'])); + $this->assertNull(session('selected_workstation_id')); + } + + public function test_an_operator_with_several_lines_still_picks_one(): void + { + $this->actingAs($this->operator)->get(route('operator.select-line')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('operator/SelectLine') + ->has('lines', 2) + ->where('operatorCanSwitchLine', true)); + } + + public function test_the_switch_line_button_is_hidden_when_there_is_only_one_line(): void + { + $this->operator->lines()->detach($this->otherLine->id); + + $this->actingAs($this->operator)->get("/operator/queue?line={$this->line->id}") + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->where('operatorCanSwitchLine', false)); + } }