From f30ce2438902bbb505ec0a7610a56485ff6f8d3b Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Tue, 26 May 2026 17:01:35 +0200 Subject: [PATCH 01/34] =?UTF-8?q?feat:=20production=20quantity=20correctio?= =?UTF-8?q?n=20=E2=80=94=20configurable=20edit=20policy=20(none/timed/full?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add system settings: production_qty_edit_policy (none/timed/full) + time window in minutes - Settings UI: radio cards in Production tab with conditional time window input - ProductionCorrectionController: edit/update with policy enforcement + audit logging - Workstation view: pencil icon on shift entries (auto-hides after timed window expires) - Correction form: shows order, product, shift, current qty, input for new qty - Recalculates work order produced_qty from all shift entries after correction - Polish translations for all new strings --- .../ProductionCorrectionController.php | 111 ++++++++++++++++++ .../Web/Operator/WorkstationController.php | 5 +- .../Controllers/Web/SettingsController.php | 6 + ...00000_add_production_qty_edit_settings.php | 22 ++++ backend/lang/pl.json | 25 +++- .../views/operator/correct-quantity.blade.php | 69 +++++++++++ .../views/operator/workstation.blade.php | 51 +++++--- .../resources/views/settings/system.blade.php | 50 ++++++++ backend/routes/web.php | 5 + 9 files changed, 328 insertions(+), 16 deletions(-) create mode 100644 backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php create mode 100644 backend/database/migrations/2026_05_26_100000_add_production_qty_edit_settings.php create mode 100644 backend/resources/views/operator/correct-quantity.blade.php diff --git a/backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php b/backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php new file mode 100644 index 00000000..93520e28 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php @@ -0,0 +1,111 @@ +authorizeCorrection($shiftEntry); + + $shiftEntry->load(['workOrder.productType', 'shift']); + + return view('operator.correct-quantity', [ + 'shiftEntry' => $shiftEntry, + 'workOrder' => $shiftEntry->workOrder, + ]); + } + + /** + * Update the shift entry quantity. + */ + public function update(Request $request, WorkOrderShiftEntry $shiftEntry) + { + $this->authorizeCorrection($shiftEntry); + + $validated = $request->validate([ + 'quantity' => 'required|numeric|min:0|max:99999999', + ]); + + $oldQty = (float) $shiftEntry->quantity; + $newQty = (float) $validated['quantity']; + + if ($oldQty === $newQty) { + return redirect()->route('operator.workstation') + ->with('info', __('No changes made.')); + } + + try { + DB::transaction(function () use ($shiftEntry, $oldQty, $newQty) { + // Log the correction in audit + AuditLog::create([ + 'user_id' => auth()->id(), + 'entity_type' => WorkOrderShiftEntry::class, + 'entity_id' => $shiftEntry->id, + 'action' => 'quantity_corrected', + 'before_state' => ['quantity' => $oldQty], + 'after_state' => ['quantity' => $newQty], + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + + // Update the shift entry + $shiftEntry->update([ + 'quantity' => $newQty, + 'user_id' => auth()->id(), + ]); + + // Recalculate work order produced_qty from all shift entries + $workOrder = $shiftEntry->workOrder; + $totalProduced = WorkOrderShiftEntry::where('work_order_id', $workOrder->id) + ->sum('quantity'); + + $workOrder->update(['produced_qty' => $totalProduced]); + }); + } catch (\Throwable $e) { + report($e); + + return back()->with('error', __('Failed to save correction. Please try again.')); + } + + return redirect()->route('operator.workstation') + ->with('success', __('Quantity corrected successfully.')); + } + + /** + * Check whether the current policy allows correction. + */ + private function authorizeCorrection(WorkOrderShiftEntry $shiftEntry): void + { + $settings = DB::table('system_settings') + ->whereIn('key', ['production_qty_edit_policy', 'production_qty_edit_window_minutes']) + ->pluck('value', 'key'); + + $policy = json_decode($settings['production_qty_edit_policy'] ?? '"none"', true) ?? 'none'; + + if ($policy === 'none') { + abort(403, __('Quantity corrections are not allowed.')); + } + + if ($policy === 'timed') { + $windowMinutes = json_decode($settings['production_qty_edit_window_minutes'] ?? '1', true) ?? 1; + $deadline = $shiftEntry->updated_at->addMinutes($windowMinutes); + + if (now()->greaterThan($deadline)) { + abort(403, __('The correction time window has expired.')); + } + } + + // policy === 'full' → always allowed + } +} diff --git a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php index 16ff4c3f..3ea1724e 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php @@ -82,10 +82,13 @@ public function index(Request $request) $settingRows = \Illuminate\Support\Facades\DB::table('system_settings')->get()->keyBy('key'); $trackingMode = json_decode($settingRows['production_tracking_mode']->value ?? '"per_operation"', true) ?? 'per_operation'; + $qtyEditPolicy = json_decode($settingRows['production_qty_edit_policy']->value ?? '"none"', true) ?? 'none'; + $qtyEditWindowMinutes = json_decode($settingRows['production_qty_edit_window_minutes']->value ?? '1', true) ?? 1; return view('operator.workstation', compact( 'workOrders', 'line', 'availableWeeks', 'weekFilter', 'search', - 'issueTypes', 'allColumns', 'shifts', 'shiftEntries', 'today', 'trackingMode' + 'issueTypes', 'allColumns', 'shifts', 'shiftEntries', 'today', 'trackingMode', + 'qtyEditPolicy', 'qtyEditWindowMinutes' )); } diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index 3f12379c..781e8e3a 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -104,6 +104,8 @@ public function showSystemSettings() 'realtime_mode' => json_decode($rows['realtime_mode']->value ?? '"polling"', true) ?? 'polling', 'production_tracking_mode' => json_decode($rows['production_tracking_mode']->value ?? '"per_operation"', true) ?? 'per_operation', 'cors_allowed_origins' => json_decode($rows['cors_allowed_origins']->value ?? '"*"', true) ?? '*', + 'production_qty_edit_policy' => json_decode($rows['production_qty_edit_policy']->value ?? '"none"', true) ?? 'none', + 'production_qty_edit_window_minutes' => json_decode($rows['production_qty_edit_window_minutes']->value ?? '1', true) ?? 1, ]; return view('settings.system', compact('settings')); @@ -253,6 +255,8 @@ public function updateSystemSettings(Request $request) 'realtime_mode' => 'required|in:polling,websocket', 'production_tracking_mode' => 'required|in:per_operation,cumulative,hybrid', 'cors_allowed_origins' => 'nullable|string|max:1000', + 'production_qty_edit_policy' => 'required|in:none,timed,full', + 'production_qty_edit_window_minutes' => 'required_if:production_qty_edit_policy,timed|integer|min:1|max:60', ]); $shiftsPerDay = (int) $validated['schedule_shifts_per_day']; @@ -273,6 +277,8 @@ public function updateSystemSettings(Request $request) 'realtime_mode' => $validated['realtime_mode'], 'production_tracking_mode' => $validated['production_tracking_mode'], 'cors_allowed_origins' => trim($validated['cors_allowed_origins'] ?? '*') ?: '*', + 'production_qty_edit_policy' => $validated['production_qty_edit_policy'], + 'production_qty_edit_window_minutes' => (int) ($validated['production_qty_edit_window_minutes'] ?? 1), ]; foreach ($map as $key => $value) { diff --git a/backend/database/migrations/2026_05_26_100000_add_production_qty_edit_settings.php b/backend/database/migrations/2026_05_26_100000_add_production_qty_edit_settings.php new file mode 100644 index 00000000..4de94895 --- /dev/null +++ b/backend/database/migrations/2026_05_26_100000_add_production_qty_edit_settings.php @@ -0,0 +1,22 @@ +insertOrIgnore([ + ['key' => 'production_qty_edit_policy', 'value' => json_encode('none')], + ['key' => 'production_qty_edit_window_minutes', 'value' => json_encode(1)], + ]); + } + + public function down(): void + { + DB::table('system_settings') + ->whereIn('key', ['production_qty_edit_policy', 'production_qty_edit_window_minutes']) + ->delete(); + } +}; diff --git a/backend/lang/pl.json b/backend/lang/pl.json index bb97e8af..7b5b40fd 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1284,5 +1284,28 @@ "EAN code removed.": "Kod EAN został usunięty.", "You do not have access to this line.": "Nie masz dostępu do tej linii.", "Forbidden": "Brak dostępu", - "Reset packed_qty counters on work_orders for new shift start": "Zresetuj liczniki packed_qty dla zleceń produkcyjnych na początek nowej zmiany" + "Reset packed_qty counters on work_orders for new shift start": "Zresetuj liczniki packed_qty dla zleceń produkcyjnych na początek nowej zmiany", + "Production Quantity Corrections": "Korekty ilości produkcji", + "Defines whether and when operators can correct previously reported quantities.": "Określa, czy i kiedy operatorzy mogą korygować wcześniej zgłoszone ilości.", + "No corrections": "Brak korekt", + "Operators cannot edit reported quantities. All entries are final.": "Operatorzy nie mogą edytować zgłoszonych ilości. Wszystkie wpisy są ostateczne.", + "Timed window": "Okno czasowe", + "Operators can correct quantities within a configurable time window after submission.": "Operatorzy mogą korygować ilości w konfigurowalnym oknie czasowym po zgłoszeniu.", + "Full edit": "Pełna edycja", + "Operators can edit reported quantities at any time.": "Operatorzy mogą edytować zgłoszone ilości w dowolnym momencie.", + "Correction time window": "Okno czasowe korekty", + "How many minutes after submission an operator can still correct the quantity.": "Ile minut po zgłoszeniu operator może jeszcze skorygować ilość.", + "Correct Quantity": "Korekta ilości", + "Modify a previously reported production quantity.": "Zmień wcześniej zgłoszoną ilość produkcji.", + "Current Quantity": "Aktualna ilość", + "New Quantity": "Nowa ilość", + "Save Correction": "Zapisz korektę", + "Correct quantity": "Korekta ilości", + "No changes made.": "Nie wprowadzono zmian.", + "Failed to save correction. Please try again.": "Nie udało się zapisać korekty. Spróbuj ponownie.", + "Quantity corrected successfully.": "Ilość została skorygowana pomyślnie.", + "Quantity corrections are not allowed.": "Korekty ilości są niedozwolone.", + "The correction time window has expired.": "Okno czasowe korekty wygasło.", + "Production Date": "Data produkcji", + "Shift": "Zmiana" } diff --git a/backend/resources/views/operator/correct-quantity.blade.php b/backend/resources/views/operator/correct-quantity.blade.php new file mode 100644 index 00000000..58fe1e37 --- /dev/null +++ b/backend/resources/views/operator/correct-quantity.blade.php @@ -0,0 +1,69 @@ +@extends('layouts.app') + +@section('title', __('Correct Quantity')) + +@section('content') +
+
+
+ + + + + +
+

{{ __('Correct Quantity') }}

+

{{ __('Modify a previously reported production quantity.') }}

+
+
+ +
+
+ {{ __('Order No') }} + {{ $workOrder->order_no }} +
+
+ {{ __('Product') }} + {{ $workOrder->productType?->name ?? '—' }} +
+
+ {{ __('Shift') }} + {{ $shiftEntry->shift->name ?? $shiftEntry->shift->code }} +
+
+ {{ __('Production Date') }} + {{ $shiftEntry->production_date->format('Y-m-d') }} +
+
+ {{ __('Current Quantity') }} + {{ number_format((float) $shiftEntry->quantity, 0) }} +
+
+ +
+ @csrf + @method('PUT') + +
+ + + @error('quantity') +

{{ $message }}

+ @enderror +
+ +
+ {{ __('Cancel') }} + +
+
+
+
+@endsection diff --git a/backend/resources/views/operator/workstation.blade.php b/backend/resources/views/operator/workstation.blade.php index 36054775..70827aa3 100644 --- a/backend/resources/views/operator/workstation.blade.php +++ b/backend/resources/views/operator/workstation.blade.php @@ -241,20 +241,43 @@ class="px-3 py-3 text-sm text-gray-800 dark:text-gray-200"> @endphp @if(!$isDone) -
- @csrf - - -
+
+
+ @csrf + + +
+ @if($entryQty > 0 && isset($shiftEntries[$entryKey]) && $qtyEditPolicy !== 'none') + @php + $entryModel = $shiftEntries[$entryKey]->first(); + $canCorrect = $qtyEditPolicy === 'full' + || ($qtyEditPolicy === 'timed' && $entryModel->updated_at->addMinutes($qtyEditWindowMinutes)->isFuture()); + @endphp + @if($canCorrect) + + + + + + @endif + @endif +
@else {{ $entryQty > 0 ? (int) $entryQty : 0 }} @endif diff --git a/backend/resources/views/settings/system.blade.php b/backend/resources/views/settings/system.blade.php index b92a4023..81de425a 100644 --- a/backend/resources/views/settings/system.blade.php +++ b/backend/resources/views/settings/system.blade.php @@ -207,6 +207,56 @@ class="flex flex-col gap-1 border rounded-lg p-3 cursor-pointer transition-color

{{ $message }}

@enderror + + {{-- Production Quantity Corrections --}} +
+

{{ __('Production Quantity Corrections') }}

+

+ {{ __('Defines whether and when operators can correct previously reported quantities.') }} +

+ + +
+
+ {{ __('No corrections') }} + {{ __('Operators cannot edit reported quantities. All entries are final.') }} +
+
+ {{ __('Timed window') }} + {{ __('Operators can correct quantities within a configurable time window after submission.') }} +
+
+ {{ __('Full edit') }} + {{ __('Operators can edit reported quantities at any time.') }} +
+
+ @error('production_qty_edit_policy') +

{{ $message }}

+ @enderror + +
+ +

+ {{ __('How many minutes after submission an operator can still correct the quantity.') }} +

+
+ + {{ __('minutes') }} +
+ @error('production_qty_edit_window_minutes') +

{{ $message }}

+ @enderror +
+
{{-- ═══ TAB: Schedule ═══ --}} diff --git a/backend/routes/web.php b/backend/routes/web.php index a5fed847..d5fe853a 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -50,6 +50,7 @@ use App\Http\Controllers\Web\Operator\IssueController as OperatorIssueController; use App\Http\Controllers\Web\Operator\LineController as OperatorLineController; use App\Http\Controllers\Web\Operator\WorkOrderController as OperatorWorkOrderController; +use App\Http\Controllers\Web\Operator\ProductionCorrectionController; use App\Http\Controllers\Web\Operator\WorkstationController as OperatorWorkstationController; use App\Http\Controllers\Web\Packaging\LabelPrintController; use App\Http\Controllers\Web\Packaging\LabelTemplateController; @@ -159,6 +160,10 @@ Route::post('/workstation/{workOrder}/start', [OperatorWorkstationController::class, 'start'])->name('workstation.start'); Route::post('/workstation/{workOrder}/complete', [OperatorWorkstationController::class, 'complete'])->name('workstation.complete'); Route::post('/workstation/{workOrder}/shift-entry', [OperatorWorkstationController::class, 'shiftEntry'])->name('workstation.shift-entry'); + + // Production quantity corrections + Route::get('/shift-entry/{shiftEntry}/correct', [ProductionCorrectionController::class, 'edit'])->name('shift-entry.correct'); + Route::put('/shift-entry/{shiftEntry}/correct', [ProductionCorrectionController::class, 'update'])->name('shift-entry.correct.update'); }); // Inbound Inspections (Supervisor + Admin) — inspectors perform from this UI From 3e50cf2b18afc4391993cc93bafe6a584d2d78b9 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Tue, 26 May 2026 22:01:26 +0200 Subject: [PATCH 02/34] chore: bump develop version to v0.12.0 --- backend/config/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/config/version.php b/backend/config/version.php index 80aa36d6..43c96d6f 100644 --- a/backend/config/version.php +++ b/backend/config/version.php @@ -1,6 +1,6 @@ 'v0.11.1', + 'current' => 'v0.12.0', 'archive_url' => env('UPDATE_ARCHIVE_URL', 'https://github.com/Mes-Open/OpenMes/archive/refs/tags/{version}.zip'), ]; From 1472f9d448f996f936a78b8dd49648ba363bf1a2 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 07:53:11 +0200 Subject: [PATCH 03/34] fix(schedule): update planned_start_at/planned_end_at on drag & drop to prevent WO disappearing on line change --- .../resources/views/admin/schedule/planner.blade.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/resources/views/admin/schedule/planner.blade.php b/backend/resources/views/admin/schedule/planner.blade.php index 323a0864..93847e06 100644 --- a/backend/resources/views/admin/schedule/planner.blade.php +++ b/backend/resources/views/admin/schedule/planner.blade.php @@ -202,7 +202,15 @@ function schedulePlanner() { async assignOrder(orderId) { const data = { line_id: this.assignLineId }; - if (this.assignDate) data.due_date = this.assignDate; + if (this.assignDate) { + data.due_date = this.assignDate; + // Also update planned_start_at/planned_end_at to match the target date + // so the WO appears on the correct day in minute-level views + const shiftHours = {1: 0, 2: 6, 3: 12, 4: 18}; + const startHour = shiftHours[this.assignShift] ?? 8; + data.planned_start_at = this.assignDate + 'T' + String(startHour).padStart(2, '0') + ':00:00'; + data.planned_end_at = this.assignDate + 'T' + String(startHour + 6).padStart(2, '0') + ':00:00'; + } if (this.assignWeekNumber) data.week_number = this.assignWeekNumber; if (this.assignShift) data.shift_number = this.assignShift; From f6b3a7a28976e07c1af7c298fe10ae0a84d8f687 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 07:55:08 +0200 Subject: [PATCH 04/34] feat(schedule): stack overlapping WOs in lanes on hourly Gantt view instead of overlapping --- .../Web/Admin/SchedulePlannerController.php | 27 ++++++++++++++++++- .../views/admin/schedule/_hourly.blade.php | 17 +++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php b/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php index ff357992..5e38434e 100644 --- a/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php +++ b/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php @@ -774,8 +774,33 @@ private function buildHourlyData(Carbon $start, $lines, $workOrders, int $slotMi } } - $layouts = $layouts->map(function ($l) use ($conflicts) { + // Assign lanes to overlapping orders so they stack vertically + $sortedLayouts = $layouts->sortBy('start_minute')->values(); + $laneEnds = []; // laneEnds[lane] = end_minute of last WO in that lane + $laneMap = []; + foreach ($sortedLayouts as $l) { + $woId = $l['wo']->id; + $placed = false; + foreach ($laneEnds as $lane => $end) { + if ($l['start_minute'] >= $end) { + $laneMap[$woId] = $lane; + $laneEnds[$lane] = $l['end_minute']; + $placed = true; + break; + } + } + if (! $placed) { + $lane = count($laneEnds); + $laneMap[$woId] = $lane; + $laneEnds[$lane] = $l['end_minute']; + } + } + $totalLanes = max(1, count($laneEnds)); + + $layouts = $layouts->map(function ($l) use ($conflicts, $laneMap, $totalLanes) { $l['has_conflict'] = isset($conflicts[$l['wo']->id]); + $l['lane'] = $laneMap[$l['wo']->id] ?? 0; + $l['total_lanes'] = $totalLanes; return $l; }); diff --git a/backend/resources/views/admin/schedule/_hourly.blade.php b/backend/resources/views/admin/schedule/_hourly.blade.php index 3079fffe..cb017ecb 100644 --- a/backend/resources/views/admin/schedule/_hourly.blade.php +++ b/backend/resources/views/admin/schedule/_hourly.blade.php @@ -73,7 +73,9 @@ @foreach($data['lines'] as $lineRow) -
max('total_lanes') ?? 1; $labelHeight = max(114, 6 + $maxLanesLabel * 34); @endphp +
{{ $lineRow['line']->code ?? $lineRow['line']->name }} @@ -117,8 +119,9 @@ {{-- Lane rows --}} @foreach($data['lines'] as $lineRow) + @php $maxLanes = $lineRow['orders']->max('total_lanes') ?? 1; $rowHeight = max(114, 6 + $maxLanes * 34); @endphp
1 ? max(30, (int)(90 / $totalLanes)) : 90; + $laneTop = 6 + ($lane * ($laneHeight + 2)); + @endphp
Date: Wed, 27 May 2026 09:40:39 +0200 Subject: [PATCH 05/34] docs: add YouTube demo video thumbnail to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c4329c0f..7831f09d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ [![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/fw3fG78pZj) +[![Watch the demo](https://img.youtube.com/vi/G8vHW-fjXV0/maxresdefault.jpg)](https://youtu.be/G8vHW-fjXV0) +
--- From 45fdf466eaa95188ea031d324e9844842fb4f22c Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 10:21:33 +0200 Subject: [PATCH 06/34] docs: remove YouTube video from README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 7831f09d..199e9904 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ [![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/fw3fG78pZj) -[![Watch the demo](https://img.youtube.com/vi/G8vHW-fjXV0/maxresdefault.jpg)](https://youtu.be/G8vHW-fjXV0)
From 6d1a7934eab65718415c0089ee0b9f36e13e8c57 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 21:55:03 +0200 Subject: [PATCH 07/34] feat: add Import button + example CSV download on Materials, Product Types, Lines; add Settings export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import button + ? icon (example CSV download) on Materials, Product Types, Lines index pages - ImportExampleController serves example CSV files per entity type - Settings → Data → Export Settings button downloads all system_settings as JSON - Polish translations for all new strings --- .../Web/Admin/ImportExampleController.php | 52 +++++++++++++++++++ .../Controllers/Web/SettingsController.php | 18 +++++++ backend/lang/pl.json | 8 ++- .../views/admin/lines/index.blade.php | 21 +++++--- .../views/admin/materials/index.blade.php | 5 +- .../views/admin/product-types/index.blade.php | 21 +++++--- .../resources/views/settings/system.blade.php | 11 ++++ backend/routes/web.php | 6 +++ 8 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 backend/app/Http/Controllers/Web/Admin/ImportExampleController.php diff --git a/backend/app/Http/Controllers/Web/Admin/ImportExampleController.php b/backend/app/Http/Controllers/Web/Admin/ImportExampleController.php new file mode 100644 index 00000000..a82bea90 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/ImportExampleController.php @@ -0,0 +1,52 @@ + [ + 'filename' => 'materials_example.csv', + 'headers' => ['code', 'name', 'description', 'material_type', 'unit_of_measure', 'stock_quantity', 'min_stock_level', 'supplier_name'], + 'rows' => [ + ['MAT-STEEL-01', 'Steel Sheet 2mm', 'Cold rolled steel sheet', 'RAW_MATERIAL', 'pcs', '100', '20', 'Steel Corp'], + ['MAT-PAINT-BL', 'Blue Paint RAL 5015', 'Industrial paint', 'CONSUMABLE', 'litre', '50', '10', 'Paint Pro'], + ], + ], + 'product-types' => [ + 'filename' => 'product_types_example.csv', + 'headers' => ['code', 'name', 'description', 'unit_of_measure'], + 'rows' => [ + ['WIDGET-A', 'Widget Type A', 'Standard widget with coating', 'pcs'], + ['BRACKET-S', 'Steel Bracket Small', 'L-shaped mounting bracket', 'pcs'], + ], + ], + 'lines' => [ + 'filename' => 'production_lines_example.csv', + 'headers' => ['code', 'name', 'description'], + 'rows' => [ + ['CNC-1', 'CNC Machining', 'CNC milling and turning center'], + ['ASSEMBLY', 'Assembly Line', 'Manual assembly workstations'], + ], + ], + ]; + + if (!isset($examples[$type])) { + abort(404); + } + + $example = $examples[$type]; + $csv = implode(',', $example['headers']) . "\n"; + foreach ($example['rows'] as $row) { + $csv .= implode(',', $row) . "\n"; + } + + return response($csv) + ->header('Content-Type', 'text/csv') + ->header('Content-Disposition', 'attachment; filename="' . $example['filename'] . '"'); + } +} diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index 781e8e3a..c631e6dc 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -293,4 +293,22 @@ public function updateSystemSettings(Request $request) return redirect()->route('settings.system') ->with('success', 'System settings updated.'); } + + /** + * Export system settings as JSON file + */ + public function exportSettings() + { + $settings = DB::table('system_settings')->pluck('value', 'key')->toArray(); + + $export = [ + 'exported_at' => now()->toISOString(), + 'version' => config('version.current'), + 'settings' => $settings, + ]; + + return response()->json($export, 200, [ + 'Content-Disposition' => 'attachment; filename="openmes-settings-' . date('Y-m-d') . '.json"', + ]); + } } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 7b5b40fd..5635847e 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1307,5 +1307,11 @@ "Quantity corrections are not allowed.": "Korekty ilości są niedozwolone.", "The correction time window has expired.": "Okno czasowe korekty wygasło.", "Production Date": "Data produkcji", - "Shift": "Zmiana" + "Shift": "Zmiana", + "Download example CSV file for materials import": "Pobierz przykładowy plik CSV do importu materiałów", + "Download example CSV file for product types import": "Pobierz przykładowy plik CSV do importu typów produktów", + "Download example CSV file for lines import": "Pobierz przykładowy plik CSV do importu linii produkcyjnych", + "Export Settings": "Eksport ustawień", + "Download current system settings as a JSON file. This includes all configuration options but no production data.": "Pobierz aktualne ustawienia systemu jako plik JSON. Zawiera wszystkie opcje konfiguracji, ale nie dane produkcyjne.", + "Export Settings (JSON)": "Eksportuj ustawienia (JSON)" } diff --git a/backend/resources/views/admin/lines/index.blade.php b/backend/resources/views/admin/lines/index.blade.php index bee4ce13..870bcc3b 100644 --- a/backend/resources/views/admin/lines/index.blade.php +++ b/backend/resources/views/admin/lines/index.blade.php @@ -11,12 +11,21 @@
diff --git a/backend/resources/views/admin/materials/index.blade.php b/backend/resources/views/admin/materials/index.blade.php index 3e534e30..07eb8400 100644 --- a/backend/resources/views/admin/materials/index.blade.php +++ b/backend/resources/views/admin/materials/index.blade.php @@ -11,13 +11,16 @@

{{ __('Materials') }}

-
+
{{ __('Import') }} + ? diff --git a/backend/resources/views/admin/product-types/index.blade.php b/backend/resources/views/admin/product-types/index.blade.php index 690f7286..dd3a6366 100644 --- a/backend/resources/views/admin/product-types/index.blade.php +++ b/backend/resources/views/admin/product-types/index.blade.php @@ -11,12 +11,21 @@
@if($productTypes->count() > 0) diff --git a/backend/resources/views/settings/system.blade.php b/backend/resources/views/settings/system.blade.php index 81de425a..205f7d6b 100644 --- a/backend/resources/views/settings/system.blade.php +++ b/backend/resources/views/settings/system.blade.php @@ -464,6 +464,17 @@ class="btn-touch px-4 py-2 text-sm font-medium rounded-lg border
+ +
+

{{ __('Export Settings') }}

+

+ {{ __('Download current system settings as a JSON file. This includes all configuration options but no production data.') }} +

+ + + {{ __('Export Settings (JSON)') }} + +
diff --git a/backend/routes/web.php b/backend/routes/web.php index d5fe853a..61c386ed 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Web\Admin\CostSourceController; use App\Http\Controllers\Web\Admin\CrewController; use App\Http\Controllers\Web\Admin\CsvImportController as AdminCsvImportController; +use App\Http\Controllers\Web\Admin\ImportExampleController; use App\Http\Controllers\Web\Admin\DashboardController as AdminDashboardController; use App\Http\Controllers\Web\Admin\DivisionController; use App\Http\Controllers\Web\Admin\FactoryController; @@ -111,6 +112,8 @@ Route::post('/system', [\App\Http\Controllers\Web\SettingsController::class, 'updateSystemSettings'])->name('update-system')->middleware('role:Admin'); // Admin-only sample data Route::post('/sample-data', [\App\Http\Controllers\Web\SettingsController::class, 'loadSampleData'])->name('sample-data')->middleware('role:Admin'); + // Admin-only settings export + Route::get('/export', [\App\Http\Controllers\Web\SettingsController::class, 'exportSettings'])->name('export')->middleware('role:Admin'); // PIN management Route::get('/pin', [\App\Http\Controllers\Web\SettingsController::class, 'showPinForm'])->name('pin'); Route::post('/pin', [\App\Http\Controllers\Web\SettingsController::class, 'updatePin'])->name('update-pin'); @@ -351,6 +354,9 @@ // Integration Configs Route::resource('integrations', IntegrationConfigController::class)->except(['show']); + // Import Example CSV + Route::get('/import-example/{type}', [ImportExampleController::class, 'download'])->name('import-example'); + // CSV Import Route::get('/csv-import', [AdminCsvImportController::class, 'index'])->name('csv-import'); Route::post('/csv-import/upload', [AdminCsvImportController::class, 'upload'])->name('csv-import.upload'); From 2bed0f037ecb812e9b6ca1c04691d3e8b4db4a0d Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 22:00:48 +0200 Subject: [PATCH 08/34] feat: add settings import (JSON upload) with security whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import Settings section in Settings → Data tab - Upload previously exported JSON, overwrites current settings - Forbidden keys blacklist (db credentials, app_key) — never imported - Only string/numeric values accepted, non-scalar silently skipped - Cache flush after import - Polish translations --- .../Controllers/Web/SettingsController.php | 50 +++++++++++++++++++ backend/lang/pl.json | 8 ++- .../resources/views/settings/system.blade.php | 19 +++++++ backend/routes/web.php | 3 +- 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index c631e6dc..d25aa3e6 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -311,4 +311,54 @@ public function exportSettings() 'Content-Disposition' => 'attachment; filename="openmes-settings-' . date('Y-m-d') . '.json"', ]); } + + /** + * Import system settings from JSON file + */ + public function importSettings(Request $request) + { + $request->validate([ + 'settings_file' => 'required|file|mimes:json,txt|max:1024', + ]); + + try { + $content = file_get_contents($request->file('settings_file')->getRealPath()); + $data = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return back()->with('error', __('Invalid JSON file.')); + } + + if (!isset($data['settings']) || !is_array($data['settings'])) { + return back()->with('error', __('Invalid settings file format. Missing "settings" key.')); + } + + // Whitelist of allowed setting keys — never import sensitive keys + $forbidden = ['app_key', 'db_host', 'db_port', 'db_database', 'db_username', 'db_password']; + + $imported = 0; + foreach ($data['settings'] as $key => $value) { + if (in_array(strtolower($key), $forbidden, true)) { + continue; + } + + if (!is_string($value) && !is_numeric($value)) { + continue; + } + + DB::table('system_settings')->updateOrInsert( + ['key' => $key], + ['value' => (string) $value] + ); + $imported++; + } + + Cache::flush(); + + return back()->with('success', __(':count settings imported successfully.', ['count' => $imported])); + } catch (\Exception $e) { + report($e); + return back()->with('error', __('Failed to import settings. Please check the file and try again.')); + } + } } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 5635847e..18351c6b 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1313,5 +1313,11 @@ "Download example CSV file for lines import": "Pobierz przykładowy plik CSV do importu linii produkcyjnych", "Export Settings": "Eksport ustawień", "Download current system settings as a JSON file. This includes all configuration options but no production data.": "Pobierz aktualne ustawienia systemu jako plik JSON. Zawiera wszystkie opcje konfiguracji, ale nie dane produkcyjne.", - "Export Settings (JSON)": "Eksportuj ustawienia (JSON)" + "Export Settings (JSON)": "Eksportuj ustawienia (JSON)", + "Import Settings": "Import ustawień", + "Upload a previously exported JSON settings file. This will overwrite current settings. Database credentials and sensitive keys are never imported.": "Wgraj wcześniej wyeksportowany plik JSON z ustawieniami. Nadpisze bieżące ustawienia. Dane dostępowe do bazy i klucze wrażliwe nigdy nie są importowane.", + "Invalid JSON file.": "Nieprawidłowy plik JSON.", + "Invalid settings file format. Missing \"settings\" key.": "Nieprawidłowy format pliku ustawień. Brak klucza \"settings\".", + ":count settings imported successfully.": "Zaimportowano :count ustawień pomyślnie.", + "Failed to import settings. Please check the file and try again.": "Nie udało się zaimportować ustawień. Sprawdź plik i spróbuj ponownie." } diff --git a/backend/resources/views/settings/system.blade.php b/backend/resources/views/settings/system.blade.php index 205f7d6b..91ff563c 100644 --- a/backend/resources/views/settings/system.blade.php +++ b/backend/resources/views/settings/system.blade.php @@ -475,6 +475,25 @@ class="btn-touch px-4 py-2 text-sm font-medium rounded-lg border {{ __('Export Settings (JSON)') }}
+ +
+

{{ __('Import Settings') }}

+

+ {{ __('Upload a previously exported JSON settings file. This will overwrite current settings. Database credentials and sensitive keys are never imported.') }} +

+
+ @csrf + + +
+ @error('settings_file') +

{{ $message }}

+ @enderror +
diff --git a/backend/routes/web.php b/backend/routes/web.php index 61c386ed..de80eb4d 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -112,8 +112,9 @@ Route::post('/system', [\App\Http\Controllers\Web\SettingsController::class, 'updateSystemSettings'])->name('update-system')->middleware('role:Admin'); // Admin-only sample data Route::post('/sample-data', [\App\Http\Controllers\Web\SettingsController::class, 'loadSampleData'])->name('sample-data')->middleware('role:Admin'); - // Admin-only settings export + // Admin-only settings export/import Route::get('/export', [\App\Http\Controllers\Web\SettingsController::class, 'exportSettings'])->name('export')->middleware('role:Admin'); + Route::post('/import', [\App\Http\Controllers\Web\SettingsController::class, 'importSettings'])->name('import')->middleware('role:Admin'); // PIN management Route::get('/pin', [\App\Http\Controllers\Web\SettingsController::class, 'showPinForm'])->name('pin'); Route::post('/pin', [\App\Http\Controllers\Web\SettingsController::class, 'updatePin'])->name('update-pin'); From 0b3fe1bb41bda45efac0958004161fe279305340 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 22:04:03 +0200 Subject: [PATCH 09/34] fix(security): ownership check on production corrections, harden settings import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: - ProductionCorrectionController: add ownership check — operators can only correct their own entries, Supervisor/Admin can correct any MEDIUM: - Settings import: expanded forbidden keys (app_debug, cors, reverb, mail, modules_enabled) - Settings import: only update existing keys — no arbitrary key injection - Settings import: max value length 1000 chars --- .../ProductionCorrectionController.php | 9 +++++++ .../Controllers/Web/SettingsController.php | 24 +++++++++++++++++-- backend/lang/pl.json | 3 ++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php b/backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php index 93520e28..577a44b1 100644 --- a/backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php +++ b/backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php @@ -87,6 +87,15 @@ public function update(Request $request, WorkOrderShiftEntry $shiftEntry) */ private function authorizeCorrection(WorkOrderShiftEntry $shiftEntry): void { + // Ownership check — only entry creator or Supervisor/Admin can correct + $user = auth()->user(); + if ( + $shiftEntry->user_id !== $user->id + && ! $user->hasRole(['Supervisor', 'Admin']) + ) { + abort(403, __('You can only correct your own entries.')); + } + $settings = DB::table('system_settings') ->whereIn('key', ['production_qty_edit_policy', 'production_qty_edit_window_minutes']) ->pluck('value', 'key'); diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index d25aa3e6..1338d937 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -333,8 +333,18 @@ public function importSettings(Request $request) return back()->with('error', __('Invalid settings file format. Missing "settings" key.')); } - // Whitelist of allowed setting keys — never import sensitive keys - $forbidden = ['app_key', 'db_host', 'db_port', 'db_database', 'db_username', 'db_password']; + // Forbidden keys — never import sensitive or infrastructure settings + $forbidden = [ + 'app_key', 'app_debug', 'app_env', + 'db_host', 'db_port', 'db_database', 'db_username', 'db_password', 'db_connection', + 'mail_host', 'mail_port', 'mail_username', 'mail_password', + 'cors_allowed_origins', 'cors_allowed_methods', + 'reverb_app_id', 'reverb_app_key', 'reverb_app_secret', + 'modules_enabled', + ]; + + // Only import keys that already exist in the database (no arbitrary key injection) + $existingKeys = DB::table('system_settings')->pluck('key')->toArray(); $imported = 0; foreach ($data['settings'] as $key => $value) { @@ -346,6 +356,16 @@ public function importSettings(Request $request) continue; } + // Only update existing keys — never create new ones from import + if (!in_array($key, $existingKeys, true)) { + continue; + } + + // Limit value length to prevent abuse + if (strlen((string) $value) > 1000) { + continue; + } + DB::table('system_settings')->updateOrInsert( ['key' => $key], ['value' => (string) $value] diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 18351c6b..51c5efde 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1319,5 +1319,6 @@ "Invalid JSON file.": "Nieprawidłowy plik JSON.", "Invalid settings file format. Missing \"settings\" key.": "Nieprawidłowy format pliku ustawień. Brak klucza \"settings\".", ":count settings imported successfully.": "Zaimportowano :count ustawień pomyślnie.", - "Failed to import settings. Please check the file and try again.": "Nie udało się zaimportować ustawień. Sprawdź plik i spróbuj ponownie." + "Failed to import settings. Please check the file and try again.": "Nie udało się zaimportować ustawień. Sprawdź plik i spróbuj ponownie.", + "You can only correct your own entries.": "Możesz korygować tylko własne wpisy." } From ad2b0a8dc456836b4ee1155c5b36c27a2ff133fd Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 22:14:50 +0200 Subject: [PATCH 10/34] =?UTF-8?q?fix(security):=20restrict=20CORS=20defaul?= =?UTF-8?q?ts=20=E2=80=94=20empty=20origins=20(block=20all),=20GET/POST=20?= =?UTF-8?q?only,=20no=20preflight=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default cors_allowed_origins: empty (block all cross-origin) instead of * (allow all) - Added cors_allowed_methods setting (default: GET, POST) - Added cors_max_age setting (default: 0 — no preflight caching) - Middleware reads all 3 CORS settings from DB - Empty origins = no CORS headers sent (most restrictive) - Non-matching origin = no CORS headers (fail-closed) - Polish translations --- .../Controllers/Web/SettingsController.php | 6 ++- backend/app/Http/Middleware/DynamicCors.php | 37 +++++++++++--- backend/lang/pl.json | 9 +++- .../resources/views/settings/system.blade.php | 50 ++++++++++++++----- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index 1338d937..a6ee69d0 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -255,6 +255,8 @@ public function updateSystemSettings(Request $request) 'realtime_mode' => 'required|in:polling,websocket', 'production_tracking_mode' => 'required|in:per_operation,cumulative,hybrid', 'cors_allowed_origins' => 'nullable|string|max:1000', + 'cors_allowed_methods' => 'nullable|string|max:200', + 'cors_max_age' => 'nullable|integer|min:0|max:86400', 'production_qty_edit_policy' => 'required|in:none,timed,full', 'production_qty_edit_window_minutes' => 'required_if:production_qty_edit_policy,timed|integer|min:1|max:60', ]); @@ -276,7 +278,9 @@ public function updateSystemSettings(Request $request) 'schedule_slot_duration_hours' => $slotDuration, 'realtime_mode' => $validated['realtime_mode'], 'production_tracking_mode' => $validated['production_tracking_mode'], - 'cors_allowed_origins' => trim($validated['cors_allowed_origins'] ?? '*') ?: '*', + 'cors_allowed_origins' => trim($validated['cors_allowed_origins'] ?? '') ?: '', + 'cors_allowed_methods' => trim($validated['cors_allowed_methods'] ?? 'GET, POST') ?: 'GET, POST', + 'cors_max_age' => max(0, min(86400, (int) ($validated['cors_max_age'] ?? 0))), 'production_qty_edit_policy' => $validated['production_qty_edit_policy'], 'production_qty_edit_window_minutes' => (int) ($validated['production_qty_edit_window_minutes'] ?? 1), ]; diff --git a/backend/app/Http/Middleware/DynamicCors.php b/backend/app/Http/Middleware/DynamicCors.php index fa4e4b8f..1f55929b 100644 --- a/backend/app/Http/Middleware/DynamicCors.php +++ b/backend/app/Http/Middleware/DynamicCors.php @@ -20,14 +20,24 @@ public function handle(Request $request, Closure $next): Response return $response; } - $allowedRaw = Cache::remember('cors_allowed_origins', 60, function () { - $row = DB::table('system_settings') - ->where('key', 'cors_allowed_origins') - ->value('value'); - - return $row ? json_decode($row, true) : '*'; + $corsSettings = Cache::remember('cors_settings', 60, function () { + return DB::table('system_settings') + ->whereIn('key', ['cors_allowed_origins', 'cors_allowed_methods', 'cors_max_age']) + ->pluck('value', 'key') + ->toArray(); }); + $allowedRaw = $corsSettings['cors_allowed_origins'] ?? ''; + // Strip JSON encoding if stored as JSON string + if (str_starts_with($allowedRaw, '"')) { + $allowedRaw = json_decode($allowedRaw, true) ?? $allowedRaw; + } + + // Empty = block all cross-origin requests (most secure default) + if (empty($allowedRaw) || $allowedRaw === '""') { + return $response; + } + if ($allowedRaw === '*') { $response->headers->set('Access-Control-Allow-Origin', '*'); } else { @@ -37,9 +47,24 @@ public function handle(Request $request, Closure $next): Response if (in_array($origin, $origins, true)) { $response->headers->set('Access-Control-Allow-Origin', $origin); $response->headers->set('Vary', 'Origin'); + } else { + return $response; // Origin not allowed — no CORS headers } } + $methods = $corsSettings['cors_allowed_methods'] ?? 'GET, POST'; + if (str_starts_with($methods, '"')) { + $methods = json_decode($methods, true) ?? $methods; + } + $response->headers->set('Access-Control-Allow-Methods', $methods); + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, X-CSRF-TOKEN, Accept'); + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + + $maxAge = (int) ($corsSettings['cors_max_age'] ?? 0); + if ($maxAge > 0) { + $response->headers->set('Access-Control-Max-Age', (string) $maxAge); + } + return $response; } } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 51c5efde..c0a1638b 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1320,5 +1320,12 @@ "Invalid settings file format. Missing \"settings\" key.": "Nieprawidłowy format pliku ustawień. Brak klucza \"settings\".", ":count settings imported successfully.": "Zaimportowano :count ustawień pomyślnie.", "Failed to import settings. Please check the file and try again.": "Nie udało się zaimportować ustawień. Sprawdź plik i spróbuj ponownie.", - "You can only correct your own entries.": "Możesz korygować tylko własne wpisy." + "You can only correct your own entries.": "Możesz korygować tylko własne wpisy.", + "Control which external domains can make API requests to this application. Leave empty to block all cross-origin requests (most secure).": "Kontroluj, które zewnętrzne domeny mogą wysyłać żądania API. Pozostaw puste, aby zablokować wszystkie żądania cross-origin (najbezpieczniej).", + "Allowed Origins": "Dozwolone źródła", + "Comma-separated list of allowed origins. Only HTTPS URLs recommended. Leave empty to block all cross-origin requests.": "Lista dozwolonych źródeł oddzielona przecinkami. Zalecane tylko adresy HTTPS. Pozostaw puste, aby zablokować wszystkie żądania cross-origin.", + "Allowed Methods": "Dozwolone metody", + "HTTP methods allowed for cross-origin requests. Default: GET, POST (minimal).": "Metody HTTP dozwolone dla żądań cross-origin. Domyślnie: GET, POST (minimalne).", + "Preflight Cache (seconds)": "Cache preflight (sekundy)", + "How long browsers cache preflight responses. 0 = no caching (strictest).": "Jak długo przeglądarki cachują odpowiedzi preflight. 0 = brak cachowania (najsurowsze)." } diff --git a/backend/resources/views/settings/system.blade.php b/backend/resources/views/settings/system.blade.php index 91ff563c..d3078829 100644 --- a/backend/resources/views/settings/system.blade.php +++ b/backend/resources/views/settings/system.blade.php @@ -412,21 +412,45 @@ class="rounded border-gray-300 text-blue-600"

{{ __('CORS (Cross-Origin Requests)') }}

- {{ __('Control which external domains can make API requests to this application.') }} + {{ __('Control which external domains can make API requests to this application. Leave empty to block all cross-origin requests (most secure).') }}

-
- - -

- {{ __('Comma-separated list of allowed origins (e.g. https://example.com, https://app.example.com). Use * to allow all origins (not recommended for production).') }} -

- @error('cors_allowed_origins') -

{{ $message }}

- @enderror +
+
+ + +

+ {{ __('Comma-separated list of allowed origins. Only HTTPS URLs recommended. Leave empty to block all cross-origin requests.') }} +

+ @error('cors_allowed_origins') +

{{ $message }}

+ @enderror +
+ +
+ + +

+ {{ __('HTTP methods allowed for cross-origin requests. Default: GET, POST (minimal).') }} +

+
+ +
+ + +

+ {{ __('How long browsers cache preflight responses. 0 = no caching (strictest).') }} +

+
From 970d35020329cee4dbe57a2590f60223c8719f52 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 22:31:55 +0200 Subject: [PATCH 11/34] =?UTF-8?q?feat:=20full=20config=20export/import=20?= =?UTF-8?q?=E2=80=94=20lines,=20products,=20templates,=20materials,=20shif?= =?UTF-8?q?ts,=20ISA-95,=20and=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export: dumps 21 config tables + system_settings to JSON (no production data, no users) - Import: truncate + re-insert for config tables, key-value update for system_settings - Backward compatible with old settings-only format - DB transaction for atomicity, rollback on error - Skips id/created_at/updated_at/tenant_id on import - Max file size 10MB - Updated UI descriptions and Polish translations --- .../Controllers/Web/SettingsController.php | 128 +++++++++++++----- backend/lang/pl.json | 3 + .../resources/views/settings/system.blade.php | 4 +- 3 files changed, 100 insertions(+), 35 deletions(-) diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index a6ee69d0..53f4b4f3 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Schema; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\PersonalAccessToken; @@ -299,30 +300,56 @@ public function updateSystemSettings(Request $request) } /** - * Export system settings as JSON file + * Export full system configuration as JSON file */ public function exportSettings() { - $settings = DB::table('system_settings')->pluck('value', 'key')->toArray(); - $export = [ 'exported_at' => now()->toISOString(), 'version' => config('version.current'), - 'settings' => $settings, + 'system_settings' => DB::table('system_settings')->pluck('value', 'key')->toArray(), + ]; + + $tables = [ + 'lines', 'workstations', 'product_types', 'process_templates', + 'template_steps', 'material_types', 'materials', 'bom_items', + 'issue_types', 'shifts', 'line_statuses', 'dashboard_widgets', + 'maintenance_schedules', 'sites', 'areas', 'skills', + 'personnel_classes', 'process_segments', ]; + foreach ($tables as $table) { + try { + $export[$table] = DB::table($table)->get()->map(fn($r) => (array) $r)->toArray(); + } catch (\Exception $e) { + // table may not exist yet + } + } + + // Add optional tables only if they exist + $optionalTables = ['inspection_plans', 'view_templates', 'label_templates']; + foreach ($optionalTables as $table) { + try { + if (Schema::hasTable($table)) { + $export[$table] = DB::table($table)->get()->map(fn($r) => (array) $r)->toArray(); + } + } catch (\Exception $e) { + // table may not exist yet + } + } + return response()->json($export, 200, [ - 'Content-Disposition' => 'attachment; filename="openmes-settings-' . date('Y-m-d') . '.json"', + 'Content-Disposition' => 'attachment; filename="openmes-config-' . date('Y-m-d') . '.json"', ]); } /** - * Import system settings from JSON file + * Import system configuration from JSON file */ public function importSettings(Request $request) { $request->validate([ - 'settings_file' => 'required|file|mimes:json,txt|max:1024', + 'settings_file' => 'required|file|mimes:json,txt|max:10240', ]); try { @@ -333,12 +360,24 @@ public function importSettings(Request $request) return back()->with('error', __('Invalid JSON file.')); } - if (!isset($data['settings']) || !is_array($data['settings'])) { - return back()->with('error', __('Invalid settings file format. Missing "settings" key.')); + // Backward compat: old format with just 'settings' key + if (isset($data['settings']) && !isset($data['system_settings'])) { + $data['system_settings'] = $data['settings']; } - // Forbidden keys — never import sensitive or infrastructure settings - $forbidden = [ + $allowedTables = [ + 'system_settings', 'lines', 'workstations', 'product_types', + 'process_templates', 'template_steps', 'material_types', 'materials', + 'bom_items', 'issue_types', 'shifts', 'line_statuses', + 'dashboard_widgets', 'maintenance_schedules', + 'sites', 'areas', 'skills', 'personnel_classes', 'process_segments', + 'inspection_plans', 'view_templates', 'label_templates', + ]; + + $skipColumns = ['id', 'created_at', 'updated_at', 'tenant_id']; + + // Forbidden system_settings keys + $forbiddenSettings = [ 'app_key', 'app_debug', 'app_env', 'db_host', 'db_port', 'db_database', 'db_username', 'db_password', 'db_connection', 'mail_host', 'mail_port', 'mail_username', 'mail_password', @@ -347,40 +386,63 @@ public function importSettings(Request $request) 'modules_enabled', ]; - // Only import keys that already exist in the database (no arbitrary key injection) - $existingKeys = DB::table('system_settings')->pluck('key')->toArray(); - $imported = 0; - foreach ($data['settings'] as $key => $value) { - if (in_array(strtolower($key), $forbidden, true)) { - continue; - } - if (!is_string($value) && !is_numeric($value)) { - continue; - } + DB::beginTransaction(); - // Only update existing keys — never create new ones from import - if (!in_array($key, $existingKeys, true)) { - continue; - } + foreach ($data as $tableName => $rows) { + if (!in_array($tableName, $allowedTables, true)) continue; + if (!is_array($rows)) continue; + if (!Schema::hasTable($tableName)) continue; + + if ($tableName === 'system_settings') { + // Special handling: key-value update, not replace + $existingKeys = DB::table('system_settings')->pluck('key')->toArray(); - // Limit value length to prevent abuse - if (strlen((string) $value) > 1000) { + foreach ($rows as $key => $value) { + if (in_array(strtolower($key), $forbiddenSettings, true)) continue; + if (!is_string($value) && !is_numeric($value)) continue; + if (strlen((string) $value) > 1000) continue; + if (!in_array($key, $existingKeys, true)) continue; + + DB::table('system_settings')->where('key', $key)->update(['value' => (string) $value]); + $imported++; + } continue; } - DB::table('system_settings')->updateOrInsert( - ['key' => $key], - ['value' => (string) $value] - ); - $imported++; + // For all other tables: clear and re-insert + if (empty($rows)) continue; + + DB::table($tableName)->truncate(); + + foreach ($rows as $row) { + if (!is_array($row)) continue; + // Remove auto-generated columns + foreach ($skipColumns as $col) { + unset($row[$col]); + } + // Remove null values for columns that might not accept null + $row = array_filter($row, fn($v) => $v !== null); + + if (!empty($row)) { + try { + DB::table($tableName)->insert($row); + $imported++; + } catch (\Exception $e) { + // Skip invalid rows silently + continue; + } + } + } } + DB::commit(); Cache::flush(); - return back()->with('success', __(':count settings imported successfully.', ['count' => $imported])); + return back()->with('success', __(':count configuration items imported successfully.', ['count' => $imported])); } catch (\Exception $e) { + DB::rollBack(); report($e); return back()->with('error', __('Failed to import settings. Please check the file and try again.')); } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index c0a1638b..d0b4e7d9 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1313,12 +1313,15 @@ "Download example CSV file for lines import": "Pobierz przykładowy plik CSV do importu linii produkcyjnych", "Export Settings": "Eksport ustawień", "Download current system settings as a JSON file. This includes all configuration options but no production data.": "Pobierz aktualne ustawienia systemu jako plik JSON. Zawiera wszystkie opcje konfiguracji, ale nie dane produkcyjne.", + "Download complete system configuration as a JSON file. Includes lines, workstations, product types, templates, materials, shifts, and all settings. No production data or user accounts are exported.": "Pobierz pełną konfigurację systemu jako plik JSON. Zawiera linie, stanowiska, typy produktów, szablony, materiały, zmiany i wszystkie ustawienia. Dane produkcyjne ani konta użytkowników nie są eksportowane.", "Export Settings (JSON)": "Eksportuj ustawienia (JSON)", "Import Settings": "Import ustawień", "Upload a previously exported JSON settings file. This will overwrite current settings. Database credentials and sensitive keys are never imported.": "Wgraj wcześniej wyeksportowany plik JSON z ustawieniami. Nadpisze bieżące ustawienia. Dane dostępowe do bazy i klucze wrażliwe nigdy nie są importowane.", + "Upload a previously exported configuration file. This will overwrite current configuration including lines, products, templates, materials, and settings. Production data (work orders, batches, issues) is never affected. Database credentials are never imported.": "Wgraj wcześniej wyeksportowany plik konfiguracji. Nadpisze bieżącą konfigurację w tym linie, produkty, szablony, materiały i ustawienia. Dane produkcyjne (zlecenia, partie, zgłoszenia) nie są zmieniane. Dane dostępowe do bazy nigdy nie są importowane.", "Invalid JSON file.": "Nieprawidłowy plik JSON.", "Invalid settings file format. Missing \"settings\" key.": "Nieprawidłowy format pliku ustawień. Brak klucza \"settings\".", ":count settings imported successfully.": "Zaimportowano :count ustawień pomyślnie.", + ":count configuration items imported successfully.": ":count elementów konfiguracji zaimportowano pomyślnie.", "Failed to import settings. Please check the file and try again.": "Nie udało się zaimportować ustawień. Sprawdź plik i spróbuj ponownie.", "You can only correct your own entries.": "Możesz korygować tylko własne wpisy.", "Control which external domains can make API requests to this application. Leave empty to block all cross-origin requests (most secure).": "Kontroluj, które zewnętrzne domeny mogą wysyłać żądania API. Pozostaw puste, aby zablokować wszystkie żądania cross-origin (najbezpieczniej).", diff --git a/backend/resources/views/settings/system.blade.php b/backend/resources/views/settings/system.blade.php index d3078829..cd676fb4 100644 --- a/backend/resources/views/settings/system.blade.php +++ b/backend/resources/views/settings/system.blade.php @@ -492,7 +492,7 @@ class="btn-touch px-4 py-2 text-sm font-medium rounded-lg border

{{ __('Export Settings') }}

- {{ __('Download current system settings as a JSON file. This includes all configuration options but no production data.') }} + {{ __('Download complete system configuration as a JSON file. Includes lines, workstations, product types, templates, materials, shifts, and all settings. No production data or user accounts are exported.') }}

@@ -503,7 +503,7 @@ class="btn-touch px-4 py-2 text-sm font-medium rounded-lg border

{{ __('Import Settings') }}

- {{ __('Upload a previously exported JSON settings file. This will overwrite current settings. Database credentials and sensitive keys are never imported.') }} + {{ __('Upload a previously exported configuration file. This will overwrite current configuration including lines, products, templates, materials, and settings. Production data (work orders, batches, issues) is never affected. Database credentials are never imported.') }}

@csrf From e8f63160682f4494c013acbccb077854c088c288 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 22:34:50 +0200 Subject: [PATCH 12/34] fix: config import uses upsert instead of truncate to preserve FK relations with production data --- .../Controllers/Web/SettingsController.php | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index 53f4b4f3..909d1d5c 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -411,13 +411,24 @@ public function importSettings(Request $request) continue; } - // For all other tables: clear and re-insert + // For all other tables: upsert by unique key (code or name) if (empty($rows)) continue; - DB::table($tableName)->truncate(); + // Determine unique key for upsert + $uniqueKey = match ($tableName) { + 'lines', 'workstations', 'product_types', 'material_types', + 'materials', 'issue_types', 'shifts', 'skills', + 'personnel_classes', 'process_segments', 'sites', 'areas' => 'code', + 'line_statuses' => 'name', + 'dashboard_widgets' => 'widget_id', + default => null, + }; foreach ($rows as $row) { if (!is_array($row)) continue; + + $originalId = $row['id'] ?? null; + // Remove auto-generated columns foreach ($skipColumns as $col) { unset($row[$col]); @@ -425,14 +436,21 @@ public function importSettings(Request $request) // Remove null values for columns that might not accept null $row = array_filter($row, fn($v) => $v !== null); - if (!empty($row)) { - try { + if (empty($row)) continue; + + try { + if ($uniqueKey && isset($row[$uniqueKey])) { + DB::table($tableName)->updateOrInsert( + [$uniqueKey => $row[$uniqueKey]], + $row + ); + } else { DB::table($tableName)->insert($row); - $imported++; - } catch (\Exception $e) { - // Skip invalid rows silently - continue; } + $imported++; + } catch (\Exception $e) { + // Skip invalid rows silently + continue; } } } From 877df3589584aaf8a71ee4102248c36ab0e62bb3 Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 22:36:12 +0200 Subject: [PATCH 13/34] =?UTF-8?q?fix:=20config=20import=20=E2=80=94=20use?= =?UTF-8?q?=20savepoints=20for=20PostgreSQL=20error=20recovery,=20add=20un?= =?UTF-8?q?ique=20keys=20for=20more=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/Http/Controllers/Web/SettingsController.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index 909d1d5c..0811e81b 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -419,7 +419,8 @@ public function importSettings(Request $request) 'lines', 'workstations', 'product_types', 'material_types', 'materials', 'issue_types', 'shifts', 'skills', 'personnel_classes', 'process_segments', 'sites', 'areas' => 'code', - 'line_statuses' => 'name', + 'line_statuses', 'process_templates', 'maintenance_schedules', + 'inspection_plans', 'label_templates' => 'name', 'dashboard_widgets' => 'widget_id', default => null, }; @@ -439,6 +440,7 @@ public function importSettings(Request $request) if (empty($row)) continue; try { + DB::statement('SAVEPOINT row_insert'); if ($uniqueKey && isset($row[$uniqueKey])) { DB::table($tableName)->updateOrInsert( [$uniqueKey => $row[$uniqueKey]], @@ -447,9 +449,10 @@ public function importSettings(Request $request) } else { DB::table($tableName)->insert($row); } + DB::statement('RELEASE SAVEPOINT row_insert'); $imported++; } catch (\Exception $e) { - // Skip invalid rows silently + DB::statement('ROLLBACK TO SAVEPOINT row_insert'); continue; } } From 1e7c84154ab282533595a0e0eee67fa093bfd2bd Mon Sep 17 00:00:00 2001 From: jakub-przepiora Date: Wed, 27 May 2026 22:39:23 +0200 Subject: [PATCH 14/34] feat(alerts): show ALL open issues (not just blocking), add real-time polling with alert sound - AlertController: add nonBlockingIssues to view, totalCount includes all open issues - New /alerts/check endpoint for JSON polling - Alerts page polls every 5 seconds for new issues - Red banner with pulse animation when new alert detected - Triple beep sound (880Hz square wave) on new alert - Non-blocking issues shown in separate table below grid - Live indicator in header --- .../Controllers/Web/Admin/AlertController.php | 29 +++- .../views/admin/alerts/index.blade.php | 127 +++++++++++++++++- backend/routes/web.php | 1 + 3 files changed, 150 insertions(+), 7 deletions(-) diff --git a/backend/app/Http/Controllers/Web/Admin/AlertController.php b/backend/app/Http/Controllers/Web/Admin/AlertController.php index 4f7d564b..7e971594 100644 --- a/backend/app/Http/Controllers/Web/Admin/AlertController.php +++ b/backend/app/Http/Controllers/Web/Admin/AlertController.php @@ -10,13 +10,22 @@ class AlertController extends Controller { public function index() { - // Blocking issues — open or acknowledged, with blocking issue type + // ALL open issues — blocking first, then non-blocking $blockingIssues = Issue::with(['workOrder', 'issueType', 'reportedBy']) ->whereIn('status', [Issue::STATUS_OPEN, Issue::STATUS_ACKNOWLEDGED]) ->whereHas('issueType', fn($q) => $q->where('is_blocking', true)) ->orderBy('created_at', 'desc') ->get(); + $nonBlockingIssues = Issue::with(['workOrder', 'issueType', 'reportedBy']) + ->whereIn('status', [Issue::STATUS_OPEN, Issue::STATUS_ACKNOWLEDGED]) + ->where(function ($q) { + $q->whereHas('issueType', fn($q2) => $q2->where('is_blocking', false)) + ->orWhereDoesntHave('issueType'); + }) + ->orderBy('created_at', 'desc') + ->get(); + // Overdue work orders — past due_date, not terminal $overdueOrders = WorkOrder::with('line') ->whereNotNull('due_date') @@ -33,18 +42,30 @@ public function index() return view('admin.alerts.index', compact( 'blockingIssues', + 'nonBlockingIssues', 'overdueOrders', 'blockedOrders', )); } + /** + * JSON endpoint for real-time polling. + */ + public function check() + { + return response()->json([ + 'total' => static::totalCount(), + 'latest_issue_at' => Issue::whereIn('status', [Issue::STATUS_OPEN, Issue::STATUS_ACKNOWLEDGED]) + ->max('created_at'), + ]); + } + /** * Returns total alert count for navbar badge (called via shared view composer). */ public static function totalCount(): int { - $blocking = Issue::whereIn('status', [Issue::STATUS_OPEN, Issue::STATUS_ACKNOWLEDGED]) - ->whereHas('issueType', fn($q) => $q->where('is_blocking', true)) + $allOpenIssues = Issue::whereIn('status', [Issue::STATUS_OPEN, Issue::STATUS_ACKNOWLEDGED]) ->count(); $overdue = WorkOrder::whereNotNull('due_date') @@ -54,6 +75,6 @@ public static function totalCount(): int $blocked = WorkOrder::where('status', WorkOrder::STATUS_BLOCKED)->count(); - return $blocking + $overdue + $blocked; + return $allOpenIssues + $overdue + $blocked; } } diff --git a/backend/resources/views/admin/alerts/index.blade.php b/backend/resources/views/admin/alerts/index.blade.php index dd2da987..1cab82aa 100644 --- a/backend/resources/views/admin/alerts/index.blade.php +++ b/backend/resources/views/admin/alerts/index.blade.php @@ -8,16 +8,29 @@ ['label' => __('Alerts'), 'url' => null], ]" /> -
+

{{ __('Alerts') }}

- @php $total = $blockingIssues->count() + $overdueOrders->count() + $blockedOrders->count(); @endphp + @php $total = $blockingIssues->count() + $nonBlockingIssues->count() + $overdueOrders->count() + $blockedOrders->count(); @endphp @if($total > 0) - + {{ $total }} @endif + + + {{ __('Live') }} + +
+ + {{-- New alert banner (shown by polling) --}} +
+ + + {{ __('Click to refresh') }}
@if($total === 0) @@ -178,7 +191,115 @@ class="shrink-0 text-xs text-red-700 hover:underline font-medium">{{ __('View is
+ + {{-- NON-BLOCKING ISSUES (below the grid) --}} + @if($nonBlockingIssues->count() > 0) +
+

+ + + + {{ __('Open Issues') }} ({{ $nonBlockingIssues->count() }}) +

+
+ + + + + + + + + + + + @foreach($nonBlockingIssues as $issue) + + + + + + + + @endforeach + +
{{ __('Issue') }}{{ __('Work Order') }}{{ __('Type') }}{{ __('Reported') }}{{ __('Status') }}
+ {{ $issue->title ?? $issue->description }} + + @if($issue->workOrder) + {{ $issue->workOrder->order_no }} + @else + + @endif + {{ $issue->issueType?->name ?? '—' }}{{ $issue->created_at->diffForHumans() }} + + {{ $issue->status }} + +
+
+
+ @endif @endif
+ + @endsection diff --git a/backend/routes/web.php b/backend/routes/web.php index de80eb4d..08c8cf83 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -222,6 +222,7 @@ // Alerts Route::get('/alerts', [\App\Http\Controllers\Web\Admin\AlertController::class, 'index'])->name('alerts'); + Route::get('/alerts/check', [\App\Http\Controllers\Web\Admin\AlertController::class, 'check'])->name('alerts.check'); // Update Route::get('/update/check', [\App\Http\Controllers\Web\Admin\UpdateController::class, 'check'])->name('update.check'); From c8d2368831d50ba3c4b13f7f23e413f999d3076f Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Wed, 27 May 2026 23:29:48 +0200 Subject: [PATCH 15/34] fix: reported issue with packing --- .../Web/Packaging/PackagingController.php | 7 +- .../Controllers/Web/SettingsController.php | 3 + backend/lang/pl.json | 87 ++++- .../resources/views/auth/register.blade.php | 1 - .../resources/views/packaging/admin.blade.php | 56 +-- .../views/packaging/eans/index.blade.php | 50 +-- .../packaging/label-templates/_form.blade.php | 356 +++++++++++++++++- .../views/packaging/station.blade.php | 286 ++++++++++++-- .../views/settings/profile.blade.php | 8 +- .../resources/views/settings/system.blade.php | 27 ++ backend/routes/web.php | 23 +- 11 files changed, 793 insertions(+), 111 deletions(-) diff --git a/backend/app/Http/Controllers/Web/Packaging/PackagingController.php b/backend/app/Http/Controllers/Web/Packaging/PackagingController.php index 25e1374a..229166ba 100644 --- a/backend/app/Http/Controllers/Web/Packaging/PackagingController.php +++ b/backend/app/Http/Controllers/Web/Packaging/PackagingController.php @@ -15,7 +15,12 @@ class PackagingController extends Controller public function station() { - return view('packaging.station'); + $scannerMode = json_decode( + \Illuminate\Support\Facades\DB::table('system_settings')->where('key', 'scanner_mode')->value('value') ?? '"hid"', + true + ) ?? 'hid'; + + return view('packaging.station', compact('scannerMode')); } public function adminOverview() diff --git a/backend/app/Http/Controllers/Web/SettingsController.php b/backend/app/Http/Controllers/Web/SettingsController.php index 781e8e3a..66fd8fe6 100644 --- a/backend/app/Http/Controllers/Web/SettingsController.php +++ b/backend/app/Http/Controllers/Web/SettingsController.php @@ -106,6 +106,7 @@ public function showSystemSettings() 'cors_allowed_origins' => json_decode($rows['cors_allowed_origins']->value ?? '"*"', true) ?? '*', 'production_qty_edit_policy' => json_decode($rows['production_qty_edit_policy']->value ?? '"none"', true) ?? 'none', 'production_qty_edit_window_minutes' => json_decode($rows['production_qty_edit_window_minutes']->value ?? '1', true) ?? 1, + 'scanner_mode' => json_decode($rows['scanner_mode']->value ?? '"hid"', true) ?? 'hid', ]; return view('settings.system', compact('settings')); @@ -257,6 +258,7 @@ public function updateSystemSettings(Request $request) 'cors_allowed_origins' => 'nullable|string|max:1000', 'production_qty_edit_policy' => 'required|in:none,timed,full', 'production_qty_edit_window_minutes' => 'required_if:production_qty_edit_policy,timed|integer|min:1|max:60', + 'scanner_mode' => 'required|in:hid,manual', ]); $shiftsPerDay = (int) $validated['schedule_shifts_per_day']; @@ -279,6 +281,7 @@ public function updateSystemSettings(Request $request) 'cors_allowed_origins' => trim($validated['cors_allowed_origins'] ?? '*') ?: '*', 'production_qty_edit_policy' => $validated['production_qty_edit_policy'], 'production_qty_edit_window_minutes' => (int) ($validated['production_qty_edit_window_minutes'] ?? 1), + 'scanner_mode' => $validated['scanner_mode'], ]; foreach ($map as $key => $value) { diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 7b5b40fd..be87f839 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1307,5 +1307,90 @@ "Quantity corrections are not allowed.": "Korekty ilości są niedozwolone.", "The correction time window has expired.": "Okno czasowe korekty wygasło.", "Production Date": "Data produkcji", - "Shift": "Zmiana" + "Shift": "Zmiana", + "Packaging": "Pakowanie", + "Packaging Station": "Stanowisko Pakowania", + "Packaging — Overview": "Pakowanie — Przegląd", + "Open station": "Otwórz stanowisko", + "Manage EANs": "Zarządzaj kodami EAN", + "Logged in": "Zalogowany", + "Scanner: manual": "Skaner: ręczny", + "Scanning active (HID)": "Skanowanie aktywne (HID)", + "EAN Scanning": "Skanowanie EAN", + "EAN": "EAN", + "Scan the EAN code assigned to a work order. The system recognizes the order and increments the packed counter.": "Zeskanuj kod EAN przypisany do zlecenia. System rozpozna zlecenie i zwiększy licznik spakowanych sztuk.", + "Current mode": "Aktualny tryb", + "HID (keyboard wedge)": "HID (czytnik klawiatury)", + "Just scan the code with the scanner - data is entered automatically, no need to click anywhere.": "Po prostu zeskanuj kod skanerem - dane wejdą automatycznie, nie klikaj nigdzie.", + "Manual (typing)": "Ręczny (wpisywanie)", + "Type the code in the field above the table and press Enter. Use when the scanner does not work.": "Wpisz kod w polu nad tabelą i naciśnij Enter. Użyj, gdy skaner nie działa.", + "OK": "OK", + "Error": "Błąd", + "ERROR": "BŁĄD", + "Code recognized, counter incremented": "Kod rozpoznany, licznik powiększony", + "Unknown EAN or inactive work order": "Nieznany EAN albo zlecenie nieaktywne", + "Configure scanner": "Konfiguruj skaner", + "Scanner Configuration": "Konfiguracja skanera", + "Scan the code to configure the scanner": "Zeskanuj kod, aby skonfigurować skaner", + "How to use": "Jak użyć", + "Point the scanner at the code below and scan it once.": "Skieruj skaner na kod poniżej i zeskanuj go raz.", + "The scanner remembers the configuration: after every subsequent scan it will send :combo.": "Skaner zapamiętuje konfigurację: po każdym kolejnym skanie wyśle :combo.", + "Go back to the station - EAN scanning will start working automatically.": "Wróć do stanowiska - skanowanie EAN-ów zacznie działać automatycznie.", + "CODE 128 · scanner suffix configuration": "CODE 128 · konfiguracja sufiksu skanera", + "Open settings": "Otwórz ustawienia", + "Scan or type the EAN code and press Enter…": "Zeskanuj lub wpisz kod EAN i naciśnij Enter…", + "Last scan": "Ostatnie skanowanie", + "Hold an EAN code up to the scanner…": "Przyłóż kod EAN do skanera…", + "Waiting for a scan…": "Czekam na skan…", + "Scanned!": "Zeskanowano!", + "Scanning error": "Błąd skanowania", + "Work orders to pack": "Zlecenia do spakowania", + "Packed": "Spakowano", + "Plan": "Plan", + "No work orders with assigned EAN codes": "Brak zleceń z przypisanymi kodami EAN", + "Scan history (shift)": "Historia skanowań (zmiana)", + "No scans in this shift": "Brak skanowań w tej zmianie", + "Connection error": "Błąd połączenia", + "Packed (shift)": "Spakowano (zmiana)", + "Total plan": "Plan łącznie", + "pcs.": "szt.", + "In progress": "W trakcie", + "Code on label": "Kod na etykiecie", + "Pick one machine-readable code. Mixing barcode and QR on the same label leads to scanning mistakes.": "Wybierz jeden kod maszynowy. Mieszanie kodu kreskowego i QR na tej samej etykiecie prowadzi do pomyłek przy skanowaniu.", + "No code": "Bez kodu", + "Text only — for visual labels or stickers.": "Tylko tekst - dla etykiet wizualnych i naklejek.", + "Barcode (1D)": "Kod kreskowy (1D)", + "Linear barcode (CODE 128 / EAN-13).": "Liniowy kod kreskowy (CODE 128 / EAN-13).", + "QR code": "Kod QR", + "2D QR code, scans from any angle.": "Dwuwymiarowy kod QR, czytany pod dowolnym kątem.", + "Other fields": "Pozostałe pola", + "Toggle which text fields appear on this template.": "Włącz pola tekstowe pojawiające się na tym szablonie.", + "Preview": "Podgląd", + "Live preview with sample data. Real codes are rendered as PNG when printed.": "Podgląd na żywo z przykładowymi danymi. Prawdziwe kody są renderowane jako PNG podczas drukowania.", + "Text only": "Tylko tekst", + "fields": "pól", + "Barcode Scanner": "Skaner kodów kreskowych", + "How the workstation receives input from a barcode scanner.": "W jaki sposób stanowisko otrzymuje dane ze skanera kodów kreskowych.", + "HID / Keyboard wedge": "HID / czytnik klawiatury", + "Scanner acts as a keyboard. Codes are captured automatically on the workstation, no input field required.": "Skaner działa jak klawiatura. Kody są łapane automatycznie na stanowisku, pole tekstowe nie jest potrzebne.", + "Manual input": "Ręczne wpisywanie", + "Operator typed the code into a visible field and confirms with Enter. Use when no scanner is available.": "Operator wpisuje kod w widocznym polu i potwierdza klawiszem Enter. Użyj, gdy skaner nie jest dostępny.", + "How does scanning work?": "Jak działa skanowanie?", + "Current shift": "Bieżąca zmiana", + "EAN Codes — Management": "Kody EAN — Zarządzanie", + "EAN Codes": "Kody EAN", + "Assign barcodes to work orders": "Przypisuj kody kreskowe do zleceń produkcyjnych", + "Packaging overview": "Przegląd pakowania", + "Add EAN code": "Dodaj kod EAN", + "Work order": "Zlecenie produkcyjne", + "select work order": "wybierz zlecenie", + "EAN code": "Kod EAN", + "e.g. 5901234123457": "np. 5901234123457", + "Add EAN": "Dodaj EAN", + "Search by order number…": "Szukaj po numerze zlecenia…", + "EAN codes": "Kody EAN", + "Packed / Plan": "Spakowano / Plan", + "Delete EAN code": "Usunąć kod EAN", + "No EAN": "Brak EAN", + "No results": "Brak wyników" } diff --git a/backend/resources/views/auth/register.blade.php b/backend/resources/views/auth/register.blade.php index 78830571..248fb723 100644 --- a/backend/resources/views/auth/register.blade.php +++ b/backend/resources/views/auth/register.blade.php @@ -90,7 +90,6 @@ class="form-input w-full @error('password_confirmation') border-red-500 @enderro class="mt-1 h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"> {{ __('I agree to receive product updates and marketing communications via email.') }} - / Wyrażam zgodę na kontakt w celach marketingowych drogą mailową.
diff --git a/backend/resources/views/packaging/admin.blade.php b/backend/resources/views/packaging/admin.blade.php index 986c497a..b655ad81 100644 --- a/backend/resources/views/packaging/admin.blade.php +++ b/backend/resources/views/packaging/admin.blade.php @@ -1,25 +1,25 @@ @extends('layouts.app') -@section('title', 'Pakowanie — Przegląd') +@section('title', __('Packaging — Overview')) @section('content')
@@ -28,29 +28,29 @@

{{ number_format($stats['today_packed']) }}

-

Spakowano (zmiana)

+

{{ __('Packed (shift)') }}

{{ number_format($stats['plan']) }}

-

Plan łącznie

+

{{ __('Total plan') }}

{{ number_format($stats['backlog']) }}

-

Backlog

+

{{ __('Backlog') }}

@php - $realizacja = $stats['plan'] > 0 ? min(100, round($stats['total_packed'] / $stats['plan'] * 100)) : 0; + $completion = $stats['plan'] > 0 ? min(100, round($stats['total_packed'] / $stats['plan'] * 100)) : 0; @endphp
-

- {{ $realizacja }}% +

+ {{ $completion }}%

-

Realizacja

+

{{ __('Completion') }}

-
+
@@ -59,21 +59,21 @@

- Zlecenia do spakowania ({{ count($items) }}) + {{ __('Work orders to pack') }} ({{ count($items) }})

- - - - - - - - + + + + + + + + @@ -101,11 +101,11 @@ @endforelse diff --git a/backend/resources/views/packaging/eans/index.blade.php b/backend/resources/views/packaging/eans/index.blade.php index 0d4c2e06..5557882a 100644 --- a/backend/resources/views/packaging/eans/index.blade.php +++ b/backend/resources/views/packaging/eans/index.blade.php @@ -1,21 +1,21 @@ @extends('layouts.app') -@section('title', 'Zarządzanie kodami EAN') +@section('title', __('EAN Codes — Management')) @section('content')
-

Kody EAN — Zarządzanie

-

Przypisuj kody kreskowe do zleceń produkcyjnych

+

{{ __('EAN Codes — Management') }}

+

{{ __('Assign barcodes to work orders') }}

- ← Przegląd pakowania + ← {{ __('Packaging overview') }}
@if(session('success')) @@ -26,13 +26,13 @@ {{-- Add EAN form ─────────────────────────────────────────────────────── --}}
-

Dodaj kod EAN

+

{{ __('Add EAN code') }}

@csrf
- + + :placeholder="@json(__('e.g. 5901234123457'))" required maxlength="100"> @error('ean')

{{ $message }}

@enderror
- +
@@ -61,10 +61,10 @@
- + class="form-input flex-1" :placeholder="@json(__('Search by order number…'))"> + @if(request('search')) - Wyczyść + {{ __('Clear') }} @endif
@@ -75,11 +75,11 @@ class="form-input flex-1" placeholder="Szukaj po numerze zlecenia…">
ZlecenieProduktLiniaEANSpakowanoPlanPostępStatus{{ __('Order') }}{{ __('Product') }}{{ __('Line') }}{{ __('EAN') }}{{ __('Packed') }}{{ __('Plan') }}{{ __('Progress') }}{{ __('Status') }}
@if($item['done']) - Spakowane + {{ __('Packed') }} @elseif($item['status'] === 'DONE') - W trakcie + {{ __('In progress') }} @else @@ -117,7 +117,7 @@ @empty
- Brak zleceń z przypisanymi kodami EAN + {{ __('No work orders with assigned EAN codes') }}
- - - - - + + + + + @@ -102,13 +102,13 @@ class="form-input flex-1" placeholder="Szukaj po numerze zlecenia…"> @forelse($wo->eans as $ean)
{{ $ean->ean }} -
+ @csrf @method('DELETE') - +
@empty - Brak EAN + {{ __('No EAN') }} @endforelse @empty - + @endforelse diff --git a/backend/resources/views/packaging/label-templates/_form.blade.php b/backend/resources/views/packaging/label-templates/_form.blade.php index 4dbc418c..a4e12646 100644 --- a/backend/resources/views/packaging/label-templates/_form.blade.php +++ b/backend/resources/views/packaging/label-templates/_form.blade.php @@ -1,7 +1,35 @@ @php $fields = $template->fields_config ?? \App\Models\LabelTemplate::defaultFieldsFor($template->type ?? \App\Models\LabelTemplate::TYPE_WORK_ORDER); + $initialFields = []; + foreach (array_keys(\App\Models\LabelTemplate::AVAILABLE_FIELDS) as $key) { + $initialFields[$key] = (bool) old("fields.$key", $fields[$key] ?? false); + } + // Code type derived from individual fields. barcode wins over qr if (incorrectly) both set. + $initialCodeType = 'none'; + if (!empty($initialFields['barcode'])) { + $initialCodeType = 'barcode'; + } elseif (!empty($initialFields['qr'])) { + $initialCodeType = 'qr'; + } + // Fields shown in the "Other fields" grid (exclude barcode/qr — handled separately). + $otherFields = collect(\App\Models\LabelTemplate::AVAILABLE_FIELDS) + ->except(['barcode', 'qr']) + ->toArray(); + + $previewInitial = [ + 'name' => old('name', $template->name ?? ''), + 'type' => old('type', $template->type ?? \App\Models\LabelTemplate::TYPE_WORK_ORDER), + 'size' => old('size', $template->size ?? '100x50'), + 'barcode_format' => old('barcode_format', $template->barcode_format ?? 'code128'), + 'fields' => $initialFields, + 'code_type' => $initialCodeType, + ]; @endphp +
+ {{-- ── Basic info ── --}}

{{ __('Template Details') }}

@@ -9,16 +37,16 @@
- @error('name')

{{ $message }}

@enderror
- @foreach(\App\Models\LabelTemplate::TYPES as $value => $label) - + @endforeach @error('type')

{{ $message }}

@enderror @@ -33,35 +61,71 @@ class="form-input w-full" required maxlength="255">
- @foreach(\App\Models\LabelTemplate::SIZES as $value => $label) - + @endforeach @error('size')

{{ $message }}

@enderror
-
+
- @foreach(\App\Models\LabelTemplate::BARCODE_FORMATS as $value => $label) - + @endforeach
+ {{-- Hidden so the field is still submitted when code_type != barcode --}} + +
+
+ +{{-- ── Code (barcode XOR qr) ── --}} +
+

{{ __('Code on label') }}

+

{{ __('Pick one machine-readable code. Mixing barcode and QR on the same label leads to scanning mistakes.') }}

+ + {{-- Hidden inputs synced from code_type --}} + + + +
+ @foreach([ + 'none' => ['title' => __('No code'), 'desc' => __('Text only — for visual labels or stickers.')], + 'barcode' => ['title' => __('Barcode (1D)'), 'desc' => __('Linear barcode (CODE 128 / EAN-13).')], + 'qr' => ['title' => __('QR code'), 'desc' => __('2D QR code, scans from any angle.')], + ] as $value => $opt) +
+ + + +
+

{{ $opt['title'] }}

+

{{ $opt['desc'] }}

+
+
+ @endforeach
-{{-- ── Fields ── --}} +{{-- ── Other fields ── --}}
-

{{ __('Fields to include on label') }}

-

{{ __('Toggle which fields appear on this template.') }}

+

{{ __('Other fields') }}

+

{{ __('Toggle which text fields appear on this template.') }}

- @foreach(\App\Models\LabelTemplate::AVAILABLE_FIELDS as $key => $label) + @foreach($otherFields as $key => $label) @@ -92,3 +156,269 @@ class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
+ +{{-- ── Preview ── --}} +
+
+

{{ __('Preview') }}

+ +
+

{{ __('Live preview with sample data. Real codes are rendered as PNG when printed.') }}

+ +
+ + {{-- Label paper --}} +
+ + {{-- Top header: logo + WO badge --}} +
+ + + +
+ + {{-- Body: text on left, QR on right --}} +
+ {{-- Text column --}} +
+ + + + +
+ + +
+
+ + {{-- QR code column --}} + +
+ + {{-- Footer: barcode (centered, ~60% width) --}} + +
+
+ + {{-- Legend below the label --}} +
+ + + + + · + +
+
+ +
{{-- /x-data --}} + +@push('scripts') + +@endpush diff --git a/backend/resources/views/packaging/station.blade.php b/backend/resources/views/packaging/station.blade.php index f666575b..ea70e868 100644 --- a/backend/resources/views/packaging/station.blade.php +++ b/backend/resources/views/packaging/station.blade.php @@ -1,10 +1,10 @@ @extends('layouts.app') -@section('title', 'Stanowisko Pakowania') +@section('title', __('Packaging Station')) @section('content')
{{-- Header ──────────────────────────────────────────────────────────── --}} @@ -15,42 +15,245 @@ - Stanowisko Pakowania + {{ __('Packaging Station') }} + + {{-- Scanner help (Admin/Supervisor only) --}} + @auth + @if(auth()->user()->hasAnyRole(['Admin', 'Supervisor'])) + + + +
+
+

{{ __('EAN Scanning') }}

+ +
+ +

+ {{ __('Scan the EAN code assigned to a work order. The system recognizes the order and increments the packed counter.') }} +

+ +
+

{{ __('Current mode') }}

+ + +
+ +
+
+ {{ __('OK') }} + {{ __('Code recognized, counter incremented') }} +
+
+ {{ __('ERROR') }} + {{ __('Unknown EAN or inactive work order') }} +
+
+ + +
+
+ @endif + @endauth

- Zmiana: -  ·  Zalogowany: {{ auth()->user()->name }} + {{ __('Shift') }}: +  ·  {{ __('Logged in') }}: {{ auth()->user()->name }}

- - - Skanowanie aktywne + + +
+ {{-- ═══════ SCANNER CONFIGURATION MODAL ═══════ --}} + @auth + @if(auth()->user()->hasAnyRole(['Admin', 'Supervisor'])) +
+
+
+ {{-- Header --}} +
+
+

{{ __('Scanner Configuration') }}

+

{{ __('Scan the code to configure the scanner') }}

+
+ +
+ + {{-- Instructions --}} +
+

{{ __('How to use') }}

+
    +
  1. {{ __('Point the scanner at the code below and scan it once.') }}
  2. +
  3. {!! __('The scanner remembers the configuration: after every subsequent scan it will send :combo.', ['combo' => 'Ctrl+V+Enter']) !!}
  4. +
  5. {{ __('Go back to the station - EAN scanning will start working automatically.') }}
  6. +
+
+ + {{-- Barcode --}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CFG-CTRL+V+ENTER
+

{{ __('CODE 128 · scanner suffix configuration') }}

+
+ + {{-- Footer --}} +
+ + {{ __('Open settings') }} + + +
+
+
+ @endif + @endauth + + {{-- Manual input (only when scanner_mode = manual) --}} +
+ + + + {{-- Stats row ────────────────────────────────────────────────────────── --}}

-

Spakowano (zmiana)

+

{{ __('Packed (shift)') }}

-

Plan łącznie

+

{{ __('Total plan') }}

-

Backlog

+

{{ __('Backlog') }}

-

Realizacja

+

{{ __('Completion') }}

@@ -58,10 +261,10 @@ {{-- Last scan ───────────────────────────────────────────────────── --}}
-

Ostatnie skanowanie

+

{{ __('Last scan') }}

- Przyłóż kod EAN do skanera… + {{ __('Hold an EAN code up to the scanner…') }}
@@ -75,7 +278,7 @@
+ x-text="lastScan?.success ? @json(__('OK')) : @json(__('Error'))">
@@ -85,7 +288,7 @@ :style="'width:' + (lastScan?.progress ?? 0) + '%'">
- / szt. + / {{ __('pcs.') }}
@@ -106,19 +309,19 @@ - Czekam na skan… + {{ __('Waiting for a scan…') }}
-

Zeskanowano!

+

{{ __('Scanned!') }}

-

Błąd skanowania

+

{{ __('Scanning error') }}

@@ -127,25 +330,25 @@

- Zlecenia do spakowania + {{ __('Work orders to pack') }}

- +
ZlecenieProduktStatusKody EANSpakowano / Plan{{ __('Order') }}{{ __('Product') }}{{ __('Status') }}{{ __('EAN codes') }}{{ __('Packed / Plan') }}
@@ -118,7 +118,7 @@ class="form-input flex-1" placeholder="Szukaj po numerze zlecenia…">
Brak wyników{{ __('No results') }}
- - - - - - + + + + + +
ZlecenieProduktEANSpakowanoPlanPostęp{{ __('Order') }}{{ __('Product') }}{{ __('EAN') }}{{ __('Packed') }}{{ __('Plan') }}{{ __('Progress') }}