Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
]),
]);
}
Expand Down
34 changes: 28 additions & 6 deletions backend/app/Http/Controllers/Web/Operator/LineController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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']);
}
}
4 changes: 4 additions & 0 deletions backend/app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
3 changes: 2 additions & 1 deletion backend/lang/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
53 changes: 47 additions & 6 deletions backend/resources/js/Pages/admin/process-templates/Bom.jsx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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';
}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -378,7 +402,9 @@ export default function ProcessTemplatesBom() {
{
key: 'consumed_at',
label: 'Consumed At',
render: (row) => <span className="text-sm text-om-muted capitalize">{row.consumed_at}</span>,
// Search and the column filter match the translated label, not the stored enum.
value: (row) => consumedAtLabel(row.consumed_at),
render: (row) => <span className="text-sm text-om-muted">{consumedAtLabel(row.consumed_at)}</span>,
},
{
key: 'tracking_type',
Expand Down Expand Up @@ -429,6 +455,24 @@ export default function ProcessTemplatesBom() {
<>
<Head title={`BOM - ${processTemplate.name}`} />

{steps.length > 0 && (
<div className="mb-4 rounded-om border border-om-line2 overflow-hidden" data-testid="bom-routing-graph">
<RoutingGraph
compact
height={200}
steps={steps}
selectedId={stepFilter ? Number(stepFilter) : null}
// Clicking the selected step again clears the filter.
onSelectStep={(id) => applyStepFilter(String(id) === stepFilter ? '' : String(id))}
nodeBadge={nodeBadge}
focusSelected
/>
<p className="px-3 py-1.5 text-[11px] text-om-muted border-t border-om-line2 m-0">
{__('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.')}
</p>
</div>
)}

<ResourceTable
// Not a synced shape: a BOM belongs to one process template and
// nothing broadcasts it, so the rows come from this page's props.
Expand All @@ -438,10 +482,7 @@ export default function ProcessTemplatesBom() {
<Dropdown
aria-label={__('Filter by step')}
value={stepFilter}
onChange={(value) => {
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}` })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
/>
</div>
<div className="flex-1 bg-om-card p-4 overflow-y-auto [&_.drag-handle]:hidden" style={{ maxHeight: 560 }}>
Expand Down
11 changes: 7 additions & 4 deletions backend/resources/js/Pages/operator/Queue.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ export default function Queue() {
lineWorkstations = [],
downtimeReasons = [],
activeDowntime = null,
operatorCanSwitchLine = true,
} = usePage().props;

// Persist view preference in localStorage
Expand Down Expand Up @@ -799,10 +800,12 @@ export default function Queue() {
</button>
</div>

<Link href="/operator/select-line"
className="px-4 py-2.5 rounded-om-sm text-sm font-medium text-om-ink bg-om-card border border-om-line hover:bg-om-chip transition-colors">
{__("Change Line")}
</Link>
{operatorCanSwitchLine && (
<Link href="/operator/select-line"
className="px-4 py-2.5 rounded-om-sm text-sm font-medium text-om-ink bg-om-card border border-om-line hover:bg-om-chip transition-colors">
{__("Change Line")}
</Link>
)}
</div>
</div>

Expand Down
15 changes: 9 additions & 6 deletions backend/resources/js/Pages/operator/Workstation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -890,12 +891,14 @@ export default function Workstation() {
</Button>
)}

<Link
href="/operator/select-line"
className="px-4 py-2.5 rounded-om-sm text-sm font-medium text-om-ink border border-om-line bg-om-card hover:bg-om-chip transition-colors"
>
{__("Change Line")}
</Link>
{operatorCanSwitchLine && (
<Link
href="/operator/select-line"
className="px-4 py-2.5 rounded-om-sm text-sm font-medium text-om-ink border border-om-line bg-om-card hover:bg-om-chip transition-colors"
>
{__("Change Line")}
</Link>
)}
</div>
</div>

Expand Down
Loading
Loading