diff --git a/README.md b/README.md index c4329c0f..199e9904 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ [![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/fw3fG78pZj) + --- diff --git a/backend/app/Console/Commands/ModbusPollCommand.php b/backend/app/Console/Commands/ModbusPollCommand.php new file mode 100644 index 00000000..cd4c69da --- /dev/null +++ b/backend/app/Console/Commands/ModbusPollCommand.php @@ -0,0 +1,81 @@ +find($this->option('connection')); + + if (! $connection || ! $connection->modbusConnection) { + $this->error('Modbus connection not found.'); + + return self::FAILURE; + } + + $modbus = $connection->modbusConnection; + $tags = $connection->activeTags; + $intervalUs = max(100, $modbus->poll_interval_ms) * 1000; + $once = (bool) $this->option('once'); + + $this->info("Polling {$connection->name} ({$modbus->host}:{$modbus->port}), {$tags->count()} tags"); + + do { + $reader = new ModbusReader($modbus); + try { + $reader->connect(); + $connection->markConnected(); + + do { + $cycleStart = microtime(true); + $runtime->heartbeat($connection->protocol, $connection->id); + foreach ($tags as $tag) { + try { + $value = $reader->readTag($tag); + $ingestor->ingest($tag, $value); + $connection->increment('messages_received'); + } catch (\Throwable $e) { + $this->warn("tag {$tag->name}: {$e->getMessage()}"); + } + } + + if ($once) { + break 2; + } + + $elapsed = (int) ((microtime(true) - $cycleStart) * 1_000_000); + usleep(max(0, $intervalUs - $elapsed)); + } while (true); + } catch (\Throwable $e) { + $connection->markError($e->getMessage()); + $this->error("connection error: {$e->getMessage()}"); + if ($once) { + return self::FAILURE; + } + sleep(5); // backoff before reconnect + } finally { + $reader->close(); + } + } while (! $once); + + return self::SUCCESS; + } +} diff --git a/backend/app/Console/Commands/ModbusSimulateCommand.php b/backend/app/Console/Commands/ModbusSimulateCommand.php new file mode 100644 index 00000000..3a82d56c --- /dev/null +++ b/backend/app/Console/Commands/ModbusSimulateCommand.php @@ -0,0 +1,150 @@ + holding/input registers */ + private array $reg = [0 => 1, 1 => 0, 2 => 0, 3 => 220]; + + public function handle(): int + { + $host = $this->option('host'); + $port = (int) $this->option('port'); + + $server = @stream_socket_server("tcp://{$host}:{$port}", $errno, $errstr); + if (! $server) { + $this->error("Cannot bind {$host}:{$port}: {$errstr}"); + + return self::FAILURE; + } + stream_set_blocking($server, false); + $this->info("Modbus simulator listening on {$host}:{$port} (Ctrl+C to stop)"); + + $clients = []; + $lastTick = microtime(true); + // Scripted state timeline (state, seconds) + $script = [[1, 15], [2, 5], [1, 20], [3, 8], [1, 15], [2, 4]]; + $phase = 0; + $phaseElapsed = 0.0; + + while (true) { + $read = array_merge([$server], $clients); + $write = $except = null; + if (@stream_select($read, $write, $except, 1) === false) { + break; + } + + foreach ($read as $sock) { + if ($sock === $server) { + $client = @stream_socket_accept($server, 0); + if ($client) { + stream_set_blocking($client, false); + $clients[(int) $client] = $client; + } + + continue; + } + + $data = @fread($sock, 1024); + if ($data === '' || $data === false) { + fclose($sock); + unset($clients[(int) $sock]); + + continue; + } + $response = $this->handleRequest($data); + if ($response !== null) { + @fwrite($sock, $response); + } + } + + // Advance simulation on ~1s ticks. + $now = microtime(true); + $dt = $now - $lastTick; + if ($dt >= 1.0) { + $lastTick = $now; + $phaseElapsed += $dt; + [$state, $duration] = $script[$phase]; + $this->reg[0] = $state; + if ($state === 1) { // RUNNING + $this->reg[1] += 2; // good parts + if (random_int(1, 10) === 1) { + $this->reg[2] += 1; // occasional reject + } + $this->reg[3] = 220 + random_int(-5, 15); + } + if ($phaseElapsed >= $duration) { + $phaseElapsed = 0; + $phase = ($phase + 1) % count($script); + } + $label = [1 => 'RUNNING', 2 => 'IDLE', 3 => 'FAULT'][$this->reg[0]]; + $this->line(sprintf('[sim] state=%s good=%d reject=%d temp=%.1f', $label, $this->reg[1], $this->reg[2], $this->reg[3] / 10)); + } + } + + return self::SUCCESS; + } + + /** + * Parse a Modbus TCP frame and build a response. Supports FC 0x03/0x04 + * (read holding/input registers) and 0x01/0x02 (read coils/discretes). + */ + private function handleRequest(string $data): ?string + { + if (strlen($data) < 12) { + return null; + } + $mbap = unpack('ntid/nprot/nlen/Cunit', substr($data, 0, 7)); + $fc = ord($data[7]); + $body = substr($data, 8); + $req = unpack('naddr/nqty', $body); + $addr = $req['addr']; + $qty = max(1, $req['qty']); + + if (in_array($fc, [0x03, 0x04], true)) { + $payload = ''; + for ($i = 0; $i < $qty; $i++) { + $val = $this->reg[$addr + $i] ?? 0; + $payload .= pack('n', $val & 0xFFFF); + } + $pdu = chr($fc).chr(strlen($payload)).$payload; + } elseif (in_array($fc, [0x01, 0x02], true)) { + $byteCount = intdiv($qty + 7, 8); + $bits = 0; + for ($i = 0; $i < $qty; $i++) { + if (($this->reg[$addr + $i] ?? 0) > 0) { + $bits |= (1 << $i); + } + } + $pdu = chr($fc).chr($byteCount).pack('C', $bits & 0xFF); + } else { + // Illegal function exception + $pdu = chr($fc | 0x80).chr(0x01); + } + + $len = strlen($pdu) + 1; // + unit id + + return pack('nnn', $mbap['tid'], 0, $len).chr($mbap['unit']).$pdu; + } +} diff --git a/backend/app/Events/Machine/WorkstationStateChanged.php b/backend/app/Events/Machine/WorkstationStateChanged.php new file mode 100644 index 00000000..f7775afb --- /dev/null +++ b/backend/app/Events/Machine/WorkstationStateChanged.php @@ -0,0 +1,21 @@ +load(['opcuaConnection', 'activeTags']); + $opc = $machineConnection->opcuaConnection; + + return response()->json([ + 'connection' => [ + 'id' => $machineConnection->id, + 'name' => $machineConnection->name, + 'protocol' => $machineConnection->protocol, + 'is_active' => $machineConnection->is_active, + ], + 'opcua' => $opc ? [ + 'endpoint_url' => $opc->endpoint_url, + 'security_policy' => $opc->security_policy, + 'security_mode' => $opc->security_mode, + 'auth_mode' => $opc->auth_mode, + 'username' => $opc->username, + 'publishing_interval_ms' => $opc->publishing_interval_ms, + ] : null, + 'tags' => $machineConnection->activeTags->map(fn (MachineTag $t) => [ + 'id' => $t->id, + 'name' => $t->name, + 'node_id' => $t->address, + 'signal_type' => $t->signal_type, + 'data_type' => $t->data_type, + ])->values(), + ]); + } + + /** + * Receive normalized readings from the gateway and ingest them. + * + * Body: { readings: [ { tag_id?, node_id?, value, ts? }, ... ] } + */ + public function ingest(Request $request, MachineConnection $machineConnection): JsonResponse + { + $data = $request->validate([ + 'readings' => ['required', 'array', 'min:1'], + 'readings.*.tag_id' => ['nullable', 'integer'], + 'readings.*.node_id' => ['nullable', 'string'], + 'readings.*.value' => ['present'], + 'readings.*.ts' => ['nullable', 'date'], + ]); + + // Heartbeat: a posting gateway is, by definition, alive. + $this->runtime->heartbeat($machineConnection->protocol, $machineConnection->id); + $machineConnection->markConnected(); + + $tags = $machineConnection->activeTags()->get()->keyBy('id'); + $byAddress = $machineConnection->activeTags()->get()->keyBy('address'); + + $accepted = 0; + foreach ($data['readings'] as $r) { + $tag = isset($r['tag_id']) ? $tags->get($r['tag_id']) : null; + $tag ??= isset($r['node_id']) ? $byAddress->get($r['node_id']) : null; + if (! $tag) { + continue; + } + $at = isset($r['ts']) ? \Illuminate\Support\Carbon::parse($r['ts']) : null; + $this->ingestor->ingest($tag, $r['value'], $at); + $machineConnection->increment('messages_received'); + $accepted++; + } + + return response()->json(['accepted' => $accepted]); + } + + /** + * Standalone heartbeat (gateway connected but no readings to push yet). + */ + public function heartbeat(MachineConnection $machineConnection): JsonResponse + { + $this->runtime->heartbeat($machineConnection->protocol, $machineConnection->id); + + return response()->json(['ok' => true]); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/MaterialLotController.php b/backend/app/Http/Controllers/Api/V1/MaterialLotController.php index e4be5118..43872251 100644 --- a/backend/app/Http/Controllers/Api/V1/MaterialLotController.php +++ b/backend/app/Http/Controllers/Api/V1/MaterialLotController.php @@ -112,7 +112,9 @@ public function forwardGenealogy(MaterialLot $materialLot): JsonResponse */ public function backwardGenealogy(MaterialLot $materialLot): JsonResponse { - $sourceBatchId = data_get($materialLot->extra_data, 'source_batch_id'); + // Prefer the formal FK column; fall back to the legacy extra_data hint. + $sourceBatchId = $materialLot->source_batch_id + ?? data_get($materialLot->extra_data, 'source_batch_id'); $upstream = collect(); if ($sourceBatchId) { diff --git a/backend/app/Http/Controllers/Api/V1/SerialUnitController.php b/backend/app/Http/Controllers/Api/V1/SerialUnitController.php new file mode 100644 index 00000000..8d09230e --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/SerialUnitController.php @@ -0,0 +1,73 @@ +when($request->query('work_order_id'), fn ($q, $id) => $q->where('work_order_id', $id)) + ->when($request->query('status'), fn ($q, $s) => $q->where('status', $s)) + ->when($request->query('search'), fn ($q, $s) => $q->where('serial_no', 'like', "%{$s}%")) + ->orderByDesc('id') + ->limit(100) + ->get(); + + return response()->json(['data' => $units]); + } + + public function show(SerialUnit $serialUnit): JsonResponse + { + return response()->json(['data' => $this->serials->getHistory($serialUnit)]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'serial_no' => ['required', 'string', 'max:100'], + 'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'], + 'batch_id' => ['nullable', 'integer', 'exists:batches,id'], + 'material_id' => ['nullable', 'integer', 'exists:materials,id'], + 'status' => ['nullable', Rule::in(SerialUnit::STATUSES)], + ]); + + $unit = $this->serials->registerUnit($data['serial_no'], $data); + + return response()->json(['data' => $unit], 201); + } + + public function recordStep(Request $request, SerialUnit $serialUnit): JsonResponse + { + $data = $request->validate([ + 'batch_step_id' => ['nullable', 'integer', 'exists:batch_steps,id'], + 'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'], + 'parameters' => ['nullable', 'array'], + 'result' => ['nullable', Rule::in(['pass', 'fail', 'rework'])], + 'notes' => ['nullable', 'string', 'max:500'], + ]); + + $step = isset($data['batch_step_id']) ? BatchStep::find($data['batch_step_id']) : null; + + $entry = $this->serials->recordStep($serialUnit, $request->user(), $step, $data); + + return response()->json([ + 'message' => __('Unit step recorded'), + 'data' => $entry->load(['workstation:id,name,code', 'operator:id,name']), + ], 201); + } +} 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/app/Http/Controllers/Web/Admin/Connectivity/ModbusConnectionController.php b/backend/app/Http/Controllers/Web/Admin/Connectivity/ModbusConnectionController.php new file mode 100644 index 00000000..e131a481 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/Connectivity/ModbusConnectionController.php @@ -0,0 +1,186 @@ +with(['modbusConnection', 'tags']) + ->orderBy('name') + ->get(); + + return view('admin.connectivity.modbus.index', compact('connections')); + } + + public function create() + { + return view('admin.connectivity.modbus.create', [ + 'workstations' => Workstation::with('line:id,name')->orderBy('name')->get(), + ]); + } + + public function store(Request $request) + { + $data = $this->validateData($request); + + DB::transaction(function () use ($data) { + $connection = MachineConnection::create([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'protocol' => MachineConnection::PROTOCOL_MODBUS, + 'is_active' => $request->boolean('is_active'), + 'status' => MachineConnection::STATUS_DISCONNECTED, + ]); + + ModbusConnection::create([ + 'machine_connection_id' => $connection->id, + 'host' => $data['host'], + 'port' => $data['port'], + 'unit_id' => $data['unit_id'], + 'poll_interval_ms' => $data['poll_interval_ms'], + 'timeout_seconds' => $data['timeout_seconds'], + 'byte_order' => $data['byte_order'], + 'word_order' => $data['word_order'], + ]); + }); + + return redirect()->route('admin.connectivity.modbus.index') + ->with('success', __('Modbus connection created.')); + } + + public function show(MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_MODBUS, 404); + $machineConnection->load(['modbusConnection', 'tags.workstation']); + + return view('admin.connectivity.modbus.show', [ + 'connection' => $machineConnection, + 'workstations' => Workstation::with('line:id,name')->orderBy('name')->get(), + 'runtime' => app(\App\Services\Machine\RuntimeMonitor::class)->connectionRuntime($machineConnection), + ]); + } + + public function edit(MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_MODBUS, 404); + $machineConnection->load('modbusConnection'); + + return view('admin.connectivity.modbus.edit', ['connection' => $machineConnection]); + } + + public function update(Request $request, MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_MODBUS, 404); + $data = $this->validateData($request); + + DB::transaction(function () use ($machineConnection, $data, $request) { + $machineConnection->update([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'is_active' => $request->boolean('is_active'), + ]); + $machineConnection->modbusConnection->update([ + 'host' => $data['host'], + 'port' => $data['port'], + 'unit_id' => $data['unit_id'], + 'poll_interval_ms' => $data['poll_interval_ms'], + 'timeout_seconds' => $data['timeout_seconds'], + 'byte_order' => $data['byte_order'], + 'word_order' => $data['word_order'], + ]); + }); + + return redirect()->route('admin.connectivity.modbus.show', $machineConnection) + ->with('success', __('Modbus connection updated.')); + } + + public function destroy(MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_MODBUS, 404); + $machineConnection->delete(); + + return redirect()->route('admin.connectivity.modbus.index') + ->with('success', __('Modbus connection deleted.')); + } + + /** Add a tag to a connection. */ + public function storeTag(Request $request, MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_MODBUS, 404); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'address' => ['required', 'string', 'max:255'], + 'signal_type' => ['required', 'string', 'max:30'], + 'data_type' => ['required', 'string', 'max:20'], + 'register_type' => ['required', 'string', 'max:20'], + 'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'], + 'value_map' => ['nullable', 'string'], + 'scale' => ['nullable', 'numeric'], + ]); + + $transform = []; + if (! empty($data['value_map'])) { + // "1=RUNNING,2=IDLE,3=FAULT" → map + $map = []; + foreach (explode(',', $data['value_map']) as $pair) { + [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, null); + if ($k !== null && $v !== null) { + $map[trim($k)] = trim($v); + } + } + if ($map) { + $transform['value_map'] = $map; + } + } + if (isset($data['scale'])) { + $transform['scale'] = (float) $data['scale']; + } + + MachineTag::create([ + 'machine_connection_id' => $machineConnection->id, + 'workstation_id' => $data['workstation_id'] ?? null, + 'name' => $data['name'], + 'address' => $data['address'], + 'signal_type' => $data['signal_type'], + 'data_type' => $data['data_type'], + 'register_type' => $data['register_type'], + 'transform' => $transform ?: null, + ]); + + return back()->with('success', __('Tag added.')); + } + + public function destroyTag(MachineConnection $machineConnection, MachineTag $tag) + { + abort_unless($tag->machine_connection_id === $machineConnection->id, 404); + $tag->delete(); + + return back()->with('success', __('Tag removed.')); + } + + private function validateData(Request $request): array + { + return $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string', 'max:500'], + 'host' => ['required', 'string', 'max:255'], + 'port' => ['required', 'integer', 'min:1', 'max:65535'], + 'unit_id' => ['required', 'integer', 'min:0', 'max:255'], + 'poll_interval_ms' => ['required', 'integer', 'min:100', 'max:60000'], + 'timeout_seconds' => ['required', 'integer', 'min:1', 'max:60'], + 'byte_order' => ['required', 'in:big,little'], + 'word_order' => ['required', 'in:big,little'], + ]); + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/Connectivity/OpcuaConnectionController.php b/backend/app/Http/Controllers/Web/Admin/Connectivity/OpcuaConnectionController.php new file mode 100644 index 00000000..af7fe392 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/Connectivity/OpcuaConnectionController.php @@ -0,0 +1,188 @@ +with(['opcuaConnection', 'tags']) + ->orderBy('name') + ->get(); + + return view('admin.connectivity.opcua.index', compact('connections')); + } + + public function create() + { + return view('admin.connectivity.opcua.create', [ + 'workstations' => Workstation::with('line:id,name')->orderBy('name')->get(), + ]); + } + + public function store(Request $request) + { + $data = $this->validateData($request); + + DB::transaction(function () use ($data, $request) { + $connection = MachineConnection::create([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'protocol' => MachineConnection::PROTOCOL_OPCUA, + 'is_active' => $request->boolean('is_active'), + 'status' => MachineConnection::STATUS_DISCONNECTED, + ]); + + OpcuaConnection::create([ + 'machine_connection_id' => $connection->id, + 'endpoint_url' => $data['endpoint_url'], + 'security_policy' => $data['security_policy'], + 'security_mode' => $data['security_mode'], + 'auth_mode' => $data['auth_mode'], + 'username' => $data['username'] ?? null, + 'password_encrypted' => ! empty($data['password']) ? Crypt::encryptString($data['password']) : null, + 'publishing_interval_ms' => $data['publishing_interval_ms'], + ]); + }); + + return redirect()->route('admin.connectivity.opcua.index') + ->with('success', __('OPC UA connection created.')); + } + + public function show(MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_OPCUA, 404); + $machineConnection->load(['opcuaConnection', 'tags.workstation']); + + return view('admin.connectivity.opcua.show', [ + 'connection' => $machineConnection, + 'workstations' => Workstation::with('line:id,name')->orderBy('name')->get(), + 'runtime' => app(RuntimeMonitor::class)->connectionRuntime($machineConnection), + ]); + } + + public function edit(MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_OPCUA, 404); + $machineConnection->load('opcuaConnection'); + + return view('admin.connectivity.opcua.edit', ['connection' => $machineConnection]); + } + + public function update(Request $request, MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_OPCUA, 404); + $data = $this->validateData($request); + + DB::transaction(function () use ($machineConnection, $data, $request) { + $machineConnection->update([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'is_active' => $request->boolean('is_active'), + ]); + $update = [ + 'endpoint_url' => $data['endpoint_url'], + 'security_policy' => $data['security_policy'], + 'security_mode' => $data['security_mode'], + 'auth_mode' => $data['auth_mode'], + 'username' => $data['username'] ?? null, + 'publishing_interval_ms' => $data['publishing_interval_ms'], + ]; + if (! empty($data['password'])) { + $update['password_encrypted'] = Crypt::encryptString($data['password']); + } + $machineConnection->opcuaConnection->update($update); + }); + + return redirect()->route('admin.connectivity.opcua.show', $machineConnection) + ->with('success', __('OPC UA connection updated.')); + } + + public function destroy(MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_OPCUA, 404); + $machineConnection->delete(); + + return redirect()->route('admin.connectivity.opcua.index') + ->with('success', __('OPC UA connection deleted.')); + } + + public function storeTag(Request $request, MachineConnection $machineConnection) + { + abort_unless($machineConnection->protocol === MachineConnection::PROTOCOL_OPCUA, 404); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'address' => ['required', 'string', 'max:255'], // node id, e.g. ns=2;s=State + 'signal_type' => ['required', 'string', 'max:30'], + 'data_type' => ['required', 'string', 'max:20'], + 'workstation_id' => ['nullable', 'integer', 'exists:workstations,id'], + 'value_map' => ['nullable', 'string'], + 'scale' => ['nullable', 'numeric'], + ]); + + $transform = []; + if (! empty($data['value_map'])) { + $map = []; + foreach (explode(',', $data['value_map']) as $pair) { + [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, null); + if ($k !== null && $v !== null) { + $map[trim($k)] = trim($v); + } + } + if ($map) { + $transform['value_map'] = $map; + } + } + if (isset($data['scale'])) { + $transform['scale'] = (float) $data['scale']; + } + + MachineTag::create([ + 'machine_connection_id' => $machineConnection->id, + 'workstation_id' => $data['workstation_id'] ?? null, + 'name' => $data['name'], + 'address' => $data['address'], + 'signal_type' => $data['signal_type'], + 'data_type' => $data['data_type'], + 'register_type' => null, + 'transform' => $transform ?: null, + ]); + + return back()->with('success', __('Tag added.')); + } + + public function destroyTag(MachineConnection $machineConnection, MachineTag $tag) + { + abort_unless($tag->machine_connection_id === $machineConnection->id, 404); + $tag->delete(); + + return back()->with('success', __('Tag removed.')); + } + + private function validateData(Request $request): array + { + return $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string', 'max:500'], + 'endpoint_url' => ['required', 'string', 'max:500'], + 'security_policy' => ['required', 'in:None,Basic256Sha256'], + 'security_mode' => ['required', 'in:None,Sign,SignAndEncrypt'], + 'auth_mode' => ['required', 'in:anonymous,username,certificate'], + 'username' => ['nullable', 'string', 'max:100'], + 'password' => ['nullable', 'string', 'max:255'], + 'publishing_interval_ms' => ['required', 'integer', 'min:100', 'max:60000'], + ]); + } +} 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/Admin/MachineMonitorController.php b/backend/app/Http/Controllers/Web/Admin/MachineMonitorController.php new file mode 100644 index 00000000..e4cad408 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/MachineMonitorController.php @@ -0,0 +1,49 @@ + $this->tiles(), + ]); + } + + public function check(): JsonResponse + { + return response()->json(['data' => $this->tiles(), 'timestamp' => now()->timestamp]); + } + + /** + * Flatten the fleet read model into the tile shape used by both the initial + * render and the polling endpoint. + */ + private function tiles(): array + { + return collect($this->monitor->fleetStatus())->map(fn ($s) => [ + 'id' => $s['workstation']->id, + 'name' => $s['workstation']->name, + 'line' => $s['workstation']->line?->name, + 'state' => $s['state'], + 'color' => $this->monitor->stateColor($s['state']), + 'since' => $s['since']?->toIso8601String(), + 'availability' => $s['availability'], + 'quality' => $s['quality'], + 'good' => $s['good'], + 'reject' => $s['reject'], + 'metadata' => $s['metadata'], + ])->values()->all(); + } +} diff --git a/backend/app/Http/Controllers/Web/Admin/MaintenanceEventController.php b/backend/app/Http/Controllers/Web/Admin/MaintenanceEventController.php index 436ba08c..f4cffdd6 100644 --- a/backend/app/Http/Controllers/Web/Admin/MaintenanceEventController.php +++ b/backend/app/Http/Controllers/Web/Admin/MaintenanceEventController.php @@ -75,6 +75,7 @@ public function store(Request $request) 'cost_source_id' => 'nullable|exists:cost_sources,id', 'assigned_to_id' => 'nullable|exists:users,id', 'scheduled_at' => 'required|date', + 'scheduled_end_at' => 'nullable|date|after:scheduled_at', 'description' => 'nullable|string|max:2000', 'actual_cost' => 'nullable|numeric|min:0', 'currency' => 'nullable|string|max:10', @@ -89,7 +90,7 @@ public function store(Request $request) MaintenanceEvent::create($validated); return redirect()->route('admin.maintenance-events.index') - ->with('success', 'Maintenance event created successfully.'); + ->with('success', __('Maintenance event created successfully.')); } /** @@ -124,6 +125,7 @@ public function update(Request $request, MaintenanceEvent $maintenanceEvent) 'cost_source_id' => 'nullable|exists:cost_sources,id', 'assigned_to_id' => 'nullable|exists:users,id', 'scheduled_at' => 'required|date', + 'scheduled_end_at' => 'nullable|date|after:scheduled_at', 'started_at' => 'nullable|date', 'completed_at' => 'nullable|date|after_or_equal:started_at', 'description' => 'nullable|string|max:2000', diff --git a/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php b/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php index ff357992..166fc965 100644 --- a/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php +++ b/backend/app/Http/Controllers/Web/Admin/SchedulePlannerController.php @@ -67,26 +67,26 @@ public function index(Request $request) ->whereIn('line_id', $lineIds) ->where(function ($q) use ($rangeStart, $rangeEnd) { $q->whereBetween('due_date', [$rangeStart, $rangeEnd]) - ->orWhere(function ($q2) use ($rangeStart, $rangeEnd) { - // Minute-planned orders that overlap the visible range - $q2->whereNotNull('planned_start_at') - ->whereNotNull('planned_end_at') - ->where('planned_start_at', '<', $rangeEnd) - ->where('planned_end_at', '>', $rangeStart); - }) - ->orWhere(function ($q2) use ($rangeStart, $rangeEnd) { - $q2->whereNull('due_date') - ->where(function ($q3) use ($rangeStart, $rangeEnd) { - // Match by week_number if due_date is null - $weekNumbers = []; - $cursor = $rangeStart->copy(); - while ($cursor->lte($rangeEnd)) { - $weekNumbers[] = $cursor->isoWeek(); - $cursor->addWeek(); - } - $q3->whereIn('week_number', array_unique($weekNumbers)); - }); - }); + ->orWhere(function ($q2) use ($rangeStart, $rangeEnd) { + // Minute-planned orders that overlap the visible range + $q2->whereNotNull('planned_start_at') + ->whereNotNull('planned_end_at') + ->where('planned_start_at', '<', $rangeEnd) + ->where('planned_end_at', '>', $rangeStart); + }) + ->orWhere(function ($q2) use ($rangeStart, $rangeEnd) { + $q2->whereNull('due_date') + ->where(function ($q3) use ($rangeStart, $rangeEnd) { + // Match by week_number if due_date is null + $weekNumbers = []; + $cursor = $rangeStart->copy(); + while ($cursor->lte($rangeEnd)) { + $weekNumbers[] = $cursor->isoWeek(); + $cursor->addWeek(); + } + $q3->whereIn('week_number', array_unique($weekNumbers)); + }); + }); }) ->orderBy('priority', 'desc') ->orderBy('due_date') @@ -112,6 +112,78 @@ public function index(Request $request) default => $startDate->copy()->addWeek(), }; + // Maintenance events in range (pending/in_progress, with scheduled_at) + $maintenanceEvents = \App\Models\MaintenanceEvent::with(['line', 'workstation']) + ->whereIn('status', ['pending', 'in_progress']) + ->whereNotNull('scheduled_at') + ->where('scheduled_at', '>=', $rangeStart) + ->where('scheduled_at', '<=', $rangeEnd) + ->orderBy('scheduled_at') + ->get(); + + // Generate virtual maintenance events from recurring schedules + // for the entire visible range (not just next_due_at) + $activeSchedules = \App\Models\MaintenanceSchedule::with(['line', 'workstation']) + ->where('is_active', true) + ->whereNotNull('next_due_at') + ->get(); + + foreach ($activeSchedules as $schedule) { + // Calculate interval in days + $intervalDays = match ($schedule->frequency) { + 'daily' => 1, + 'weekly' => 7, + 'monthly' => 30, + 'quarterly' => 91, + 'annually' => 365, + default => 7, + }; + + // Generate occurrences within visible range + $cursor = $schedule->next_due_at->copy(); + + // If next_due is after range, walk backwards to find first occurrence in range + while ($cursor->gt($rangeEnd)) { + $cursor->subDays($intervalDays); + } + // Walk backwards to find the earliest occurrence in range + while ($cursor->copy()->subDays($intervalDays)->gte($rangeStart)) { + $cursor->subDays($intervalDays); + } + + // Now walk forward generating events + while ($cursor->lte($rangeEnd)) { + if ($cursor->gte($rangeStart)) { + // Check if a real event already exists on this date + $dateStr = $cursor->format('Y-m-d'); + $hasEvent = $maintenanceEvents->contains(function ($e) use ($schedule, $dateStr) { + return $e->schedule_id === $schedule->id + && $e->scheduled_at->format('Y-m-d') === $dateStr; + }); + + if (! $hasEvent) { + $time = $schedule->preferred_time ?? '06:00'; + $scheduledAt = $cursor->copy()->setTimeFromTimeString($time); + $virtual = new \App\Models\MaintenanceEvent([ + 'title' => $schedule->name, + 'event_type' => $schedule->event_type, + 'status' => 'pending', + 'line_id' => $schedule->line_id, + 'workstation_id' => $schedule->workstation_id, + 'schedule_id' => $schedule->id, + 'scheduled_at' => $scheduledAt, + 'scheduled_end_at' => $scheduledAt->copy()->addHour(), + 'description' => $schedule->description, + ]); + $virtual->setRelation('line', $schedule->line); + $virtual->setRelation('workstation', $schedule->workstation); + $maintenanceEvents->push($virtual); + } + } + $cursor->addDays($intervalDays); + } + } + // All lines for filter dropdown (unfiltered) $allLines = Line::where('is_active', true)->orderBy('name')->get(); @@ -120,9 +192,9 @@ public function index(Request $request) ->whereIn('status', WorkOrder::ACTIVE_STATUSES) ->where(function ($q) { $q->whereNull('line_id') - ->orWhere(function ($q2) { - $q2->whereNull('due_date')->whereNull('week_number'); - }); + ->orWhere(function ($q2) { + $q2->whereNull('due_date')->whereNull('week_number'); + }); }) ->orderBy('priority', 'desc') ->orderBy('due_date') @@ -146,6 +218,7 @@ public function index(Request $request) 'navPrev', 'navNext', 'backlogOrders', + 'maintenanceEvents', 'realtimeMode', ); @@ -238,17 +311,17 @@ public function updateOrder(Request $request, WorkOrder $workOrder) } // Auto-create first batch if none exist and WO has line + snapshot - if ($workOrder->line_id && !empty($workOrder->process_snapshot) && $workOrder->batches()->count() === 0) { + if ($workOrder->line_id && ! empty($workOrder->process_snapshot) && $workOrder->batches()->count() === 0) { app(\App\Services\WorkOrder\WorkOrderService::class) ->createBatch($workOrder, $workOrder->planned_qty); } // Warn about cross-line workstations $warnings = []; - if ($workOrder->line_id && !empty($workOrder->process_snapshot)) { + if ($workOrder->line_id && ! empty($workOrder->process_snapshot)) { $lineWorkstationIds = \App\Models\Workstation::where('line_id', $workOrder->line_id)->pluck('id')->toArray(); foreach ($workOrder->process_snapshot['steps'] ?? [] as $step) { - if (!empty($step['workstation_id']) && !in_array($step['workstation_id'], $lineWorkstationIds)) { + if (! empty($step['workstation_id']) && ! in_array($step['workstation_id'], $lineWorkstationIds)) { $warnings[] = __('Step ":step" uses workstation ":ws" from another line.', [ 'step' => $step['name'], 'ws' => $step['workstation_name'] ?? $step['workstation_id'], @@ -257,11 +330,11 @@ public function updateOrder(Request $request, WorkOrder $workOrder) } } - event(new \App\Events\ScheduleUpdated()); + event(new \App\Events\ScheduleUpdated); $message = __('Work order updated successfully.'); - if (!empty($warnings)) { - $message .= ' ' . __('Warnings:') . ' ' . implode('; ', $warnings); + if (! empty($warnings)) { + $message .= ' '.__('Warnings:').' '.implode('; ', $warnings); } if ($request->wantsJson() || $request->ajax()) { @@ -318,7 +391,7 @@ public function resizeOrder(Request $request, WorkOrder $workOrder) 'planned_end_at' => $validated['planned_end_at'], ]); - event(new \App\Events\ScheduleUpdated()); + event(new \App\Events\ScheduleUpdated); return response()->json([ 'success' => true, @@ -337,7 +410,7 @@ public function resizeOrder(Request $request, WorkOrder $workOrder) $workOrder->update(['end_date' => null, 'end_shift_number' => null]); } else { $request->validate([ - 'end_date' => 'required|date|after_or_equal:' . ($workOrder->due_date?->format('Y-m-d') ?? 'today'), + 'end_date' => 'required|date|after_or_equal:'.($workOrder->due_date?->format('Y-m-d') ?? 'today'), 'end_shift_number' => 'required|integer|min:1|max:10', ]); $workOrder->update([ @@ -346,7 +419,7 @@ public function resizeOrder(Request $request, WorkOrder $workOrder) ]); } - event(new \App\Events\ScheduleUpdated()); + event(new \App\Events\ScheduleUpdated); return response()->json([ 'success' => true, @@ -491,7 +564,7 @@ private function buildWeeklyData(Carbon $start, Carbon $end, $lines, $workOrders for ($d = 0; $d < $daysInWeek; $d++) { $dateKey = $dayCursor->format('Y-m-d'); for ($s = 1; $s <= $shiftsPerDay; $s++) { - $grid[$dateKey . '-' . $s] = null; + $grid[$dateKey.'-'.$s] = null; } $dayCursor->addDay(); } @@ -501,7 +574,7 @@ private function buildWeeklyData(Carbon $start, Carbon $end, $lines, $workOrders // First pass: orders with explicit shift_number go to exact slot foreach ($dated as $wo) { if ($wo->shift_number) { - $key = $wo->due_date->format('Y-m-d') . '-' . $wo->shift_number; + $key = $wo->due_date->format('Y-m-d').'-'.$wo->shift_number; if (array_key_exists($key, $grid) && $grid[$key] === null) { $grid[$key] = $wo; } @@ -514,7 +587,7 @@ private function buildWeeklyData(Carbon $start, Carbon $end, $lines, $workOrders } $dk = $wo->due_date->format('Y-m-d'); for ($s = 1; $s <= $shiftsPerDay; $s++) { - $key = $dk . '-' . $s; + $key = $dk.'-'.$s; if (array_key_exists($key, $grid) && $grid[$key] === null) { $grid[$key] = $wo; break; @@ -542,6 +615,7 @@ private function buildWeeklyData(Carbon $start, Carbon $end, $lines, $workOrders } if (! $wo->end_date || ! $wo->end_shift_number) { $spans[$key] = ['order' => $wo, 'type' => 'single', 'rowspan' => 1]; + continue; } @@ -557,10 +631,14 @@ private function buildWeeklyData(Carbon $start, Carbon $end, $lines, $workOrders $maxIter = $shiftsPerDay * $daysInWeek * 2; // safety limit $iter = 0; while ($iter++ < $maxIter) { - $curKey = $curDate . '-' . $curShift; - if (! array_key_exists($curKey, $grid)) break; + $curKey = $curDate.'-'.$curShift; + if (! array_key_exists($curKey, $grid)) { + break; + } $spannedKeys[] = $curKey; - if ($curDate === $endDate && $curShift === $endShift) break; + if ($curDate === $endDate && $curShift === $endShift) { + break; + } // Advance: next shift, or wrap to next day $curShift++; if ($curShift > $shiftsPerDay) { @@ -571,6 +649,7 @@ private function buildWeeklyData(Carbon $start, Carbon $end, $lines, $workOrders if (count($spannedKeys) <= 1) { $spans[$key] = ['order' => $wo, 'type' => 'single', 'rowspan' => 1]; + continue; } @@ -626,8 +705,8 @@ private function buildWeeklyData(Carbon $start, Carbon $end, $lines, $workOrders 'number' => $weekNumber, 'start' => $weekStart, 'end' => $weekEnd, - 'label' => __('Week') . ' ' . $weekNumber, - 'date_range' => $weekStart->format('d.m') . ' - ' . $weekEnd->format('d.m'), + 'label' => __('Week').' '.$weekNumber, + 'date_range' => $weekStart->format('d.m').' - '.$weekEnd->format('d.m'), 'lines' => $weekLines, 'total_orders' => $totalOrders, 'total_load_percent' => $totalLoad, @@ -774,8 +853,40 @@ 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)); + + // Stacked-lane geometry (px) computed here so the view stays + // logic-free: lanes share a 90px band, the row grows to fit them. + $laneHeight = $totalLanes > 1 ? max(30, (int) (90 / $totalLanes)) : 90; + + $layouts = $layouts->map(function ($l) use ($conflicts, $laneMap, $totalLanes, $laneHeight) { + $lane = $laneMap[$l['wo']->id] ?? 0; $l['has_conflict'] = isset($conflicts[$l['wo']->id]); + $l['lane'] = $lane; + $l['total_lanes'] = $totalLanes; + $l['lane_height'] = $laneHeight; + $l['lane_top'] = 6 + ($lane * ($laneHeight + 2)); return $l; }); @@ -788,6 +899,7 @@ private function buildHourlyData(Carbon $start, $lines, $workOrders, int $slotMi $lineRows[] = [ 'line' => $line, 'orders' => $layouts, + 'row_height' => max(114, 6 + $totalLanes * 34), 'used_minutes' => $usedMinutes, 'capacity_minutes' => $minutesPerDay, 'load_percent' => $loadPercent, diff --git a/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php b/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php new file mode 100644 index 00000000..e9a5169e --- /dev/null +++ b/backend/app/Http/Controllers/Web/Admin/TraceabilityController.php @@ -0,0 +1,61 @@ +query('q', '')); + $result = null; + + if ($term !== '') { + $resolved = $this->tracer->resolve($term); + + if ($resolved && $resolved['type'] === 'batch') { + $result = [ + 'type' => 'batch', + 'data' => $this->tracer->batchGenealogy($resolved['model']), + ]; + } elseif ($resolved && $resolved['type'] === 'material_lot') { + $lot = $resolved['model']; + $result = [ + 'type' => 'material_lot', + 'forward' => $this->tracer->forwardTrace($lot), + 'backward' => $this->tracer->backwardTraceLot($lot), + ]; + } else { + // Fall back to serial-number lookup + $unit = SerialUnit::where('serial_no', $term)->first(); + if ($unit) { + $result = [ + 'type' => 'serial', + 'data' => $this->serials->getHistory($unit), + ]; + } + } + } + + return view('admin.traceability.index', [ + 'term' => $term, + 'result' => $result, + ]); + } +} diff --git a/backend/app/Http/Controllers/Web/AuthController.php b/backend/app/Http/Controllers/Web/AuthController.php index 1dc82a7e..34b9a491 100644 --- a/backend/app/Http/Controllers/Web/AuthController.php +++ b/backend/app/Http/Controllers/Web/AuthController.php @@ -45,14 +45,24 @@ public function login(Request $request) ]); } + $user = auth()->user(); + + // If 2FA is enabled, redirect to challenge page + if ($user->two_factor_enabled) { + Auth::logout(); + $request->session()->put('2fa_user_id', $user->id); + $request->session()->put('2fa_remember', $request->filled('remember')); + return redirect()->route('two-factor.challenge'); + } + // Regenerate session to prevent session fixation $request->session()->regenerate(); // Update last login - auth()->user()->update(['last_login_at' => now()]); + $user->update(['last_login_at' => now()]); // Check if user needs to change password - if (auth()->user()->force_password_change) { + if ($user->force_password_change) { return redirect()->route('change-password') ->with('error', 'You must change your password before continuing.'); } 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..577a44b1 --- /dev/null +++ b/backend/app/Http/Controllers/Web/Operator/ProductionCorrectionController.php @@ -0,0 +1,120 @@ +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 + { + // 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'); + + $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/WorkOrderController.php b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php index 03777ead..d37094b0 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkOrderController.php @@ -54,32 +54,48 @@ public function queue(Request $request) $line = \App\Models\Line::find($lineId); - // Workstation filter: from query param, session, or workstation account + $settingRows = DB::table('system_settings')->get()->keyBy('key'); + $workflowMode = json_decode($settingRows['workflow_mode']->value ?? '"status"', true) ?? 'status'; + $trackingMode = json_decode($settingRows['production_tracking_mode']->value ?? '"per_operation"', true) ?? 'per_operation'; + $routingEnabled = json_decode($settingRows['workstation_routing_enabled']->value ?? 'false', true) ?? false; + + // Workstation filter: from query param, session, or workstation account. + // Workstation accounts default to their own assigned workstation. $selectedWorkstationId = $request->query('workstation') - ?? $request->session()->get('selected_workstation_id'); + ?? $request->session()->get('selected_workstation_id') + ?? (auth()->user()->account_type === 'workstation' ? auth()->user()->workstation_id : null); if ($request->has('workstation')) { $request->session()->put('selected_workstation_id', $selectedWorkstationId); } + // A workstation may belong to another line when routing spans lines, so + // only constrain to the current line when routing is disabled. $selectedWorkstation = $selectedWorkstationId - ? Workstation::where('id', $selectedWorkstationId)->where('line_id', $lineId)->first() + ? ($routingEnabled + ? Workstation::find($selectedWorkstationId) + : Workstation::where('id', $selectedWorkstationId)->where('line_id', $lineId)->first()) : null; $lineStatuses = LineStatus::forLine($lineId)->get(); $issueTypes = IssueType::where('is_active', true)->orderBy('name')->get(); - $settingRows = DB::table('system_settings')->get()->keyBy('key'); - $workflowMode = json_decode($settingRows['workflow_mode']->value ?? '"status"', true) ?? 'status'; - $trackingMode = json_decode($settingRows['production_tracking_mode']->value ?? '"per_operation"', true) ?? 'per_operation'; $doneStatusIds = $lineStatuses->where('is_done_status', true)->pluck('id')->values(); // In per_operation/hybrid mode with selected workstation: filter to WOs with current step on this workstation $workstationQueue = collect(); if (in_array($trackingMode, ['per_operation', 'hybrid']) && $selectedWorkstation) { - $workstationQueue = $activeWorkOrders->filter(function ($wo) use ($selectedWorkstation) { + // When routing is enabled, scan all active work orders (steps may route + // across lines, e.g. a shared packing station); otherwise stay on this line. + $queueSource = $routingEnabled + ? WorkOrder::whereIn('status', WorkOrder::ACTIVE_STATUSES) + ->with(['productType', 'batches.steps.workstation']) + ->get() + : $activeWorkOrders; + + $workstationQueue = $queueSource->filter(function ($wo) use ($selectedWorkstation) { foreach ($wo->batches as $batch) { $currentStep = $batch->currentStep(); - if ($currentStep && $currentStep->workstation_id === $selectedWorkstation->id) { + if ($currentStep && (int) $currentStep->workstation_id === (int) $selectedWorkstation->id) { return true; } } @@ -140,6 +156,48 @@ public function updateLineStatus(Request $request, WorkOrder $workOrder) return back()->with('success', 'Status updated.'); } + /** + * JSON endpoint for polling — returns current queue counts. + */ + public function check(Request $request) + { + $lineId = $request->session()->get('selected_line_id'); + if (!$lineId) { + return response()->json(['active' => 0, 'workstation' => 0]); + } + + $activeCount = WorkOrder::where('line_id', $lineId) + ->whereIn('status', WorkOrder::ACTIVE_STATUSES) + ->count(); + + $workstationCount = 0; + $wsId = $request->session()->get('selected_workstation_id'); + $settingRows = DB::table('system_settings')->get()->keyBy('key'); + $trackingMode = json_decode($settingRows['production_tracking_mode']->value ?? '"per_operation"', true) ?? 'per_operation'; + + if ($wsId && in_array($trackingMode, ['per_operation', 'hybrid'])) { + $workstationCount = WorkOrder::where('line_id', $lineId) + ->whereIn('status', WorkOrder::ACTIVE_STATUSES) + ->with('batches.steps') + ->get() + ->filter(function ($wo) use ($wsId) { + foreach ($wo->batches as $batch) { + $step = $batch->currentStep(); + if ($step && $step->workstation_id == $wsId) { + return true; + } + } + return false; + })->count(); + } + + return response()->json([ + 'active' => $activeCount, + 'workstation' => $workstationCount, + 'timestamp' => now()->timestamp, + ]); + } + /** * Show work order detail page. */ diff --git a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php index 16ff4c3f..1d3b264c 100644 --- a/backend/app/Http/Controllers/Web/Operator/WorkstationController.php +++ b/backend/app/Http/Controllers/Web/Operator/WorkstationController.php @@ -82,13 +82,44 @@ 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' )); } + /** + * JSON endpoint for polling — returns work order count and hash for change detection. + */ + public function check(Request $request) + { + $lineId = $request->session()->get('selected_line_id'); + if (!$lineId) { + return response()->json(['count' => 0, 'hash' => '']); + } + + $weekFilter = $request->query('week'); + $query = WorkOrder::where('line_id', $lineId) + ->whereNotIn('status', [WorkOrder::STATUS_REJECTED, WorkOrder::STATUS_CANCELLED]); + + if ($weekFilter && $weekFilter !== 'all') { + $query->where('week_number', (int) $weekFilter); + } + + $orders = $query->select('id', 'status', 'produced_qty')->get(); + $hash = md5($orders->map(fn($o) => "{$o->id}:{$o->status}:{$o->produced_qty}")->implode('|')); + + return response()->json([ + 'count' => $orders->count(), + 'hash' => $hash, + 'timestamp' => now()->timestamp, + ]); + } + /** * Build complete column list: system fields first, then all extra_data keys. */ diff --git a/backend/app/Http/Controllers/Web/Packaging/LabelTemplateController.php b/backend/app/Http/Controllers/Web/Packaging/LabelTemplateController.php index f1726914..dd5c738d 100644 --- a/backend/app/Http/Controllers/Web/Packaging/LabelTemplateController.php +++ b/backend/app/Http/Controllers/Web/Packaging/LabelTemplateController.php @@ -30,7 +30,10 @@ public function create() 'is_active' => true, ]); - return view('packaging.label-templates.create', compact('template')); + return view('packaging.label-templates.create', [ + 'template' => $template, + ...$this->formData($template), + ]); } public function store(Request $request) @@ -49,7 +52,10 @@ public function store(Request $request) public function edit(LabelTemplate $labelTemplate) { - return view('packaging.label-templates.edit', ['template' => $labelTemplate]); + return view('packaging.label-templates.edit', [ + 'template' => $labelTemplate, + ...$this->formData($labelTemplate), + ]); } public function update(Request $request, LabelTemplate $labelTemplate) @@ -83,6 +89,44 @@ public function setDefault(LabelTemplate $labelTemplate) ->with('success', __('Default template updated.')); } + /** + * Build the view data the create/edit form needs: the resolved field + * toggles (honoring old() input), the derived code type, the non-code + * fields, and the initial state for the live Alpine preview. + */ + private function formData(LabelTemplate $template): array + { + $fields = $template->fields_config + ?? LabelTemplate::defaultFieldsFor($template->type ?? LabelTemplate::TYPE_WORK_ORDER); + + $initialFields = []; + foreach (array_keys(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 both set. + $codeType = 'none'; + if (! empty($initialFields['barcode'])) { + $codeType = 'barcode'; + } elseif (! empty($initialFields['qr'])) { + $codeType = 'qr'; + } + + return [ + 'otherFields' => collect(LabelTemplate::AVAILABLE_FIELDS) + ->except(['barcode', 'qr']) + ->toArray(), + 'previewInitial' => [ + 'name' => old('name', $template->name ?? ''), + 'type' => old('type', $template->type ?? LabelTemplate::TYPE_WORK_ORDER), + 'size' => old('size', $template->size ?? '100x50'), + 'barcode_format' => old('barcode_format', $template->barcode_format ?? 'code128'), + 'fields' => $initialFields, + 'code_type' => $codeType, + ], + ]; + } + private function validateRequest(Request $request): array { $request->validate([ diff --git a/backend/app/Http/Controllers/Web/Packaging/PackagingController.php b/backend/app/Http/Controllers/Web/Packaging/PackagingController.php index 25e1374a..e76f8ec5 100644 --- a/backend/app/Http/Controllers/Web/Packaging/PackagingController.php +++ b/backend/app/Http/Controllers/Web/Packaging/PackagingController.php @@ -8,6 +8,7 @@ use App\Models\WorkOrderEan; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class PackagingController extends Controller { @@ -15,7 +16,12 @@ class PackagingController extends Controller public function station() { - return view('packaging.station'); + $scannerMode = json_decode( + 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 3f12379c..5d020823 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; @@ -93,6 +94,7 @@ public function showSystemSettings() 'production_period' => json_decode($rows['production_period']->value ?? '"none"', true) ?? 'none', 'allow_overproduction' => json_decode($rows['allow_overproduction']->value ?? 'false', true) ?? false, 'force_sequential_steps' => json_decode($rows['force_sequential_steps']->value ?? 'true', true) ?? true, + 'workstation_routing_enabled' => json_decode($rows['workstation_routing_enabled']->value ?? 'false', true) ?? false, 'workflow_mode' => json_decode($rows['workflow_mode']->value ?? '"status"', true) ?? 'status', 'pin_login_enabled' => json_decode($rows['pin_login_enabled']->value ?? 'false', true) ?? false, 'language' => json_decode($rows['language']->value ?? '"en"', true) ?? 'en', @@ -104,6 +106,9 @@ 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, + 'scanner_mode' => json_decode($rows['scanner_mode']->value ?? '"hid"', true) ?? 'hid', ]; return view('settings.system', compact('settings')); @@ -243,9 +248,10 @@ public function updateSystemSettings(Request $request) 'production_period' => 'required|in:none,weekly,monthly', 'allow_overproduction' => 'nullable|boolean', 'force_sequential_steps' => 'nullable|boolean', + 'workstation_routing_enabled' => 'nullable|boolean', 'workflow_mode' => 'required|in:status,board_status', 'pin_login_enabled' => 'nullable|boolean', - 'language' => 'nullable|in:en,pl', + 'language' => 'nullable|in:en,pl,tr', 'schedule_view_mode' => 'required|in:weekly,daily,monthly', 'schedule_shifts_per_day' => 'required|integer|in:1,2,3,4', 'schedule_horizon_weeks' => 'required|integer|min:1|max:52', @@ -253,6 +259,11 @@ 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', + 'scanner_mode' => 'required|in:hid,manual', ]); $shiftsPerDay = (int) $validated['schedule_shifts_per_day']; @@ -262,6 +273,7 @@ public function updateSystemSettings(Request $request) 'production_period' => $validated['production_period'], 'allow_overproduction' => (bool) ($validated['allow_overproduction'] ?? false), 'force_sequential_steps' => (bool) ($validated['force_sequential_steps'] ?? false), + 'workstation_routing_enabled' => (bool) ($validated['workstation_routing_enabled'] ?? false), 'workflow_mode' => $validated['workflow_mode'], 'pin_login_enabled' => (bool) ($validated['pin_login_enabled'] ?? false), 'language' => $validated['language'] ?? 'en', @@ -272,7 +284,12 @@ 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), + 'scanner_mode' => $validated['scanner_mode'], ]; foreach ($map as $key => $value) { @@ -287,4 +304,174 @@ public function updateSystemSettings(Request $request) return redirect()->route('settings.system') ->with('success', 'System settings updated.'); } + + /** + * Export full system configuration as JSON file + */ + public function exportSettings() + { + $export = [ + 'exported_at' => now()->toISOString(), + 'version' => config('version.current'), + '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-config-' . date('Y-m-d') . '.json"', + ]); + } + + /** + * Import system configuration from JSON file + */ + public function importSettings(Request $request) + { + $request->validate([ + 'settings_file' => 'required|file|mimes:json,txt|max:10240', + ]); + + 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.')); + } + + // Backward compat: old format with just 'settings' key + if (isset($data['settings']) && !isset($data['system_settings'])) { + $data['system_settings'] = $data['settings']; + } + + $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', + 'cors_allowed_origins', 'cors_allowed_methods', + 'reverb_app_id', 'reverb_app_key', 'reverb_app_secret', + 'modules_enabled', + ]; + + $imported = 0; + + DB::beginTransaction(); + + 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(); + + 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; + } + + // For all other tables: upsert by unique key (code or name) + if (empty($rows)) continue; + + // 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', 'process_templates', 'maintenance_schedules', + 'inspection_plans', 'label_templates' => '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]); + } + // Remove null values for columns that might not accept null + $row = array_filter($row, fn($v) => $v !== null); + + if (empty($row)) continue; + + try { + DB::statement('SAVEPOINT row_insert'); + if ($uniqueKey && isset($row[$uniqueKey])) { + DB::table($tableName)->updateOrInsert( + [$uniqueKey => $row[$uniqueKey]], + $row + ); + } else { + DB::table($tableName)->insert($row); + } + DB::statement('RELEASE SAVEPOINT row_insert'); + $imported++; + } catch (\Exception $e) { + DB::statement('ROLLBACK TO SAVEPOINT row_insert'); + continue; + } + } + } + + DB::commit(); + Cache::flush(); + + 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/app/Http/Controllers/Web/TwoFactorChallengeController.php b/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php new file mode 100644 index 00000000..0db2fdd3 --- /dev/null +++ b/backend/app/Http/Controllers/Web/TwoFactorChallengeController.php @@ -0,0 +1,135 @@ +session()->has('2fa_user_id')) { + return redirect()->route('login'); + } + + return view('auth.two-factor-challenge'); + } + + /** + * Verify the 2FA code and complete login. + */ + public function verify(Request $request) + { + $request->validate([ + 'code' => 'nullable|string', + 'recovery_code' => 'nullable|string', + ]); + + $userId = $request->session()->get('2fa_user_id'); + $remember = $request->session()->get('2fa_remember', false); + + if (!$userId) { + return redirect()->route('login'); + } + + // Rate limit: 5 attempts per minute + $key = '2fa-challenge:' . $userId; + if (RateLimiter::tooManyAttempts($key, 5)) { + $seconds = RateLimiter::availableIn($key); + return back()->withErrors([ + 'code' => "Too many attempts. Please wait {$seconds} seconds.", + ]); + } + + $user = User::find($userId); + if (!$user || !$user->two_factor_enabled) { + $request->session()->forget(['2fa_user_id', '2fa_remember']); + return redirect()->route('login'); + } + + // Try TOTP code + if ($request->filled('code')) { + $google2fa = new Google2FA(); + $secret = Crypt::decryptString($user->two_factor_secret); + $valid = $google2fa->verifyKey($secret, $request->input('code'), 1); + + if ($valid) { + return $this->completeLogin($request, $user, $remember); + } + + RateLimiter::hit($key, 60); + return back()->withErrors(['code' => 'Invalid authentication code.']); + } + + // Try recovery code + if ($request->filled('recovery_code')) { + $recoveryCode = $request->input('recovery_code'); + $storedCodes = json_decode(Crypt::decryptString($user->two_factor_recovery_codes), true); + + foreach ($storedCodes as $index => $hashedCode) { + if (Hash::check($recoveryCode, $hashedCode)) { + // Remove used code + unset($storedCodes[$index]); + $user->update([ + 'two_factor_recovery_codes' => Crypt::encryptString(json_encode(array_values($storedCodes))), + ]); + + return $this->completeLogin($request, $user, $remember); + } + } + + RateLimiter::hit($key, 60); + return back()->withErrors(['recovery_code' => 'Invalid recovery code.']); + } + + return back()->withErrors(['code' => 'Please enter an authentication code or recovery code.']); + } + + /** + * Complete login after 2FA verification. + */ + protected function completeLogin(Request $request, User $user, bool $remember) + { + $request->session()->forget(['2fa_user_id', '2fa_remember']); + + Auth::login($user, $remember); + $request->session()->regenerate(); + $user->update(['last_login_at' => now()]); + + if ($user->force_password_change) { + return redirect()->route('change-password') + ->with('error', 'You must change your password before continuing.'); + } + + // Redirect based on role (same logic as AuthController) + if ($user->hasRole('Admin')) { + if (OnboardingController::shouldShowWizard()) { + return redirect()->route('onboarding.index'); + } + return redirect()->route('admin.dashboard'); + } + + if ($user->hasRole('Supervisor')) { + return redirect()->route('supervisor.dashboard'); + } + + if ($user->account_type === 'workstation' && $user->workstation_id) { + $lineId = $user->workstation?->line_id; + if ($lineId) { + return redirect()->route('operator.queue', ['line' => $lineId]); + } + } + + return redirect()->route('operator.select-line'); + } +} diff --git a/backend/app/Http/Controllers/Web/TwoFactorController.php b/backend/app/Http/Controllers/Web/TwoFactorController.php new file mode 100644 index 00000000..91347460 --- /dev/null +++ b/backend/app/Http/Controllers/Web/TwoFactorController.php @@ -0,0 +1,162 @@ +google2fa = new Google2FA(); + } + + /** + * Show 2FA setup page with QR code. + */ + public function enable(Request $request) + { + $user = auth()->user(); + + if ($user->two_factor_enabled) { + return redirect()->route('settings.profile') + ->with('info', 'Two-factor authentication is already enabled.'); + } + + // Generate secret (or reuse pending one from session) + $secret = $request->session()->get('2fa_setup_secret'); + if (!$secret) { + $secret = $this->google2fa->generateSecretKey(); + $request->session()->put('2fa_setup_secret', $secret); + } + + $qrCodeUrl = $this->google2fa->getQRCodeUrl( + 'OpenMES', + $user->username, + $secret + ); + + // Generate QR code image as data URI + $result = Builder::create() + ->writer(new PngWriter()) + ->data($qrCodeUrl) + ->encoding(new Encoding('UTF-8')) + ->size(250) + ->margin(10) + ->build(); + + $qrCodeDataUri = $result->getDataUri(); + + return view('auth.two-factor-setup', [ + 'secret' => $secret, + 'qrCodeDataUri' => $qrCodeDataUri, + ]); + } + + /** + * Confirm 2FA setup — validate first code. + */ + public function confirm(Request $request) + { + $request->validate([ + 'code' => 'required|string|digits:6', + ]); + + $secret = $request->session()->get('2fa_setup_secret'); + if (!$secret) { + return back()->withErrors(['code' => 'Setup session expired. Please start again.']); + } + + $valid = $this->google2fa->verifyKey($secret, $request->input('code'), 1); + + if (!$valid) { + return back()->withErrors(['code' => 'Invalid code. Please try again.']); + } + + $user = auth()->user(); + + // Generate 8 recovery codes + $recoveryCodes = collect(range(1, 8))->map(fn() => Str::random(10))->values(); + $hashedCodes = $recoveryCodes->map(fn($c) => Hash::make($c))->toArray(); + + $user->update([ + 'two_factor_secret' => Crypt::encryptString($secret), + 'two_factor_enabled' => true, + 'two_factor_confirmed_at' => now(), + 'two_factor_recovery_codes' => Crypt::encryptString(json_encode($hashedCodes)), + ]); + + $request->session()->forget('2fa_setup_secret'); + + return view('auth.two-factor-recovery-codes', [ + 'recoveryCodes' => $recoveryCodes, + ]); + } + + /** + * Disable 2FA (requires current password). + */ + public function disable(Request $request) + { + $request->validate([ + 'password' => 'required|string', + ]); + + $user = auth()->user(); + + if (!Hash::check($request->input('password'), $user->password)) { + return back()->withErrors(['password' => 'Incorrect password.']); + } + + $user->update([ + 'two_factor_secret' => null, + 'two_factor_enabled' => false, + 'two_factor_confirmed_at' => null, + 'two_factor_recovery_codes' => null, + ]); + + return redirect()->route('settings.profile') + ->with('success', 'Two-factor authentication has been disabled.'); + } + + /** + * Regenerate recovery codes. + */ + public function regenerateRecoveryCodes(Request $request) + { + $request->validate([ + 'password' => 'required|string', + ]); + + $user = auth()->user(); + + if (!Hash::check($request->input('password'), $user->password)) { + return back()->withErrors(['password' => 'Incorrect password.']); + } + + if (!$user->two_factor_enabled) { + return back()->with('error', '2FA is not enabled.'); + } + + $recoveryCodes = collect(range(1, 8))->map(fn() => Str::random(10))->values(); + $hashedCodes = $recoveryCodes->map(fn($c) => Hash::make($c))->toArray(); + + $user->update([ + 'two_factor_recovery_codes' => Crypt::encryptString(json_encode($hashedCodes)), + ]); + + return view('auth.two-factor-recovery-codes', [ + 'recoveryCodes' => $recoveryCodes, + ]); + } +} 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/app/Livewire/BatchStepList.php b/backend/app/Livewire/BatchStepList.php index 398446f7..17ceabe7 100644 --- a/backend/app/Livewire/BatchStepList.php +++ b/backend/app/Livewire/BatchStepList.php @@ -20,12 +20,44 @@ class BatchStepList extends Component public ?int $pendingStepId = null; + /** Workstation routing context (set on mount) */ + public bool $routingEnabled = false; + + public ?int $myWorkstationId = null; + + public bool $canOperateAnyStation = false; + public function mount(int $batchId): void { $this->batchId = $batchId; + + $user = auth()->user(); + $this->routingEnabled = (bool) (json_decode( + \Illuminate\Support\Facades\DB::table('system_settings') + ->where('key', 'workstation_routing_enabled')->value('value') ?? 'false', + true + ) ?? false); + $this->myWorkstationId = $user->workstation_id ?? session('selected_workstation_id'); + $this->canOperateAnyStation = $user->hasRole('Admin') || $user->hasRole('Supervisor') || ! $user->workstation_id; + $this->loadBatch(); } + /** + * Whether the current user may operate the given step under routing rules. + */ + public function canOperateStep(BatchStep $step): bool + { + if (! $this->routingEnabled || ! $step->workstation_id) { + return true; + } + if ($this->canOperateAnyStation) { + return true; + } + + return (int) $step->workstation_id === (int) $this->myWorkstationId; + } + /** Estimated durations keyed by step_number, from process_snapshot */ public array $estimatedDurations = []; diff --git a/backend/app/Models/Batch.php b/backend/app/Models/Batch.php index ee20cd08..4b30482c 100644 --- a/backend/app/Models/Batch.php +++ b/backend/app/Models/Batch.php @@ -103,6 +103,15 @@ public function packagingChecklist() return $this->hasOne(PackagingChecklist::class); } + /** + * Material lots produced by this batch (semi-finished / multi-stage output). + * The inverse of MaterialLot::sourceBatch(). + */ + public function outputLots(): HasMany + { + return $this->hasMany(MaterialLot::class, 'source_batch_id'); + } + /** * Get the current (in progress or next pending) step. */ diff --git a/backend/app/Models/MachineConnection.php b/backend/app/Models/MachineConnection.php index 2e4ba3a6..067cbf8c 100644 --- a/backend/app/Models/MachineConnection.php +++ b/backend/app/Models/MachineConnection.php @@ -63,6 +63,26 @@ public function messages(): HasMany return $this->hasMany(MachineMessage::class); } + public function modbusConnection(): HasOne + { + return $this->hasOne(ModbusConnection::class); + } + + public function opcuaConnection(): HasOne + { + return $this->hasOne(OpcuaConnection::class); + } + + public function tags(): HasMany + { + return $this->hasMany(MachineTag::class); + } + + public function activeTags(): HasMany + { + return $this->hasMany(MachineTag::class)->where('is_active', true); + } + public function isConnected(): bool { return $this->status === self::STATUS_CONNECTED; diff --git a/backend/app/Models/MachineEvent.php b/backend/app/Models/MachineEvent.php new file mode 100644 index 00000000..28c6d457 --- /dev/null +++ b/backend/app/Models/MachineEvent.php @@ -0,0 +1,55 @@ + 'array', + 'event_timestamp' => 'datetime', + 'synced_to_cloud' => 'boolean', + ]; + } + + public function workstation(): BelongsTo + { + return $this->belongsTo(Workstation::class); + } + + public function connection(): BelongsTo + { + return $this->belongsTo(MachineConnection::class, 'machine_connection_id'); + } +} diff --git a/backend/app/Models/MachineTag.php b/backend/app/Models/MachineTag.php new file mode 100644 index 00000000..6f1eb71e --- /dev/null +++ b/backend/app/Models/MachineTag.php @@ -0,0 +1,90 @@ + 'array', + 'is_active' => 'boolean', + ]; + } + + public function connection(): BelongsTo + { + return $this->belongsTo(MachineConnection::class, 'machine_connection_id'); + } + + public function workstation(): BelongsTo + { + return $this->belongsTo(Workstation::class); + } + + /** + * Apply scale/offset/value-map to a raw reading, returning the semantic value. + */ + public function applyTransform(mixed $raw): mixed + { + $t = $this->transform ?? []; + + // Discrete value map (e.g. {1: RUNNING, 2: IDLE}) — used for state signals. + if (! empty($t['value_map']) && is_array($t['value_map'])) { + $key = is_bool($raw) ? ($raw ? '1' : '0') : (string) $raw; + + return $t['value_map'][$key] ?? ($t['value_map']['default'] ?? $raw); + } + + // Numeric scale/offset for analog telemetry / counters. + if (is_numeric($raw)) { + $value = (float) $raw; + if (isset($t['scale'])) { + $value *= (float) $t['scale']; + } + if (isset($t['offset'])) { + $value += (float) $t['offset']; + } + + return $value; + } + + return $raw; + } +} diff --git a/backend/app/Models/MaintenanceEvent.php b/backend/app/Models/MaintenanceEvent.php index 5897bffc..de2d28d9 100644 --- a/backend/app/Models/MaintenanceEvent.php +++ b/backend/app/Models/MaintenanceEvent.php @@ -31,6 +31,7 @@ class MaintenanceEvent extends Model 'cost_source_id', 'assigned_to_id', 'scheduled_at', + 'scheduled_end_at', 'started_at', 'completed_at', 'description', @@ -42,8 +43,9 @@ class MaintenanceEvent extends Model protected function casts(): array { return [ - 'scheduled_at' => 'datetime', - 'started_at' => 'datetime', + 'scheduled_at' => 'datetime', + 'scheduled_end_at' => 'datetime', + 'started_at' => 'datetime', 'completed_at' => 'datetime', 'actual_cost' => 'decimal:2', ]; diff --git a/backend/app/Models/MaterialLot.php b/backend/app/Models/MaterialLot.php index bbe234da..dc446ebe 100644 --- a/backend/app/Models/MaterialLot.php +++ b/backend/app/Models/MaterialLot.php @@ -51,6 +51,7 @@ class MaterialLot extends Model 'supplier_lot_no', 'supplier_reference', 'inspection_id', + 'source_batch_id', 'created_by_id', 'tenant_id', 'extra_data', @@ -85,6 +86,15 @@ public function inspection(): BelongsTo return $this->belongsTo(Inspection::class); } + /** + * The batch that produced this lot (for semi-finished / multi-stage lots). + * Null for inbound raw lots received from a supplier. + */ + public function sourceBatch(): BelongsTo + { + return $this->belongsTo(Batch::class, 'source_batch_id'); + } + public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by_id'); diff --git a/backend/app/Models/ModbusConnection.php b/backend/app/Models/ModbusConnection.php new file mode 100644 index 00000000..900f2cd7 --- /dev/null +++ b/backend/app/Models/ModbusConnection.php @@ -0,0 +1,40 @@ + 'integer', + 'unit_id' => 'integer', + 'poll_interval_ms' => 'integer', + 'timeout_seconds' => 'integer', + 'max_registers_per_read' => 'integer', + ]; + } + + public function connection(): BelongsTo + { + return $this->belongsTo(MachineConnection::class, 'machine_connection_id'); + } +} diff --git a/backend/app/Models/OpcuaConnection.php b/backend/app/Models/OpcuaConnection.php new file mode 100644 index 00000000..73a5b0e4 --- /dev/null +++ b/backend/app/Models/OpcuaConnection.php @@ -0,0 +1,34 @@ +belongsTo(MachineConnection::class, 'machine_connection_id'); + } +} diff --git a/backend/app/Models/SerialUnit.php b/backend/app/Models/SerialUnit.php new file mode 100644 index 00000000..a4626de8 --- /dev/null +++ b/backend/app/Models/SerialUnit.php @@ -0,0 +1,74 @@ + 'datetime', + 'extra_data' => 'array', + ]; + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function batch(): BelongsTo + { + return $this->belongsTo(Batch::class); + } + + public function material(): BelongsTo + { + return $this->belongsTo(Material::class); + } + + public function history(): HasMany + { + return $this->hasMany(UnitStepHistory::class)->orderBy('processed_at'); + } +} diff --git a/backend/app/Models/UnitStepHistory.php b/backend/app/Models/UnitStepHistory.php new file mode 100644 index 00000000..19038969 --- /dev/null +++ b/backend/app/Models/UnitStepHistory.php @@ -0,0 +1,57 @@ + 'array', + 'processed_at' => 'datetime', + ]; + } + + public function serialUnit(): BelongsTo + { + return $this->belongsTo(SerialUnit::class); + } + + public function batchStep(): BelongsTo + { + return $this->belongsTo(BatchStep::class); + } + + public function workstation(): BelongsTo + { + return $this->belongsTo(Workstation::class); + } + + public function operator(): BelongsTo + { + return $this->belongsTo(User::class, 'operator_id'); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index e7afa9fa..81463ea3 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -31,6 +31,10 @@ class User extends Authenticatable 'force_password_change', 'last_login_at', 'tenant_id', + 'two_factor_secret', + 'two_factor_enabled', + 'two_factor_confirmed_at', + 'two_factor_recovery_codes', ]; /** @@ -42,7 +46,8 @@ class User extends Authenticatable 'password', 'pin', 'remember_token', - 'pin', + 'two_factor_secret', + 'two_factor_recovery_codes', ]; /** @@ -56,6 +61,8 @@ protected function casts(): array 'force_password_change' => 'boolean', 'last_login_at' => 'datetime', 'password' => 'hashed', + 'two_factor_enabled' => 'boolean', + 'two_factor_confirmed_at' => 'datetime', ]; } diff --git a/backend/app/Models/WorkstationState.php b/backend/app/Models/WorkstationState.php new file mode 100644 index 00000000..43a4d91a --- /dev/null +++ b/backend/app/Models/WorkstationState.php @@ -0,0 +1,56 @@ + 'datetime', + 'ended_at' => 'datetime', + 'metadata' => 'array', + ]; + } + + public function workstation(): BelongsTo + { + return $this->belongsTo(Workstation::class); + } + + public function isLoss(): bool + { + return in_array($this->state, self::LOSS_STATES, true); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 46203939..d831c8d3 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -130,6 +130,7 @@ private function availableLocales(): array return [ 'en' => 'English', 'pl' => 'Polski', + 'tr' => 'Türkçe', ]; } } diff --git a/backend/app/Services/Machine/MachineMonitorService.php b/backend/app/Services/Machine/MachineMonitorService.php new file mode 100644 index 00000000..71821b59 --- /dev/null +++ b/backend/app/Services/Machine/MachineMonitorService.php @@ -0,0 +1,114 @@ +id) + ->whereNull('ended_at') + ->latest('started_at') + ->first(); + + $dayStart = now()->startOfDay(); + + // Sum durations per state today (closed slices + the open one up to now). + $slices = WorkstationState::where('workstation_id', $workstation->id) + ->where('started_at', '>=', $dayStart) + ->get(); + + $runningSec = 0; + $lossSec = 0; + $totalSec = 0; + foreach ($slices as $s) { + $end = $s->ended_at ?? now(); + $dur = max(0, (int) $s->started_at->diffInSeconds($end)); + $totalSec += $dur; + if ($s->state === WorkstationState::RUNNING) { + $runningSec += $dur; + } elseif (in_array($s->state, WorkstationState::LOSS_STATES, true)) { + $lossSec += $dur; + } + } + + $availability = $totalSec > 0 ? round($runningSec / $totalSec * 100, 1) : null; + + $counters = MachineEvent::where('workstation_id', $workstation->id) + ->where('event_type', MachineEvent::TYPE_COUNTER) + ->where('event_timestamp', '>=', $dayStart) + ->get(); + + $good = 0; + $reject = 0; + foreach ($counters as $c) { + $delta = (float) ($c->payload['delta'] ?? 0); + if (($c->payload['kind'] ?? 'good') === 'reject') { + $reject += $delta; + } else { + $good += $delta; + } + } + + $total = $good + $reject; + $quality = $total > 0 ? round($good / $total * 100, 1) : null; + + return [ + 'workstation' => $workstation, + 'state' => $current?->state ?? 'UNKNOWN', + 'since' => $current?->started_at, + 'metadata' => $current?->metadata ?? [], + 'availability' => $availability, + 'quality' => $quality, + 'good' => $good, + 'reject' => $reject, + 'running_seconds' => $runningSec, + 'loss_seconds' => $lossSec, + 'is_live' => $current !== null, + ]; + } + + /** + * Fleet view: live status for every workstation that has machine tags + * (i.e. is wired to a machine connection). + */ + public function fleetStatus(): array + { + $workstationIds = \App\Models\MachineTag::query() + ->whereNotNull('workstation_id') + ->distinct() + ->pluck('workstation_id'); + + return Workstation::with('line:id,name') + ->whereIn('id', $workstationIds) + ->orderBy('name') + ->get() + ->map(fn (Workstation $w) => $this->liveStatus($w)) + ->all(); + } + + public function stateColor(string $state): string + { + return match ($state) { + WorkstationState::RUNNING => 'green', + WorkstationState::IDLE => 'amber', + WorkstationState::SETUP => 'blue', + WorkstationState::STOPPED => 'gray', + WorkstationState::FAULT => 'red', + default => 'slate', + }; + } +} diff --git a/backend/app/Services/Machine/MachineSignalIngestor.php b/backend/app/Services/Machine/MachineSignalIngestor.php new file mode 100644 index 00000000..5049a85c --- /dev/null +++ b/backend/app/Services/Machine/MachineSignalIngestor.php @@ -0,0 +1,139 @@ +applyTransform($rawValue); + $workstation = $tag->workstation; + + match ($tag->signal_type) { + MachineTag::SIGNAL_STATE => $this->handleState($tag, $workstation, (string) $value, $at), + MachineTag::SIGNAL_GOOD_COUNT => $this->handleCounter($tag, $workstation, $value, 'good', $at), + MachineTag::SIGNAL_REJECT_COUNT => $this->handleCounter($tag, $workstation, $value, 'reject', $at), + MachineTag::SIGNAL_CYCLE_COMPLETE => $this->handleCounter($tag, $workstation, $value, 'good', $at), + MachineTag::SIGNAL_TELEMETRY => $this->handleTelemetry($tag, $workstation, $value, $at), + MachineTag::SIGNAL_ALARM => $this->handleAlarm($tag, $workstation, $value, $at), + default => null, + }; + } + + private function handleState(MachineTag $tag, ?Workstation $ws, string $state, Carbon $at): void + { + if (! $ws) { + return; + } + + $current = $this->stateMachine->current($ws); + + // Only record an event on an actual transition — polling re-reads the + // same state every cycle and we don't want a flood of no-op events. + if ($current?->state === $state) { + return; + } + + $this->stateMachine->transition($ws, $state, [], $at); + + $this->record($ws, $tag, MachineEvent::TYPE_STATE_CHANGE, $at, [ + 'from' => $current?->state, + 'to' => $state, + ], $current?->state, $state); + } + + /** + * Counters are cumulative on the machine; we store the last reading per tag + * and emit the delta. Counter resets (new < last) are treated as the new + * value to avoid negative spikes. + */ + private function handleCounter(MachineTag $tag, ?Workstation $ws, mixed $value, string $kind, Carbon $at): void + { + if (! $ws || ! is_numeric($value)) { + return; + } + + $value = (float) $value; + $cacheKey = "machine_tag_last:{$tag->id}"; + $last = Cache::get($cacheKey); + Cache::put($cacheKey, $value, now()->addDay()); + + $delta = ($last === null || $value < $last) ? ($last === null ? 0 : $value) : ($value - $last); + if ($delta <= 0) { + return; + } + + $this->record($ws, $tag, MachineEvent::TYPE_COUNTER, $at, [ + 'kind' => $kind, + 'value' => $value, + 'delta' => $delta, + ]); + } + + private function handleTelemetry(MachineTag $tag, ?Workstation $ws, mixed $value, Carbon $at): void + { + if (is_float($value)) { + $value = round($value, 2); + } + + if ($ws) { + $current = $this->stateMachine->current($ws); + if ($current) { + $current->update(['metadata' => array_merge($current->metadata ?? [], [$tag->name => $value])]); + } + } + + $this->record($ws, $tag, MachineEvent::TYPE_TELEMETRY, $at, [$tag->name => $value]); + } + + private function handleAlarm(MachineTag $tag, ?Workstation $ws, mixed $value, Carbon $at): void + { + // Truthy alarm value → record an alarm event. Issue creation is left to + // a downstream listener so policy stays configurable. + if (! $value) { + return; + } + + $this->record($ws, $tag, MachineEvent::TYPE_ALARM, $at, [ + 'tag' => $tag->name, + 'value' => $value, + ]); + } + + private function record(?Workstation $ws, MachineTag $tag, string $type, Carbon $at, array $payload, ?string $from = null, ?string $to = null): void + { + MachineEvent::create([ + 'workstation_id' => $ws?->id, + 'machine_connection_id' => $tag->machine_connection_id, + 'event_type' => $type, + 'state_from' => $from, + 'state_to' => $to, + 'payload' => $payload, + 'event_timestamp' => $at->format('Y-m-d H:i:s.u'), + 'correlation_id' => (string) Str::uuid(), + ]); + } +} diff --git a/backend/app/Services/Machine/Modbus/ModbusReader.php b/backend/app/Services/Machine/Modbus/ModbusReader.php new file mode 100644 index 00000000..4dc0b397 --- /dev/null +++ b/backend/app/Services/Machine/Modbus/ModbusReader.php @@ -0,0 +1,111 @@ +conn = BinaryStreamConnection::getBuilder() + ->setHost($this->modbus->host) + ->setPort($this->modbus->port) + ->setConnectTimeoutSec((float) $this->modbus->timeout_seconds) + ->setReadTimeoutSec((float) $this->modbus->timeout_seconds) + ->build(); + $this->conn->connect(); + } + + public function close(): void + { + $this->conn?->close(); + $this->conn = null; + } + + private function endian(): int + { + $byte = $this->modbus->byte_order === 'little' ? Endian::LITTLE_ENDIAN : Endian::BIG_ENDIAN; + $word = $this->modbus->word_order === 'little' ? Endian::LOW_WORD_FIRST : 0; + + return $byte | $word; + } + + /** + * Read a single tag and return its decoded raw value (pre-transform). + */ + public function readTag(MachineTag $tag): mixed + { + $address = (int) $this->normalizeAddress($tag->address); + $unit = $this->modbus->unit_id; + $is32 = in_array($tag->data_type, ['int32', 'uint32', 'float32'], true); + $quantity = $is32 ? 2 : 1; + + $registerType = $tag->register_type ?? 'holding'; + + $request = match ($registerType) { + 'coil' => new ReadCoilsRequest($address, 1, $unit), + 'discrete' => new ReadInputDiscretesRequest($address, 1, $unit), + 'input' => new ReadInputRegistersRequest($address, $quantity, $unit), + default => new ReadHoldingRegistersRequest($address, $quantity, $unit), + }; + + $binary = $this->conn->sendAndReceive($request); + $response = ResponseFactory::parseResponseOrThrow($binary)->withStartAddress($address); + + if (in_array($registerType, ['coil', 'discrete'], true)) { + return (bool) $response[$address]; + } + + $endian = $this->endian(); + + if ($is32) { + $dword = $response->getDoubleWordAt($address); + + return match ($tag->data_type) { + 'float32' => $dword->getFloat($endian), + 'uint32' => $dword->getUInt32($endian), + default => $dword->getInt32($endian), + }; + } + + $word = $response->getWordAt($address); + + return match ($tag->data_type) { + 'uint16' => $word->getUInt16($endian), + 'bool' => (bool) $word->getUInt16($endian), + default => $word->getInt16($endian), + }; + } + + /** + * Accept either a raw 0-based offset ("5") or a Modicon address + * ("40006" → 5, "30002" → 1) and return the 0-based register offset. + */ + private function normalizeAddress(string $address): int + { + $address = trim($address); + if (preg_match('/^[0134]\d{4}$/', $address)) { + return ((int) substr($address, 1)) - 1; + } + + return (int) $address; + } +} diff --git a/backend/app/Services/Machine/RuntimeMonitor.php b/backend/app/Services/Machine/RuntimeMonitor.php new file mode 100644 index 00000000..5d9c5c1f --- /dev/null +++ b/backend/app/Services/Machine/RuntimeMonitor.php @@ -0,0 +1,90 @@ +cacheKey($kind, $key), time(), self::TTL); + } + + public function lastSeen(string $kind, int|string $key): ?int + { + return Cache::get($this->cacheKey($kind, $key)); + } + + public function isAlive(string $kind, int|string $key): bool + { + $ts = $this->lastSeen($kind, $key); + + return $ts !== null && (time() - $ts) <= self::STALE_AFTER; + } + + public function secondsSince(string $kind, int|string $key): ?int + { + $ts = $this->lastSeen($kind, $key); + + return $ts === null ? null : max(0, time() - $ts); + } + + /** + * Runtime status for a machine connection, including a copy-paste command + * and the optional Docker service to start it. + * + * @return array{required: bool, alive: bool, seconds_ago: int|null, label: string, command: string|null, docker: string|null} + */ + public function connectionRuntime(MachineConnection $connection): array + { + $kind = $connection->protocol; + $id = $connection->id; + + $meta = match ($kind) { + MachineConnection::PROTOCOL_MODBUS => [ + 'label' => __('Modbus poller'), + 'command' => "php artisan modbus:poll --connection={$id}", + 'docker' => "MODBUS_CONNECTION_ID={$id} docker compose --profile connectivity up -d modbus-poller", + ], + MachineConnection::PROTOCOL_MQTT => [ + 'label' => __('MQTT listener'), + 'command' => "php artisan mqtt:listen --connection={$id}", + 'docker' => "MQTT_CONNECTION_ID={$id} docker compose --profile connectivity up -d mqtt-listener", + ], + MachineConnection::PROTOCOL_OPCUA => [ + 'label' => __('OPC UA gateway'), + 'command' => null, // external sidecar, not an artisan command + 'docker' => "OPCUA_CONNECTION_ID={$id} docker compose --profile connectivity up -d opcua-gateway", + ], + default => ['label' => __('Runtime'), 'command' => null, 'docker' => null], + }; + + return [ + 'required' => $connection->is_active, + 'alive' => $this->isAlive($kind, $id), + 'seconds_ago' => $this->secondsSince($kind, $id), + 'label' => $meta['label'], + 'command' => $meta['command'], + 'docker' => $meta['docker'], + ]; + } + + private function cacheKey(string $kind, int|string $key): string + { + return "runtime_hb:{$kind}:{$key}"; + } +} diff --git a/backend/app/Services/Machine/WorkstationStateMachine.php b/backend/app/Services/Machine/WorkstationStateMachine.php new file mode 100644 index 00000000..fc88547a --- /dev/null +++ b/backend/app/Services/Machine/WorkstationStateMachine.php @@ -0,0 +1,137 @@ +current($workstation); + + if ($current && $current->state === $newState) { + if ($metadata) { + $current->update(['metadata' => array_merge($current->metadata ?? [], $metadata)]); + } + + return $current; + } + + if ($current) { + $current->update([ + 'ended_at' => $at, + 'duration_seconds' => max(0, (int) $current->started_at->diffInSeconds($at)), + ]); + $this->closeDowntimeIfOpen($workstation, $at); + } + + $state = WorkstationState::create([ + 'workstation_id' => $workstation->id, + 'state' => $newState, + 'started_at' => $at, + 'source' => 'machine', + 'metadata' => $metadata ?: null, + ]); + + if (in_array($newState, WorkstationState::LOSS_STATES, true)) { + $this->openDowntime($workstation, $newState, $at); + } + + event(new \App\Events\Machine\WorkstationStateChanged( + $workstation, + $current?->state, + $newState, + $state + )); + + return $state; + }); + } + + public function current(Workstation $workstation): ?WorkstationState + { + return WorkstationState::where('workstation_id', $workstation->id) + ->whereNull('ended_at') + ->latest('started_at') + ->first(); + } + + private function openDowntime(Workstation $workstation, string $state, Carbon $at): void + { + // Avoid duplicate open downtime. + $alreadyOpen = ProductionDowntime::where('workstation_id', $workstation->id) + ->whereNull('ended_at') + ->exists(); + if ($alreadyOpen) { + return; + } + + $reason = $this->autoReasonFor($state); + + ProductionDowntime::create([ + 'line_id' => $workstation->line_id, + 'workstation_id' => $workstation->id, + 'downtime_reason_id' => $reason->id, + 'started_at' => $at, + 'notes' => __('Auto-recorded from machine state :state', ['state' => $state]), + ]); + } + + private function closeDowntimeIfOpen(Workstation $workstation, Carbon $at): void + { + $open = ProductionDowntime::where('workstation_id', $workstation->id) + ->whereNull('ended_at') + ->latest('started_at') + ->first(); + + if ($open) { + $open->update([ + 'ended_at' => $at, + 'duration_minutes' => (int) ceil($open->started_at->diffInSeconds($at) / 60), + ]); + } + } + + /** + * A FAULT is unplanned loss; a STOPPED machine is treated as unplanned too + * (operators can re-categorise later). Reasons are provisioned once. + */ + private function autoReasonFor(string $state): DowntimeReason + { + $map = [ + WorkstationState::FAULT => ['code' => 'AUTO-FAULT', 'name' => 'Machine fault (auto)', 'kind' => DowntimeKind::Unplanned->value], + WorkstationState::STOPPED => ['code' => 'AUTO-STOP', 'name' => 'Machine stopped (auto)', 'kind' => DowntimeKind::Unplanned->value], + ]; + $cfg = $map[$state] ?? $map[WorkstationState::STOPPED]; + + return DowntimeReason::firstOrCreate( + ['code' => $cfg['code']], + ['name' => $cfg['name'], 'kind' => $cfg['kind'], 'is_active' => true] + ); + } +} diff --git a/backend/app/Services/Traceability/SerialTraceService.php b/backend/app/Services/Traceability/SerialTraceService.php new file mode 100644 index 00000000..ff1db7d5 --- /dev/null +++ b/backend/app/Services/Traceability/SerialTraceService.php @@ -0,0 +1,77 @@ + $serialNo, 'tenant_id' => $attributes['tenant_id'] ?? null], + [ + 'work_order_id' => $attributes['work_order_id'] ?? null, + 'batch_id' => $attributes['batch_id'] ?? null, + 'material_id' => $attributes['material_id'] ?? null, + 'status' => $attributes['status'] ?? SerialUnit::STATUS_IN_PRODUCTION, + 'produced_at' => $attributes['produced_at'] ?? null, + 'extra_data' => $attributes['extra_data'] ?? null, + ] + ); + } + + /** + * Record a processing event for a unit at a workstation. The high-precision + * timestamp guarantees ordering even for sub-second consecutive steps. + */ + public function recordStep(SerialUnit $unit, User $operator, ?BatchStep $step, array $data = []): UnitStepHistory + { + return DB::transaction(function () use ($unit, $operator, $step, $data) { + $entry = $unit->history()->create([ + 'batch_step_id' => $step?->id, + 'workstation_id' => $data['workstation_id'] ?? $step?->workstation_id ?? $operator->workstation_id, + 'operator_id' => $operator->id, + 'parameters' => $data['parameters'] ?? null, + 'result' => $data['result'] ?? null, + 'notes' => $data['notes'] ?? null, + 'processed_at' => now()->format('Y-m-d H:i:s.u'), + ]); + + // Mark a failed unit as scrapped so it drops out of in-production counts. + if (($data['result'] ?? null) === 'fail') { + $unit->update(['status' => SerialUnit::STATUS_SCRAPPED]); + } + + return $entry; + }); + } + + /** + * Full chronological process history for a unit (the birth certificate). + */ + public function getHistory(SerialUnit $unit): SerialUnit + { + return $unit->load([ + 'workOrder:id,order_no,product_type_id', + 'workOrder.productType:id,name,code', + 'batch:id,batch_number,lot_number', + 'material:id,name,code', + 'history.workstation:id,name,code', + 'history.operator:id,name', + 'history.batchStep:id,name,step_number', + ]); + } +} diff --git a/backend/app/Services/Traceability/TraceabilityService.php b/backend/app/Services/Traceability/TraceabilityService.php new file mode 100644 index 00000000..5b9dd826 --- /dev/null +++ b/backend/app/Services/Traceability/TraceabilityService.php @@ -0,0 +1,183 @@ +consumptions() + ->with([ + 'batchStep:id,batch_id,name,step_number,status,workstation_id', + 'batchStep.workstation:id,name,code', + 'batchStep.batch:id,work_order_id,batch_number,lot_number', + 'batchStep.batch.workOrder:id,order_no,product_type_id,status', + 'batchStep.batch.workOrder.productType:id,name,code', + 'recordedBy:id,name', + ]) + ->orderByDesc('consumed_at') + ->get(); + + $workOrders = $consumptions + ->map(fn ($c) => $c->batchStep?->batch?->workOrder) + ->filter() + ->unique('id') + ->values(); + + return [ + 'lot' => $lot->only(['id', 'lot_number', 'material_id', 'status', 'supplier_lot_no']), + 'consumptions' => $consumptions, + 'work_orders' => $workOrders, + 'total_consumed' => (float) $consumptions->sum('quantity_consumed'), + ]; + } + + /** + * Backward trace from a material lot: the ingredient lots that fed into it. + * + * For an inbound raw lot this is terminal (supplier reference). For a + * batch-produced lot (source_batch_id set), it returns the lots consumed by + * that batch, recursing into each so the full ingredient tree is built. + */ + public function backwardTraceLot(MaterialLot $lot, int $depth = 0): array + { + $node = [ + 'lot' => $lot->only(['id', 'lot_number', 'material_id', 'status']), + 'material' => $lot->material?->only(['id', 'name', 'code']), + 'supplier_lot_no' => $lot->supplier_lot_no, + 'supplier_reference' => $lot->supplier_reference, + 'inspection_id' => $lot->inspection_id, + 'source_batch_id' => $lot->source_batch_id, + 'ingredients' => [], + 'truncated' => false, + ]; + + if ($depth >= self::MAX_DEPTH) { + $node['truncated'] = true; + + return $node; + } + + if ($lot->source_batch_id) { + $batch = Batch::find($lot->source_batch_id); + if ($batch) { + $node['source_batch'] = $batch->only(['id', 'batch_number', 'lot_number', 'work_order_id']); + $node['ingredients'] = $this->batchInputLots($batch) + ->map(fn (MaterialLot $ingredient) => $this->backwardTraceLot($ingredient, $depth + 1)) + ->values() + ->all(); + } + } + + return $node; + } + + /** + * Full genealogy for a finished batch: which lots were consumed at each + * step, by which operator, when — plus the batch's own output lots. + */ + public function batchGenealogy(Batch $batch): array + { + $batch->loadMissing([ + 'workOrder:id,order_no,product_type_id,status', + 'workOrder.productType:id,name,code', + 'steps:id,batch_id,name,step_number,status,workstation_id,started_by_id,completed_by_id,started_at,completed_at', + 'steps.workstation:id,name,code', + 'steps.completedBy:id,name', + 'outputLots:id,lot_number,material_id,source_batch_id,status', + 'outputLots.material:id,name,code', + ]); + + $consumptions = BatchStepLotConsumption::query() + ->whereHas('batchStep', fn ($q) => $q->where('batch_id', $batch->id)) + ->with([ + 'materialLot:id,lot_number,material_id,supplier_lot_no,source_batch_id,status', + 'materialLot.material:id,name,code', + 'batchStep:id,batch_id,name,step_number', + 'recordedBy:id,name', + ]) + ->orderBy('consumed_at') + ->get() + ->groupBy('batch_step_id'); + + return [ + 'batch' => $batch, + 'consumptions_by_step' => $consumptions, + 'distinct_input_lots' => $this->batchInputLots($batch), + ]; + } + + /** + * Distinct material lots consumed by any step of the given batch. + * + * @return \Illuminate\Support\Collection + */ + public function batchInputLots(Batch $batch): \Illuminate\Support\Collection + { + $lotIds = BatchStepLotConsumption::query() + ->whereHas('batchStep', fn ($q) => $q->where('batch_id', $batch->id)) + ->pluck('material_lot_id') + ->unique() + ->values(); + + if ($lotIds->isEmpty()) { + return collect(); + } + + return MaterialLot::with('material:id,name,code') + ->whereIn('id', $lotIds) + ->get(); + } + + /** + * Resolve a free-text search to a result: a finished batch (by lot_number) + * or a material lot (by lot_number / supplier_lot_no). + * + * @return array{type: string, model: mixed}|null + */ + public function resolve(string $term): ?array + { + $term = trim($term); + if ($term === '') { + return null; + } + + $batch = Batch::where('lot_number', $term)->first(); + if ($batch) { + return ['type' => 'batch', 'model' => $batch]; + } + + $lot = MaterialLot::where('lot_number', $term) + ->orWhere('supplier_lot_no', $term) + ->first(); + if ($lot) { + return ['type' => 'material_lot', 'model' => $lot]; + } + + return null; + } +} diff --git a/backend/app/Services/WorkOrder/BatchService.php b/backend/app/Services/WorkOrder/BatchService.php index 3a0bfdcf..cbfae2a6 100644 --- a/backend/app/Services/WorkOrder/BatchService.php +++ b/backend/app/Services/WorkOrder/BatchService.php @@ -23,6 +23,9 @@ public function __construct( public function startStep(BatchStep $step, User $user): BatchStep { return DB::transaction(function () use ($step, $user) { + // Enforce workstation routing (if enabled) + $this->guardWorkstationRouting($step, $user); + // Validate step can be started if (! $step->canStart()) { $this->throwValidationError($step); @@ -65,6 +68,9 @@ public function startStep(BatchStep $step, User $user): BatchStep public function completeStep(BatchStep $step, User $user, array $data = []): BatchStep { return DB::transaction(function () use ($step, $user, $data) { + // Enforce workstation routing (if enabled) + $this->guardWorkstationRouting($step, $user); + // Validate step can be completed if (! $step->canComplete()) { throw new \Exception('Step cannot be completed. Current status: '.$step->status); @@ -165,6 +171,46 @@ protected function completeBatch(Batch $batch, float $producedQty): void ]); } + /** + * Enforce workstation routing: when enabled, a workstation-bound operator + * may only start/complete steps assigned to their own workstation. + * + * Bypassed for Admins/Supervisors and for line-level operators (users with + * no workstation assigned). Steps without an assigned workstation are open + * to anyone. This is the single server-side chokepoint covering both the + * Livewire UI and the REST API, since both route through BatchService. + * + * @throws \Exception + */ + protected function guardWorkstationRouting(BatchStep $step, User $user): void + { + $enabled = json_decode( + DB::table('system_settings')->where('key', 'workstation_routing_enabled')->value('value') ?? 'false', + true + ) ?? false; + + if (! $enabled || ! $step->workstation_id) { + return; + } + + // Admins and Supervisors can operate any workstation. + if ($user->hasRole('Admin') || $user->hasRole('Supervisor')) { + return; + } + + // Line-level operators (no workstation assigned) are not restricted. + if (! $user->workstation_id) { + return; + } + + if ((int) $step->workstation_id !== (int) $user->workstation_id) { + $stationName = $step->workstation?->name ?? __('another workstation'); + throw new \Exception( + __('This step is assigned to :station and will appear in that workstation\'s queue.', ['station' => $stationName]) + ); + } + } + /** * Throw appropriate validation error based on step state. * diff --git a/backend/app/View/Components/MaintenanceReminder.php b/backend/app/View/Components/MaintenanceReminder.php new file mode 100644 index 00000000..f82a47dc --- /dev/null +++ b/backend/app/View/Components/MaintenanceReminder.php @@ -0,0 +1,35 @@ +upcoming = MaintenanceEvent::with(['line', 'workstation']) + ->whereIn('status', ['pending', 'in_progress']) + ->whereNotNull('scheduled_at') + ->where('scheduled_at', '>=', now()) + ->where('scheduled_at', '<=', now()->addHours(2)) + ->orderBy('scheduled_at') + ->get(); + } + + public function shouldRender(): bool + { + return $this->upcoming->isNotEmpty(); + } + + public function render(): View + { + return view('components.maintenance-reminder'); + } +} diff --git a/backend/composer.json b/backend/composer.json index b462ab54..8f946e93 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -7,6 +7,7 @@ "license": "AGPL-3.0-only", "require": { "php": "^8.2", + "aldas/modbus-tcp-client": "^3.6", "barryvdh/laravel-dompdf": "^3.1", "dedoc/scramble": "^0.12", "endroid/qr-code": "^5.0", @@ -18,6 +19,7 @@ "maatwebsite/excel": "^3.1", "php-mqtt/client": "^2.0", "picqer/php-barcode-generator": "^3.2", + "pragmarx/google2fa-laravel": "^3.0", "spatie/laravel-permission": "^7.1" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index bbbd8585..13bb660f 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,8 +4,57 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fae05c004f1ba6fcec31c563f253be3b", + "content-hash": "197a1a40a3e2632b12a3a099fe63f8e8", "packages": [ + { + "name": "aldas/modbus-tcp-client", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/aldas/modbus-tcp-client.git", + "reference": "57273edfdcdf7e868cd3d428fa6a8111240227d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aldas/modbus-tcp-client/zipball/57273edfdcdf7e868cd3d428fa6a8111240227d6", + "reference": "57273edfdcdf7e868cd3d428fa6a8111240227d6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.1.37", + "phpunit/phpunit": "^12.5.8", + "psr/log": "^1.1.4", + "react/child-process": "^0.6.7", + "react/datagram": "^1.10.0", + "react/socket": "^1.17" + }, + "suggest": { + "psr/log": "Required for using the Log middleware with BinaryStreamConnection" + }, + "type": "library", + "autoload": { + "psr-4": { + "ModbusTcpClient\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Modbus TCP protocol client library", + "keywords": [ + "modbus" + ], + "support": { + "issues": "https://github.com/aldas/modbus-tcp-client/issues", + "source": "https://github.com/aldas/modbus-tcp-client/tree/3.6.0" + }, + "time": "2026-01-28T15:15:27+00:00" + }, { "name": "bacon/bacon-qr-code", "version": "v3.1.1", @@ -3920,6 +3969,75 @@ ], "time": "2025-11-20T02:34:59+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, { "name": "php-mqtt/client", "version": "v2.3.2", @@ -4288,6 +4406,201 @@ }, "time": "2026-01-08T08:57:40+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v8.0.3", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + }, + "time": "2024-09-05T11:56:40+00:00" + }, + { + "name": "pragmarx/google2fa-laravel", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-laravel.git", + "reference": "d885bb5bca8be03b226d040aa80250402760a67c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-laravel/zipball/d885bb5bca8be03b226d040aa80250402760a67c", + "reference": "d885bb5bca8be03b226d040aa80250402760a67c", + "shasum": "" + }, + "require": { + "laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": ">=7.0", + "pragmarx/google2fa-qrcode": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "bacon/bacon-qr-code": "^2.0", + "orchestra/testbench": "3.4.*|3.5.*|3.6.*|3.7.*|4.*|5.*|6.*|7.*|8.*|9.*|10.*|11.*", + "phpunit/phpunit": "~5|~6|~7|~8|~9|~10|~11|~12" + }, + "suggest": { + "bacon/bacon-qr-code": "Required to generate inline QR Codes.", + "pragmarx/recovery": "Generate recovery codes." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Google2FA": "PragmaRX\\Google2FALaravel\\Facade" + }, + "providers": [ + "PragmaRX\\Google2FALaravel\\ServiceProvider" + ] + }, + "component": "package", + "frameworks": [ + "Laravel" + ], + "branch-alias": { + "dev-master": "0.2-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FALaravel\\": "src/", + "PragmaRX\\Google2FALaravel\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "Authentication", + "Two Factor Authentication", + "google2fa", + "laravel" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa-laravel/issues", + "source": "https://github.com/antonioribeiro/google2fa-laravel/tree/v3.0.1" + }, + "time": "2026-03-17T20:54:53+00:00" + }, + { + "name": "pragmarx/google2fa-qrcode", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa-qrcode.git", + "reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/c23ebcc3a50de0d1566016a6dd1486e183bb78e1", + "reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "pragmarx/google2fa": "^4.0|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "bacon/bacon-qr-code": "^2.0", + "chillerlan/php-qrcode": "^1.0|^2.0|^3.0|^4.0", + "khanamiryan/qrcode-detector-decoder": "^1.0", + "phpunit/phpunit": "~4|~5|~6|~7|~8|~9" + }, + "suggest": { + "bacon/bacon-qr-code": "For QR Code generation, requires imagick", + "chillerlan/php-qrcode": "For QR Code generation" + }, + "type": "library", + "extra": { + "component": "package", + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FAQRCode\\": "src/", + "PragmaRX\\Google2FAQRCode\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "QR Code package for Google2FA", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa", + "qr code", + "qrcode" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues", + "source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v3.0.1" + }, + "time": "2025-09-19T23:02:26+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -10383,12 +10696,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.2" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/backend/database/factories/SerialUnitFactory.php b/backend/database/factories/SerialUnitFactory.php new file mode 100644 index 00000000..0c6318df --- /dev/null +++ b/backend/database/factories/SerialUnitFactory.php @@ -0,0 +1,20 @@ + 'SN-' . fake()->unique()->numerify('########'), + 'status' => SerialUnit::STATUS_IN_PRODUCTION, + 'produced_at' => null, + ]; + } +} 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/database/migrations/2026_05_27_100000_add_scheduled_end_at_to_maintenance_events.php b/backend/database/migrations/2026_05_27_100000_add_scheduled_end_at_to_maintenance_events.php new file mode 100644 index 00000000..9f816f13 --- /dev/null +++ b/backend/database/migrations/2026_05_27_100000_add_scheduled_end_at_to_maintenance_events.php @@ -0,0 +1,21 @@ +timestamp('scheduled_end_at')->nullable()->after('scheduled_at'); + }); + } + + public function down(): void + { + Schema::table('maintenance_events', function (Blueprint $table) { + $table->dropColumn('scheduled_end_at'); + }); + } +}; diff --git a/backend/database/migrations/2026_05_28_152506_add_two_factor_columns_to_users_table.php b/backend/database/migrations/2026_05_28_152506_add_two_factor_columns_to_users_table.php new file mode 100644 index 00000000..7aafced6 --- /dev/null +++ b/backend/database/migrations/2026_05_28_152506_add_two_factor_columns_to_users_table.php @@ -0,0 +1,30 @@ +text('two_factor_secret')->nullable()->after('password'); + $table->boolean('two_factor_enabled')->default(false)->after('two_factor_secret'); + $table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_enabled'); + $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_confirmed_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_enabled', + 'two_factor_confirmed_at', + 'two_factor_recovery_codes', + ]); + }); + } +}; diff --git a/backend/database/migrations/2026_05_29_100001_add_source_batch_id_to_material_lots_table.php b/backend/database/migrations/2026_05_29_100001_add_source_batch_id_to_material_lots_table.php new file mode 100644 index 00000000..0cdd021d --- /dev/null +++ b/backend/database/migrations/2026_05_29_100001_add_source_batch_id_to_material_lots_table.php @@ -0,0 +1,32 @@ +foreignId('source_batch_id') + ->nullable() + ->after('inspection_id') + ->constrained('batches') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('material_lots', function (Blueprint $table) { + $table->dropConstrainedForeignId('source_batch_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_05_29_100002_create_serial_units_table.php b/backend/database/migrations/2026_05_29_100002_create_serial_units_table.php new file mode 100644 index 00000000..7f14fe21 --- /dev/null +++ b/backend/database/migrations/2026_05_29_100002_create_serial_units_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('serial_no', 100); + $table->foreignId('work_order_id')->nullable()->constrained('work_orders')->nullOnDelete(); + $table->foreignId('batch_id')->nullable()->constrained('batches')->nullOnDelete(); + $table->foreignId('material_id')->nullable()->constrained('materials')->nullOnDelete(); + $table->string('status', 20)->default('in_production'); // in_production / completed / scrapped / shipped + $table->timestamp('produced_at')->nullable(); + $table->foreignId('tenant_id')->nullable()->constrained('tenants')->cascadeOnDelete(); + $table->json('extra_data')->nullable(); + $table->timestamps(); + + $table->unique(['serial_no', 'tenant_id']); + $table->index(['status']); + $table->index(['work_order_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('serial_units'); + } +}; diff --git a/backend/database/migrations/2026_05_29_100003_create_unit_step_history_table.php b/backend/database/migrations/2026_05_29_100003_create_unit_step_history_table.php new file mode 100644 index 00000000..15d3e70e --- /dev/null +++ b/backend/database/migrations/2026_05_29_100003_create_unit_step_history_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('serial_unit_id')->constrained('serial_units')->cascadeOnDelete(); + $table->foreignId('batch_step_id')->nullable()->constrained('batch_steps')->nullOnDelete(); + $table->foreignId('workstation_id')->nullable()->constrained('workstations')->nullOnDelete(); + $table->foreignId('operator_id')->nullable()->constrained('users')->nullOnDelete(); + $table->json('parameters')->nullable(); // sensor / measurement snapshot at processing time + $table->string('result', 20)->nullable(); // pass / fail / rework + $table->string('notes', 500)->nullable(); + $table->timestamp('processed_at', 6); // microsecond precision for ordering within a station + $table->timestamps(); + + $table->index(['serial_unit_id', 'processed_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('unit_step_history'); + } +}; diff --git a/backend/database/migrations/2026_05_29_110001_create_workstation_states_table.php b/backend/database/migrations/2026_05_29_110001_create_workstation_states_table.php new file mode 100644 index 00000000..a3d0f9b8 --- /dev/null +++ b/backend/database/migrations/2026_05_29_110001_create_workstation_states_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('workstation_id')->constrained('workstations')->cascadeOnDelete(); + $table->string('state', 20); // RUNNING / IDLE / STOPPED / FAULT / SETUP + $table->timestamp('started_at'); + $table->timestamp('ended_at')->nullable(); + $table->unsignedBigInteger('duration_seconds')->nullable(); + $table->string('source', 20)->default('machine'); // machine / manual + $table->json('metadata')->nullable(); // telemetry snapshot at transition + $table->timestamps(); + + $table->index(['workstation_id', 'started_at']); + $table->index(['workstation_id', 'ended_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('workstation_states'); + } +}; diff --git a/backend/database/migrations/2026_05_29_110002_create_machine_events_table.php b/backend/database/migrations/2026_05_29_110002_create_machine_events_table.php new file mode 100644 index 00000000..dbef9670 --- /dev/null +++ b/backend/database/migrations/2026_05_29_110002_create_machine_events_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('workstation_id')->nullable()->constrained('workstations')->nullOnDelete(); + $table->foreignId('machine_connection_id')->nullable()->constrained('machine_connections')->nullOnDelete(); + $table->string('event_type', 40); // state_change / counter / alarm / telemetry + $table->string('state_from', 20)->nullable(); + $table->string('state_to', 20)->nullable(); + $table->json('payload')->nullable(); + $table->timestamp('event_timestamp', 6); + $table->uuid('correlation_id')->nullable(); + $table->boolean('synced_to_cloud')->default(false); + $table->timestamps(); + + $table->index(['workstation_id', 'event_timestamp']); + $table->index(['event_type']); + $table->index(['synced_to_cloud']); + }); + } + + public function down(): void + { + Schema::dropIfExists('machine_events'); + } +}; diff --git a/backend/database/migrations/2026_05_29_110003_create_machine_tags_table.php b/backend/database/migrations/2026_05_29_110003_create_machine_tags_table.php new file mode 100644 index 00000000..2665d764 --- /dev/null +++ b/backend/database/migrations/2026_05_29_110003_create_machine_tags_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('machine_connection_id')->constrained('machine_connections')->cascadeOnDelete(); + $table->foreignId('workstation_id')->nullable()->constrained('workstations')->nullOnDelete(); + $table->string('name', 100); + $table->string('address', 255); // 40001 / ns=2;s=State / $.sensor.value + $table->string('signal_type', 30); // state / good_count / reject_count / cycle_complete / telemetry / alarm + $table->string('data_type', 20)->default('int16'); // int16/uint16/int32/uint32/float32/bool/string + $table->string('register_type', 20)->nullable(); // holding/input/coil/discrete (Modbus) + $table->json('transform')->nullable(); // {scale, offset, value_map:{1:RUNNING,...}} + $table->string('unit', 20)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['machine_connection_id', 'is_active']); + $table->index(['workstation_id', 'signal_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('machine_tags'); + } +}; diff --git a/backend/database/migrations/2026_05_29_110004_create_modbus_connections_table.php b/backend/database/migrations/2026_05_29_110004_create_modbus_connections_table.php new file mode 100644 index 00000000..027fcca1 --- /dev/null +++ b/backend/database/migrations/2026_05_29_110004_create_modbus_connections_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('machine_connection_id')->constrained('machine_connections')->cascadeOnDelete(); + $table->string('host', 255); + $table->unsignedSmallInteger('port')->default(502); + $table->unsignedSmallInteger('unit_id')->default(1); // slave id + $table->unsignedInteger('poll_interval_ms')->default(1000); + $table->unsignedSmallInteger('timeout_seconds')->default(3); + $table->string('byte_order', 10)->default('big'); // big / little (endianness) + $table->string('word_order', 10)->default('big'); // big / little (for 32-bit across two registers) + $table->unsignedSmallInteger('max_registers_per_read')->default(120); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('modbus_connections'); + } +}; diff --git a/backend/database/migrations/2026_05_29_110005_create_opcua_connections_table.php b/backend/database/migrations/2026_05_29_110005_create_opcua_connections_table.php new file mode 100644 index 00000000..a245c8af --- /dev/null +++ b/backend/database/migrations/2026_05_29_110005_create_opcua_connections_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('machine_connection_id')->constrained('machine_connections')->cascadeOnDelete(); + $table->string('endpoint_url', 500); // opc.tcp://host:4840 + $table->string('security_policy', 50)->default('None'); // None / Basic256Sha256 + $table->string('security_mode', 30)->default('None'); // None / Sign / SignAndEncrypt + $table->string('auth_mode', 30)->default('anonymous'); // anonymous / username / certificate + $table->string('username', 100)->nullable(); + $table->text('password_encrypted')->nullable(); + $table->text('client_cert')->nullable(); + $table->text('client_key_encrypted')->nullable(); + $table->unsignedInteger('publishing_interval_ms')->default(1000); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('opcua_connections'); + } +}; diff --git a/backend/database/seeders/PrintShopDemoSeeder.php b/backend/database/seeders/PrintShopDemoSeeder.php index 4a25389a..b4a96815 100644 --- a/backend/database/seeders/PrintShopDemoSeeder.php +++ b/backend/database/seeders/PrintShopDemoSeeder.php @@ -798,7 +798,8 @@ private function seedMaintenanceSchedulesAndEvents(array $lines, array $workstat 'line_id' => $lines['DTG']->id, 'workstation_id' => $workstations['DTG-1']->id, 'schedule_id' => $schedules['dtg_cleaning']->id, - 'scheduled_at' => now()->subWeeks(2)->setTime(6, 0), + 'scheduled_at' => now()->subWeeks(2)->setTime(6, 0), + 'scheduled_end_at' => now()->subWeeks(2)->setTime(7, 0), 'description' => 'Routine printhead cleaning completed without issues.', ], [ @@ -808,7 +809,8 @@ private function seedMaintenanceSchedulesAndEvents(array $lines, array $workstat 'line_id' => $lines['HAFT']->id, 'workstation_id' => $workstations['HAFT-1']->id, 'schedule_id' => $schedules['embroidery_calibration']->id, - 'scheduled_at' => now()->subMonth()->setTime(7, 0), + 'scheduled_at' => now()->subMonth()->setTime(7, 0), + 'scheduled_end_at' => now()->subMonth()->setTime(9, 0), 'description' => 'Monthly calibration done. Tension adjusted on head 3.', ], [ @@ -818,8 +820,9 @@ private function seedMaintenanceSchedulesAndEvents(array $lines, array $workstat 'line_id' => $lines['SITO']->id, 'workstation_id' => $workstations['SITO-1']->id, 'schedule_id' => $schedules['screen_press']->id, - 'scheduled_at' => now()->addWeeks(2)->setTime(6, 30), - 'description' => 'Upcoming bi-weekly screen press inspection.', + 'scheduled_at' => now()->addDays(1)->setTime(6, 30), + 'scheduled_end_at' => now()->addDays(1)->setTime(8, 30), + 'description' => 'Bi-weekly screen press inspection.', ], [ 'title' => 'DTG Printhead Cleaning (overdue)', @@ -828,8 +831,9 @@ private function seedMaintenanceSchedulesAndEvents(array $lines, array $workstat 'line_id' => $lines['DTG']->id, 'workstation_id' => $workstations['DTG-1']->id, 'schedule_id' => $schedules['dtg_cleaning']->id, - 'scheduled_at' => now()->subWeek()->setTime(6, 0), - 'description' => 'Missed weekly printhead cleaning — needs immediate attention.', + 'scheduled_at' => now()->setTime(14, 0), + 'scheduled_end_at' => now()->setTime(15, 0), + 'description' => 'Weekly printhead cleaning — due today.', ], ]; diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 2fa270da..b4891391 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1124,7 +1124,6 @@ "View Details": "Szczegóły", "View Steps": "Zobacz kroki", "View Template": "Szablon widoku", - "View Templates": "Szablony widoku", "View all": "Zobacz wszystkie", "View issues": "Zobacz problemy", "View mode": "Tryb widoku", @@ -1285,44 +1284,148 @@ "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", - "← Packaging overview": "← Przegląd pakowania", - "Add EAN": "Dodaj EAN", + "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", + "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.", + "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).", + "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).", + "Maintenance Reminder": "Przypomnienie o konserwacji", + "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": "Spakowane", + "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", + "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.", + "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?", + "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", + "← Packaging overview": "← Przegląd pakowania", "Assign barcodes to production work orders": "Przypisuj kody kreskowe do zleceń produkcyjnych", "Current shift:": "Bieżąca zmiana:", "Delete EAN code :ean?": "Usunąć kod EAN :ean?", - "EAN Codes": "Kody EAN", "EAN Codes — Management": "Kody EAN — Zarządzanie", "EAN Code Management": "Zarządzanie kodami EAN", - "EAN code": "Kod EAN", - "Error": "Błąd", - "Last scan": "Ostatnie skanowanie", "Logged in:": "Zalogowany:", "Manage EAN": "Zarządzaj EAN", - "No EAN": "Brak EAN", - "No results": "Brak wyników", - "No scans in this shift": "Brak skanowań w tej zmianie", - "No work orders with assigned EAN codes": "Brak zleceń z przypisanymi kodami EAN", - "Open station": "Otwórz stanowisko", - "Packed": "Spakowane", - "Packed (shift)": "Spakowano (zmiana)", - "Packed / Plan": "Spakowano / Plan", - "Packaging": "Pakowanie", "Packaging — Overview": "Pakowanie — Przegląd", - "Packaging Station": "Stanowisko pakowania", - "Plan": "Plan", "Production work order": "Zlecenie produkcyjne", "Scan EAN code…": "Skanuj kod EAN…", "Scan error": "Błąd skanowania", - "Scan history (shift)": "Historia skanowań (zmiana)", - "Scanned!": "Zeskanowano!", "Scanning active": "Skanowanie aktywne", - "Search by order number…": "Szukaj po numerze zlecenia…", "Shift:": "Zmiana:", - "Total plan": "Plan łącznie", "Waiting for scan…": "Oczekiwanie na skanowanie…", - "Work orders to pack": "Zlecenia do spakowania", - "e.g. 5901234123457": "np. 5901234123457", - "pcs.": "szt.", "— select work order —": "— wybierz zlecenie —" } diff --git a/backend/lang/tr.json b/backend/lang/tr.json new file mode 100644 index 00000000..16f36e59 --- /dev/null +++ b/backend/lang/tr.json @@ -0,0 +1,1255 @@ +{ + "% of Total": "Toplamın %'si", + "(default)": "(varsayılan)", + "(leave blank to keep)": "(korumak için boş bırakın)", + "(optional — operator will skip line selection)": "(isteğe bağlı — operatör hat seçimini atlayacak)", + "+ Add": "+ Ekle", + "+ New Shift": "+ Yeni Vardiya", + "+ New Template": "+ Yeni Şablon", + "+ New Type": "+ Yeni Tip", + "3 hours": "3 saat", + "ALL PASS": "TÜMÜ GEÇTİ", + "API Tokens": "API Belirteçleri", + "Accept": "Onayla", + "Accepted": "Onaylandı", + "Account Information": "Hesap Bilgileri", + "Account Type": "Hesap Tipi", + "Acknowledge": "Bilgilendirildi", + "Action": "İşlem", + "Action Required:": "İşlem Gerekli:", + "Action params (JSON)": "İşlem parametreleri (JSON)", + "Action type": "İşlem tipi", + "Actions": "İşlemler", + "Activate": "Etkinleştir", + "Active": "Aktif", + "Active (line is ready for production)": "Aktif (hat üretime hazır)", + "Active (ready for production)": "Aktif (üretime hazır)", + "Active (start listening on daemon start)": "Aktif (daemon başlangıcında dinlemeye başla)", + "Active (template is ready for use in work orders)": "Aktif (şablon iş emirlerinde kullanıma hazır)", + "Active (workstation is ready for use)": "Aktif (iş istasyonu kullanıma hazır)", + "Active Lines": "Aktif Hatlar", + "Active Tokens": "Aktif Belirteçler", + "Active Types": "Aktif Tipler", + "Active Work Orders": "Aktif İş Emirleri", + "Actual Qty": "Gerçek Mik.", + "Actual Quantity": "Gerçek Miktar", + "Add": "Ekle", + "Add Company": "Şirket Ekle", + "Add Cost Source": "Maliyet Kaynağı Ekle", + "Add Crew": "Ekip Ekle", + "Add Division": "Bölüm Ekle", + "Add Factory": "Fabrika Ekle", + "Add First Material": "İlk Malzemeyi Ekle", + "Add First Sequence": "İlk Diziyi Ekle", + "Add First Step": "İlk Adımı Ekle", + "Add Integration": "Entegrasyon Ekle", + "Add LOT Sequence": "LOT Dizisi Ekle", + "Add Maintenance Event": "Bakım Etkinliği Ekle", + "Add Mapping": "Eşleme Ekle", + "Add Material": "Malzeme Ekle", + "Add Material to BOM": "Malzeme Listesine Malzeme Ekle", + "Add New Step": "Yeni Adım Ekle", + "Add Product Type": "Ürün Tipi Ekle", + "Add Production Line": "Üretim Hattı Ekle", + "Add Reason": "Neden Ekle", + "Add Sequence": "Dizi Ekle", + "Add Skill": "Beceri Ekle", + "Add Status": "Durum Ekle", + "Add Step": "Adım Ekle", + "Add Subassembly": "Alt Montaj Ekle", + "Add Tool": "Takım Ekle", + "Add Topic": "Konu Ekle", + "Add User": "Kullanıcı Ekle", + "Add Wage Group": "Ücret Grubu Ekle", + "Add Worker": "Çalışan Ekle", + "Add Workstation": "İş İstasyonu Ekle", + "Add Workstation Type": "İş İstasyonu Tipi Ekle", + "Add a Product Type": "Ürün Tipi Ekle", + "Add a supplier, customer, or both": "Tedarikçi, müşteri veya her ikisini ekle", + "Add mapping rule": "Eşleme kuralı ekle", + "Add skills": "Beceri ekle", + "Add steps to define the manufacturing process for this product.": "Bu ürün için üretim sürecini tanımlamak üzere adımlar ekleyin.", + "Add to BOM": "Malzeme Listesine Ekle", + "Add to this line": "Bu hatta ekle", + "Add topic": "Konu ekle", + "Additional Sources": "Ek Kaynaklar", + "Additional details about the anomaly...": "Anomali hakkında ek ayrıntılar...", + "Additional login methods for operators.": "Operatörler için ek giriş yöntemleri.", + "Address": "Adres", + "Admin": "Yönetici", + "Admin Dashboard": "Yönetici Paneli", + "Admins": "Yöneticiler", + "Advanced": "Gelişmiş", + "Alerts": "Uyarılar", + "All": "Tümü", + "All Actions": "Tüm İşlemler", + "All Lines": "Tüm Hatlar", + "All Types": "Tüm Tipler", + "All Users": "Tüm Kullanıcılar", + "All available operators are already assigned to this line.": "Mevcut tüm operatörler zaten bu hatta atanmış.", + "All clear": "Her şey yolunda", + "All lines": "Tüm hatlar", + "All rights reserved": "Tüm hakları saklıdır", + "All statuses": "Tüm durumlar", + "All steps / general": "Tüm adımlar / genel", + "All types": "Tüm tipler", + "All work orders": "Tüm iş emirleri", + "Allow overproduction": "Fazla üretime izin ver", + "Already have an account?": "Zaten bir hesabınız var mı?", + "Anomalies": "Anomaliler", + "Anomaly Reason": "Anomali Nedeni", + "Anomaly Reasons": "Anomali Nedenleri", + "Any extra field — stored as JSON on the work order": "Herhangi bir ek alan — iş emrinde JSON olarak saklanır", + "Apply": "Uygula", + "Apply Filters": "Filtreleri Uygula", + "Are you sure you want to delete this product type?": "Bu ürün tipini silmek istediğinizden emin misiniz?", + "Are you sure you want to delete this production line?": "Bu üretim hattını silmek istediğinizden emin misiniz?", + "Are you sure you want to delete this template?": "Bu şablonu silmek istediğinizden emin misiniz?", + "Are you sure you want to delete this user?": "Bu kullanıcıyı silmek istediğinizden emin misiniz?", + "Are you sure you want to delete this workstation?": "Bu iş istasyonunu silmek istediğinizden emin misiniz?", + "Assign": "Ata", + "Assign New Operator": "Yeni Operatör Ata", + "Assign all rows to Production Line (optional)": "Tüm satırları Üretim Hattına ata (isteğe bağlı)", + "Assign each CSV column to a system field or a custom key.": "Her CSV sütununu bir sistem alanına veya özel bir anahtara atayın.", + "Assign each column to a material field.": "Her sütunu bir malzeme alanına atayın.", + "Assign order to shift": "Siparişi vardiyaya ata", + "Assign to available line with free capacity": "Boş kapasitesi olan uygun hatta ata", + "Assigned Operators": "Atanan Operatörler", + "Assigned Product Types": "Atanan Ürün Tipleri", + "Assigned To": "Atanan", + "Assigned Workers": "Atanan Çalışanlar", + "Audit Logs": "Denetim Günlükleri", + "Authentication": "Kimlik Doğrulama", + "Auto-detect": "Otomatik algıla", + "Auto-generate LOT number": "LOT numarasını otomatik oluştur", + "Auto-generated if empty": "Boşsa otomatik oluşturulur", + "Auto-scroll": "Otomatik kaydırma", + "Auxiliary Material": "Yardımcı Malzeme", + "Availability": "Kullanılabilirlik", + "Available": "Mevcut", + "Available hooks and events": "Mevcut kancalar ve etkinlikler", + "Available keys:": "Mevcut anahtarlar:", + "Available system fields reference": "Mevcut sistem alanları referansı", + "Average": "Ortalama", + "Avg Cycle Time": "Ort. Çevrim Süresi", + "Awaiting Release": "Serbest Bırakılmayı Bekliyor", + "BLOCKING": "ENGELLEYİCİ", + "BOM": "Malzeme Listesi", + "Back": "Geri", + "Back to": "Geri dön:", + "Back to MQTT Connections": "MQTT Bağlantılarına Dön", + "Back to Materials": "Malzemelere Dön", + "Back to OEE": "OEE'ye Dön", + "Back to Production Lines": "Üretim Hatlarına Dön", + "Back to Queue": "Kuyruğa Dön", + "Back to Template": "Şablona Dön", + "Back to Templates": "Şablonlara Dön", + "Back to Users": "Kullanıcılara Dön", + "Back to Workstations": "İş İstasyonlarına Dön", + "Backlog": "Birikmiş İşler", + "Base Hourly Rate": "Temel Saatlik Ücret", + "Basic": "Temel", + "Basic Information": "Temel Bilgiler", + "Batch": "Parti", + "Batch #": "Parti #", + "Batch (LOT)": "Parti (LOT)", + "Batches": "Partiler", + "Batches:": "Partiler:", + "Bill of Materials": "Malzeme Listesi", + "Blocked": "Engellendi", + "Blocked Orders": "Engellenen Siparişler", + "Blocked Work Orders": "Engellenen İş Emirleri", + "Blocked since": "Engellenme tarihi", + "Blocking": "Engelleyici", + "Blocking Issues": "Engelleyici Sorunlar", + "Blocking issues will halt production and change work order status to BLOCKED": "Engelleyici sorunlar üretimi durdurur ve iş emri durumunu ENGELLENDİ olarak değiştirir", + "Blocking only": "Yalnızca engelleyici", + "Board Status": "Pano Durumu", + "Both": "Her ikisi", + "Broker": "Broker", + "Browse and install ready-made OpenMES modules": "Hazır OpenMES modüllerine göz atın ve kurun", + "Built-in": "Yerleşik", + "Built-in work order fields": "Yerleşik iş emri alanları", + "CA Certificate (PEM)": "CA Sertifikası (PEM)", + "CSV (comma or semicolon separated)": "CSV (virgül veya noktalı virgülle ayrılmış)", + "CSV Import": "CSV İçe Aktar", + "CSV column": "CSV sütunu", + "Calibration": "Kalibrasyon", + "Cancel": "İptal Et", + "Cancel Event": "Etkinliği İptal Et", + "Cancel this event?": "Bu etkinlik iptal edilsin mi?", + "Cancel this work order?": "Bu iş emri iptal edilsin mi?", + "Cancelled": "İptal Edildi", + "Cannot delete - has steps": "Silinemiyor - adımları var", + "Cannot delete - has template steps": "Silinemiyor - şablon adımları var", + "Cannot delete - has templates or work orders": "Silinemiyor - şablonları veya iş emirleri var", + "Cannot delete - has work orders": "Silinemiyor - iş emirleri var", + "Cannot delete yourself": "Kendinizi silemezsiniz", + "Category": "Kategori", + "Change Line": "Hattı Değiştir", + "Change PIN": "PIN Değiştir", + "Change Password": "Şifre Değiştir", + "Change assignment": "Atamayı değiştir", + "Change your account password": "Hesap şifrenizi değiştirin", + "Changes": "Değişiklikler", + "Changing...": "Değiştiriliyor...", + "Checked by": "Kontrol eden", + "Checklist": "Kontrol Listesi", + "Choose a production line to begin work": "Çalışmaya başlamak için bir üretim hattı seçin", + "Choose which view operators see by default when they select this line.": "Operatörlerin bu hattı seçtiklerinde varsayılan olarak hangi görünümü göreceğini seçin.", + "Clean session (recommended for stateless connections)": "Temiz oturum (durumsuz bağlantılar için önerilir)", + "Clear": "Temizle", + "Clear Filters": "Filtreleri Temizle", + "Clear all": "Tümünü temizle", + "Clear filters": "Filtreleri temizle", + "Click to assign order": "Sipariş atamak için tıklayın", + "Click to select a .zip file": "Bir .zip dosyası seçmek için tıklayın", + "Client ID": "İstemci Kimliği", + "Close": "Kapat", + "Code": "Kod", + "Code in external system": "Harici sistemdeki kod", + "Code, name or external code...": "Kod, ad veya harici kod...", + "Collapse": "Daralt", + "Color": "Renk", + "Column Label": "Sütun Etiketi", + "Column Mapping": "Sütun Eşleme", + "Columns": "Sütunlar", + "Columns define which data the operator sees in the Workstation production table. Each column has a": "Sütunlar, operatörün İş İstasyonu üretim tablosunda hangi verileri göreceğini tanımlar. Her sütunun bir", + "Columns with source": "Kaynaklı sütunlar", + "Coming Soon": "Çok Yakında", + "Coming soon": "Çok yakında", + "Comment": "Yorum", + "Companies": "Şirketler", + "Complete": "Tamamla", + "Complete Work Order": "İş Emrini Tamamla", + "Completed": "Tamamlandı", + "Completed By": "Tamamlayan", + "Completed Work Orders": "Tamamlanan İş Emirleri", + "Completed with errors": "Hatalarla tamamlandı", + "Completed:": "Tamamlandı:", + "Completion": "Tamamlanma", + "Completion %": "Tamamlanma %", + "Completion Rate": "Tamamlanma Oranı", + "Condition": "Koşul", + "Configure how the production schedule planner displays data.": "Üretim çizelge planlayıcısının verileri nasıl gösterdiğini yapılandırın.", + "Configure the types of issues operators can report": "Operatörlerin bildirebileceği sorun tiplerini yapılandırın", + "Configure which columns operators see in the Workstation view for this line.": "Bu hat için operatörlerin İş İstasyonu görünümünde hangi sütunları göreceğini yapılandırın.", + "Confirm New Password": "Yeni Şifreyi Onayla", + "Confirm PIN": "PIN Onayla", + "Confirm Parameters": "Parametreleri Onayla", + "Confirm Password": "Şifreyi Onayla", + "Confirm your password": "Şifrenizi onaylayın", + "Confirmed By": "Onaylayan", + "Connect timeout (seconds)": "Bağlantı zaman aşımı (saniye)", + "Connection error": "Bağlantı hatası", + "Connectivity": "Bağlantı", + "Consumed": "Tüketildi", + "Consumed At": "Tüketilme Zamanı", + "Corrective": "Düzeltici", + "Cost Source": "Maliyet Kaynağı", + "Cost Sources": "Maliyet Kaynakları", + "Count": "Sayı", + "Create": "Oluştur", + "Create & Update": "Oluştur ve Güncelle", + "Create Account": "Hesap Oluştur", + "Create Batch": "Parti Oluştur", + "Create Company": "Şirket Oluştur", + "Create Connection": "Bağlantı Oluştur", + "Create Cost Source": "Maliyet Kaynağı Oluştur", + "Create Crew": "Ekip Oluştur", + "Create Division": "Bölüm Oluştur", + "Create Factory": "Fabrika Oluştur", + "Create First Shift": "İlk Vardiyayı Oluştur", + "Create First Work Order": "İlk İş Emrini Oluştur", + "Create Issue Type": "Sorun Tipi Oluştur", + "Create Line": "Hat Oluştur", + "Create Material": "Malzeme Oluştur", + "Create New Batch": "Yeni Parti Oluştur", + "Create New User": "Yeni Kullanıcı Oluştur", + "Create Only": "Yalnızca Oluştur", + "Create Process Template": "Süreç Şablonu Oluştur", + "Create Product Type": "Ürün Tipi Oluştur", + "Create Production Line": "Üretim Hattı Oluştur", + "Create Reason": "Neden Oluştur", + "Create Sequence": "Dizi Oluştur", + "Create Shift": "Vardiya Oluştur", + "Create Skill": "Beceri Oluştur", + "Create Subassembly": "Alt Montaj Oluştur", + "Create Template": "Şablon Oluştur", + "Create Tool": "Takım Oluştur", + "Create User": "Kullanıcı Oluştur", + "Create View Template": "Görünüm Şablonu Oluştur", + "Create Wage Group": "Ücret Grubu Oluştur", + "Create Work Order": "İş Emri Oluştur", + "Create Worker": "Çalışan Oluştur", + "Create Workstation": "İş İstasyonu Oluştur", + "Create Workstation Type": "İş İstasyonu Tipi Oluştur", + "Create a Production Line": "Üretim Hattı Oluştur", + "Create a division within a factory": "Bir fabrika içinde bölüm oluştur", + "Create a factory or production site": "Bir fabrika veya üretim sahası oluştur", + "Create a production crew": "Bir üretim ekibi oluştur", + "Create a template to define how this product is manufactured.": "Bu ürünün nasıl üretildiğini tanımlamak için bir şablon oluşturun.", + "Create a template to define which columns operators see in the workstation view.": "Operatörlerin iş istasyonu görünümünde hangi sütunları göreceğini tanımlamak için bir şablon oluşturun.", + "Create a work order manually": "Manuel olarak iş emri oluştur", + "Create account": "Hesap oluştur", + "Create new & update existing": "Yeni oluştur ve mevcudu güncelle", + "Create new only (skip existing)": "Yalnızca yeni oluştur (mevcudu atla)", + "Create one": "Bir tane oluştur", + "Create worker profile": "Çalışan profili oluştur", + "Create your first MQTT connection": "İlk MQTT bağlantınızı oluşturun", + "Created": "Oluşturuldu", + "Created by": "Oluşturan", + "Creating account...": "Hesap oluşturuluyor...", + "Crew": "Ekip", + "Crews": "Ekipler", + "Currency": "Para Birimi", + "Current Password": "Mevcut Şifre", + "Current production qty": "Mevcut üretim miktarı", + "Current shift": "Mevcut vardiya", + "Currently open issues and problems": "Şu anda açık sorunlar ve problemler", + "Custom Field": "Özel Alan", + "Custom key…": "Özel anahtar…", + "Customer": "Müşteri", + "Cycle Time": "Çevrim Süresi", + "Daily": "Günlük", + "Daily Production (Last 30 Days)": "Günlük Üretim (Son 30 Gün)", + "Daily Records": "Günlük Kayıtlar", + "Dashboard": "Panel", + "Dashboard Setup": "Panel Kurulumu", + "Data Key": "Veri Anahtarı", + "Data Preview": "Veri Önizlemesi", + "Date": "Tarih", + "Date & Time": "Tarih ve Saat", + "Deactivate": "Devre Dışı Bırak", + "Default": "Varsayılan", + "Default (all product types)": "Varsayılan (tüm ürün tipleri)", + "Default (all)": "Varsayılan (tümü)", + "Default (no custom columns)": "Varsayılan (özel sütun yok)", + "Default Operator View": "Varsayılan Operatör Görünümü", + "Default Scrap %": "Varsayılan Fire %", + "Default View": "Varsayılan Görünüm", + "Default Workstation": "Varsayılan İş İstasyonu", + "Default time scale for the schedule view.": "Çizelge görünümü için varsayılan zaman ölçeği.", + "Define Process Template": "Süreç Şablonu Tanımla", + "Define a category of workstations": "Bir iş istasyonu kategorisi tanımlayın", + "Define a component or subassembly": "Bir bileşen veya alt montaj tanımlayın", + "Define a cost item for maintenance and operations": "Bakım ve operasyonlar için bir maliyet kalemi tanımlayın", + "Define a pay grade for workers": "Çalışanlar için bir ücret kademesi tanımlayın", + "Define a reason for production anomalies": "Üretim anomalileri için bir neden tanımlayın", + "Define a worker skill or competency": "Bir çalışan becerisi veya yetkinliği tanımlayın", + "Define and manage MQTT broker connections and topic subscriptions.": "MQTT broker bağlantılarını ve konu aboneliklerini tanımlayın ve yönetin.", + "Define column layouts for the operator workstation view": "Operatör iş istasyonu görünümü için sütun düzenlerini tanımlayın", + "Define production shifts. These appear as columns in the Workstation view.": "Üretim vardiyalarını tanımlayın. Bunlar İş İstasyonu görünümünde sütun olarak görünür.", + "Defines how work order completion is tracked.": "İş emri tamamlanmasının nasıl izlendiğini tanımlar.", + "Defines the sequence of production steps for a given product type. Each template is a recipe for how to manufacture a product — step by step. One product type can have multiple templates (e.g. different process versions).": "Belirli bir ürün tipi için üretim adımlarının sırasını tanımlar. Her şablon, bir ürünün nasıl üretileceğine dair adım adım bir reçetedir. Bir ürün tipinin birden fazla şablonu olabilir (örn. farklı süreç sürümleri).", + "Delete": "Sil", + "Delete Connection": "Bağlantıyı Sil", + "Delete mapping profile?": "Eşleme profili silinsin mi?", + "Delete status": "Durumu sil", + "Delete this LOT sequence?": "Bu LOT dizisi silinsin mi?", + "Delete this anomaly record?": "Bu anomali kaydı silinsin mi?", + "Delete this company?": "Bu şirket silinsin mi?", + "Delete this connection and all its topics?": "Bu bağlantı ve tüm konuları silinsin mi?", + "Delete this connection and all topics?": "Bu bağlantı ve tüm konular silinsin mi?", + "Delete this cost source?": "Bu maliyet kaynağı silinsin mi?", + "Delete this crew?": "Bu ekip silinsin mi?", + "Delete this division?": "Bu bölüm silinsin mi?", + "Delete this factory?": "Bu fabrika silinsin mi?", + "Delete this mapping?": "Bu eşleme silinsin mi?", + "Delete this reason?": "Bu neden silinsin mi?", + "Delete this skill?": "Bu beceri silinsin mi?", + "Delete this step?": "Bu adım silinsin mi?", + "Delete this subassembly?": "Bu alt montaj silinsin mi?", + "Delete this tool?": "Bu takım silinsin mi?", + "Delete this topic and all its mappings?": "Bu konu ve tüm eşlemeleri silinsin mi?", + "Delete this wage group?": "Bu ücret grubu silinsin mi?", + "Delete this worker?": "Bu çalışan silinsin mi?", + "Delete this workstation type?": "Bu iş istasyonu tipi silinsin mi?", + "Deleted": "Silindi", + "Description": "Açıklama", + "Description (optional)": "Açıklama (isteğe bağlı)", + "Descriptive name for this manufacturing process": "Bu üretim süreci için açıklayıcı ad", + "Details": "Ayrıntılar", + "Determines how work orders are grouped for planning.": "İş emirlerinin planlama için nasıl gruplandığını belirler.", + "Deviation": "Sapma", + "Dimension": "Boyut", + "Disable": "Devre Dışı Bırak", + "Disabled": "Devre Dışı", + "Dismissed": "Reddedildi", + "Display Name": "Görünen Ad", + "Display Saturday and Sunday columns in the schedule view.": "Çizelge görünümünde Cumartesi ve Pazar sütunlarını göster.", + "Division": "Bölüm", + "Divisions": "Bölümler", + "Done": "Tamamlandı", + "Done Today": "Bugün Tamamlanan", + "Download PDF": "PDF İndir", + "Downtime": "Duruş", + "Downtime by Reason": "Nedene Göre Duruş", + "Drag to reorder": "Yeniden sıralamak için sürükleyin", + "Drag to schedule": "Çizelgelemek için sürükleyin", + "Drop file here or": "Dosyayı buraya bırakın veya", + "Due": "Termin", + "Due Date": "Teslim Tarihi", + "Due date": "Termin tarihi", + "Duplicate Strategy": "Yinelenen Stratejisi", + "Duration": "Süre", + "During step": "Adım sırasında", + "EAN / Barcode": "EAN / Barkod", + "Edit": "Düzenle", + "Edit Anomaly Reason": "Anomali Nedenini Düzenle", + "Edit Company": "Şirketi Düzenle", + "Edit Cost Source": "Maliyet Kaynağını Düzenle", + "Edit Crew": "Ekibi Düzenle", + "Edit Division": "Bölümü Düzenle", + "Edit Event": "Etkinliği Düzenle", + "Edit Factory": "Fabrikayı Düzenle", + "Edit Integration": "Entegrasyonu Düzenle", + "Edit Issue Type": "Sorun Tipini Düzenle", + "Edit LOT Sequence": "LOT Dizisini Düzenle", + "Edit Line": "Hattı Düzenle", + "Edit MQTT Connection": "MQTT Bağlantısını Düzenle", + "Edit Maintenance Event": "Bakım Etkinliğini Düzenle", + "Edit Material": "Malzemeyi Düzenle", + "Edit Process Template": "Süreç Şablonunu Düzenle", + "Edit Product Type": "Ürün Tipini Düzenle", + "Edit Production Line": "Üretim Hattını Düzenle", + "Edit Shift": "Vardiyayı Düzenle", + "Edit Skill": "Beceriyi Düzenle", + "Edit Subassembly": "Alt Montajı Düzenle", + "Edit Template": "Şablonu Düzenle", + "Edit Tool": "Takımı Düzenle", + "Edit User": "Kullanıcıyı Düzenle", + "Edit View Template": "Görünüm Şablonunu Düzenle", + "Edit Wage Group": "Ücret Grubunu Düzenle", + "Edit Work Order": "İş Emrini Düzenle", + "Edit Worker": "Çalışanı Düzenle", + "Edit Workstation": "İş İstasyonunu Düzenle", + "Edit Workstation Type": "İş İstasyonu Tipini Düzenle", + "Edit:": "Düzenle:", + "Email": "E-posta", + "Enable": "Etkinleştir", + "Enable PIN login": "PIN ile girişi etkinleştir", + "Enable TLS (port 8883)": "TLS'yi etkinleştir (port 8883)", + "Enable and disable installed OpenMES extensions": "Kurulu OpenMES uzantılarını etkinleştirin ve devre dışı bırakın", + "Enable, disable, and reorder dashboard widgets": "Panel bileşenlerini etkinleştirin, devre dışı bırakın ve yeniden sıralayın", + "Enabled": "Etkin", + "End Date": "Bitiş Tarihi", + "End Time": "Bitiş Saati", + "End of step": "Adım sonu", + "Enter the produced quantity for": "Üretilen miktarı girin:", + "Enter your 4–6 digit PIN": "4–6 haneli PIN'inizi girin", + "Enter your current account password and choose a 4–6 digit numeric PIN.": "Mevcut hesap şifrenizi girin ve 4–6 haneli sayısal bir PIN seçin.", + "Entity": "Varlık", + "Entity Type": "Varlık Tipi", + "Error on duplicates": "Yinelenenlerde hata ver", + "Error saving": "Kaydetme hatası", + "Estimated Duration (minutes)": "Tahmini Süre (dakika)", + "Event Type": "Etkinlik Tipi", + "Examples:": "Örnekler:", + "Existing materials are matched by:": "Mevcut malzemeler şuna göre eşleştirilir:", + "Expert": "Uzman", + "Expiry": "Son Kullanma", + "Expiry Date": "Son Kullanma Tarihi", + "Export to CSV": "CSV'ye Aktar", + "External": "Harici", + "External Code": "Harici Kod", + "External Code + Source System": "Harici Kod + Kaynak Sistem", + "External Integrations": "Harici Entegrasyonlar", + "External System": "Harici Sistem", + "Extra Data": "Ek Veri", + "FAIL": "BAŞARISIZ", + "FAILED": "BAŞARISIZ", + "Factories": "Fabrikalar", + "Factory": "Fabrika", + "Fail": "Başarısız", + "Field path": "Alan yolu", + "File (CSV, XLS, XLSX)": "Dosya (CSV, XLS, XLSX)", + "Filter": "Filtrele", + "Filters": "Filtreler", + "Fit check": "Uygunluk kontrolü", + "Flat production table with quantities, Z1/Z2 shift inputs and inline entry.": "Miktarlar, Z1/Z2 vardiya girişleri ve satır içi giriş içeren düz üretim tablosu.", + "For Production": "Üretim İçin", + "For Sale": "Satış İçin", + "Force password change on first login": "İlk girişte şifre değişikliğini zorunlu kıl", + "Force password change on next login": "Sonraki girişte şifre değişikliğini zorunlu kıl", + "Force sequential steps": "Adımları sıralı tamamlamaya zorla", + "Format": "Biçim", + "Free": "Boş", + "From": "Başlangıç", + "Full Name": "Tam Ad", + "Full report": "Tam rapor", + "General": "Genel", + "General Information": "Genel Bilgiler", + "Generate New Token": "Yeni Belirteç Oluştur", + "Generate Token": "Belirteç Oluştur", + "Generated": "Oluşturuldu", + "Get started by adding your first user.": "İlk kullanıcınızı ekleyerek başlayın.", + "Get started by creating your first product type.": "İlk ürün tipinizi oluşturarak başlayın.", + "Get started by creating your first production line.": "İlk üretim hattınızı oluşturarak başlayın.", + "Get started by creating your first workstation for this line.": "Bu hat için ilk iş istasyonunuzu oluşturarak başlayın.", + "Global Statuses": "Genel Durumlar", + "Global application configuration": "Genel uygulama yapılandırması", + "Global kanban statuses available on all production lines. You can also add line-specific statuses from the line detail page.": "Tüm üretim hatlarında kullanılabilen genel kanban durumları. Hat detay sayfasından hatta özel durumlar da ekleyebilirsiniz.", + "Group by ISO week (1-53)": "ISO haftasına göre grupla (1-53)", + "Group by month (1-12)": "Aya göre grupla (1-12)", + "HR": "İnsan Kaynakları", + "Hide": "Gizle", + "Hide Filters": "Filtreleri Gizle", + "Hide help": "Yardımı gizle", + "Hide selector": "Seçiciyi gizle", + "High": "Yüksek", + "Host": "Sunucu", + "How do columns work?": "Sütunlar nasıl çalışır?", + "How many weeks ahead the planner displays.": "Planlayıcının kaç hafta ileriyi göstereceği.", + "How this product is counted or measured": "Bu ürünün nasıl sayıldığı veya ölçüldüğü", + "I agree to receive product updates and marketing communications via email.": "Ürün güncellemelerini ve pazarlama iletişimlerini e-posta ile almayı kabul ediyorum.", + "I understand this will add demo data to the system": "Bunun sisteme demo verisi ekleyeceğini anlıyorum", + "I'll do it later": "Daha sonra yapacağım", + "If selected, every imported work order will be assigned to this line, overriding any line_code column in the file.": "Seçilirse, içe aktarılan her iş emri bu hatta atanır ve dosyadaki line_code sütunu geçersiz kılınır.", + "Ignore this column": "Bu sütunu yoksay", + "Import": "İçe Aktar", + "Import CSV": "CSV İçe Aktar", + "Import Completed": "İçe Aktarma Tamamlandı", + "Import Completed with errors": "İçe Aktarma Hatalarla Tamamlandı", + "Import Materials": "Malzemeleri İçe Aktar", + "Import Strategy": "İçe Aktarma Stratejisi", + "Import Summary": "İçe Aktarma Özeti", + "Import materials from CSV, XLS or XLSX file (e.g. Subiekt GT export)": "CSV, XLS veya XLSX dosyasından malzeme içe aktar (örn. Subiekt GT dışa aktarımı)", + "Import work orders from a CSV, XLS or XLSX file with custom column mapping": "Özel sütun eşlemesiyle bir CSV, XLS veya XLSX dosyasından iş emirlerini içe aktarın", + "Imported CSV data": "İçe aktarılan CSV verisi", + "In Progress": "Devam Ediyor", + "In Use": "Kullanımda", + "In the meantime, you can install modules manually:": "Bu arada modülleri manuel olarak kurabilirsiniz:", + "Inactive": "Pasif", + "Include year in LOT (e.g. FLT-2026-0001)": "LOT'a yılı dahil et (örn. FLT-2026-0001)", + "Inspection": "Muayene", + "Install": "Kur", + "Install Module": "Modül Kur", + "Install from ZIP file": "ZIP dosyasından kur", + "Installed": "Kuruldu", + "Installed Modules": "Kurulu Modüller", + "Instructions": "Talimatlar", + "Integration Configs": "Entegrasyon Yapılandırmaları", + "Integrations": "Entegrasyonlar", + "Intermediate": "Orta Düzey", + "Internal Code": "Dahili Kod", + "Issue Type": "Sorun Tipi", + "Issue Types": "Sorun Tipleri", + "Issues": "Sorunlar", + "Issues by Type (Last 30 Days)": "Tipe Göre Sorunlar (Son 30 Gün)", + "KPI Cards": "KPI Kartları", + "Kanban statuses available for work orders on this line. Global statuses are shown in gray.": "Bu hattaki iş emirleri için kullanılabilen kanban durumları. Genel durumlar gri gösterilir.", + "Keep-alive (seconds)": "Canlı tutma (saniye)", + "Key": "Anahtar", + "LOT": "LOT", + "LOT Number": "LOT Numarası", + "LOT Number (manual)": "LOT Numarası (manuel)", + "LOT Sequences": "LOT Dizileri", + "Label": "Etiket", + "Label matches product": "Etiket ürünle eşleşiyor", + "Labels readable": "Etiketler okunabilir", + "Language": "Dil", + "Last": "Son", + "Last connected": "Son bağlantı", + "Last used": "Son kullanım", + "Latest work orders with status and progress": "Durum ve ilerleme ile en son iş emirleri", + "Leader": "Lider", + "Leave all unchecked to allow all product types on this line.": "Bu hatta tüm ürün tiplerine izin vermek için tümünü işaretsiz bırakın.", + "Leave blank to keep current password": "Mevcut şifreyi korumak için boş bırakın", + "Leave empty for no LOT": "LOT istemiyorsanız boş bırakın", + "Line": "Hat", + "Line Code": "Hat Kodu", + "Line Name": "Hat Adı", + "Line Statuses": "Hat Durumları", + "Lines": "Hatlar", + "Linked to worker:": "Çalışana bağlı:", + "List of operations performed during production — ordered top to bottom (step 1 = first to execute). Use drag handle or arrows to reorder. New steps are always added at the end. Estimated time is used to calculate operator efficiency.": "Üretim sırasında gerçekleştirilen operasyonların listesi — yukarıdan aşağıya sıralı (adım 1 = ilk yürütülen). Yeniden sıralamak için sürükleme tutamacını veya okları kullanın. Yeni adımlar her zaman sona eklenir. Tahmini süre, operatör verimliliğini hesaplamak için kullanılır.", + "Live (polling)": "Canlı (yoklama)", + "Live Message Log": "Canlı Mesaj Günlüğü", + "Load": "Yükle", + "Load Mapping Profile (optional)": "Eşleme Profili Yükle (isteğe bağlı)", + "Load Sample Data": "Örnek Veri Yükle", + "Load Saved Profile": "Kayıtlı Profili Yükle", + "Load a pre-built demo dataset: lines, workstations, products, templates and work orders. Safe to run multiple times.": "Önceden hazırlanmış demo veri kümesini yükle: hatlar, iş istasyonları, ürünler, şablonlar ve iş emirleri. Birden çok kez çalıştırmak güvenlidir.", + "Log a deviation from the production plan": "Üretim planından bir sapmayı kaydet", + "Login": "Giriş", + "Login Account": "Giriş Hesabı", + "Logout": "Çıkış Yap", + "Low": "Düşük", + "Lower numbers appear first as columns.": "Düşük sayılar sütun olarak önce görünür.", + "Lowest": "En Düşük", + "MQTT": "MQTT", + "MQTT Connection": "MQTT Bağlantısı", + "MQTT Connections": "MQTT Bağlantıları", + "Machine Connectivity": "Makine Bağlantısı", + "Maintenance": "Bakım", + "Maintenance Event": "Bakım Etkinliği", + "Maintenance Events": "Bakım Etkinlikleri", + "Manage": "Yönet", + "Manage global statuses": "Genel durumları yönet", + "Manage personal access tokens for external integrations": "Harici entegrasyonlar için kişisel erişim belirteçlerini yönetin", + "Manage tokens for external integrations": "Harici entegrasyonlar için belirteçleri yönetin", + "Manage →": "Yönet →", + "Manual Installation": "Manuel Kurulum", + "Manufacturing Execution System": "Üretim Yürütme Sistemi", + "Map Columns": "Sütunları Eşle", + "Map Material Columns": "Malzeme Sütunlarını Eşle", + "Map columns manually": "Sütunları manuel eşle", + "Mapped": "Eşlendi", + "Mark Resolved": "Çözüldü Olarak İşaretle", + "Mark as Complete": "Tamamlandı Olarak İşaretle", + "Mark as Done": "Bitti Olarak İşaretle", + "Matching Logic": "Eşleştirme Mantığı", + "Material": "Malzeme", + "Materials": "Malzemeler", + "Materials (BOM)": "Malzemeler (Malzeme Listesi)", + "Max 20 MB": "En fazla 20 MB", + "Max 32 MB": "En fazla 32 MB", + "Medium": "Orta", + "Member Since": "Üyelik Tarihi", + "Messages": "Mesajlar", + "Messages received": "Alınan mesajlar", + "Min. requirement met": "Asgari gereksinim karşılandı", + "Minimum 8 characters": "En az 8 karakter", + "Module Store": "Modül Mağazası", + "Modules": "Modüller", + "Modules — Store": "Modüller — Mağaza", + "Month": "Ay", + "Month Number": "Ay Numarası", + "Monthly": "Aylık", + "Move down": "Aşağı taşı", + "Move up": "Yukarı taşı", + "Moving to a Done status automatically closes the work order.": "Bitti durumuna geçmek iş emrini otomatik olarak kapatır.", + "Must change password": "Şifre değiştirilmeli", + "Name": "Ad", + "Needed": "Gerekli", + "Never": "Asla", + "New Anomaly Reason": "Yeni Anomali Nedeni", + "New Company": "Yeni Şirket", + "New Connection": "Yeni Bağlantı", + "New Cost Source": "Yeni Maliyet Kaynağı", + "New Crew": "Yeni Ekip", + "New Division": "Yeni Bölüm", + "New Factory": "Yeni Fabrika", + "New Issue Type": "Yeni Sorun Tipi", + "New Line": "Yeni Hat", + "New MQTT Connection": "Yeni MQTT Bağlantısı", + "New Password": "Yeni Şifre", + "New Product Type": "Yeni Ürün Tipi", + "New Skill": "Yeni Beceri", + "New Subassembly": "Yeni Alt Montaj", + "New Template": "Yeni Şablon", + "New Tool": "Yeni Takım", + "New User": "Yeni Kullanıcı", + "New Wage Group": "Yeni Ücret Grubu", + "New Work Order": "Yeni İş Emri", + "New Worker": "Yeni Çalışan", + "New Workstation": "Yeni İş İstasyonu", + "New Workstation Type": "Yeni İş İstasyonu Tipi", + "Next": "İleri", + "Next #": "Sonraki #", + "Next Service": "Sonraki Servis", + "Next Service Date": "Sonraki Servis Tarihi", + "Next week": "Gelecek hafta", + "No LOT sequences configured yet.": "Henüz LOT dizisi yapılandırılmadı.", + "No Line Assigned": "Atanmış Hat Yok", + "No MQTT connections defined.": "Tanımlı MQTT bağlantısı yok.", + "No OEE data available": "OEE verisi yok", + "No OEE records for this period.": "Bu dönem için OEE kaydı yok.", + "No PIN yet? Log in with password first, then set your PIN in Settings.": "Henüz PIN yok mu? Önce şifreyle giriş yapın, ardından Ayarlar'dan PIN'inizi belirleyin.", + "No Role": "Rol Yok", + "No active alerts at this time.": "Şu anda aktif uyarı yok.", + "No active batches": "Aktif parti yok", + "No active batches.": "Aktif parti yok.", + "No active product types defined yet.": "Henüz aktif ürün tipi tanımlanmadı.", + "No active workers in the system.": "Sistemde aktif çalışan yok.", + "No anomalies recorded": "Kayıtlı anomali yok", + "No anomaly reasons yet": "Henüz anomali nedeni yok", + "No audit logs found": "Denetim kaydı bulunamadı", + "No batches available for this work order.": "Bu iş emri için kullanılabilir parti yok.", + "No batches created yet": "Henüz parti oluşturulmadı", + "No batches yet.": "Henüz parti yok.", + "No columns added yet. Add at least one column.": "Henüz sütun eklenmedi. En az bir sütun ekleyin.", + "No companies yet": "Henüz şirket yok", + "No completed batches in the last 30 days": "Son 30 günde tamamlanan parti yok", + "No connections defined yet.": "Henüz bağlantı tanımlanmadı.", + "No cost sources yet": "Henüz maliyet kaynağı yok", + "No crews yet": "Henüz ekip yok", + "No custom columns configured. Default view will be shown.": "Özel sütun yapılandırılmadı. Varsayılan görünüm gösterilecek.", + "No data for the selected period.": "Seçilen dönem için veri yok.", + "No divisions yet": "Henüz bölüm yok", + "No external system linked.": "Bağlı harici sistem yok.", + "No factories yet": "Henüz fabrika yok", + "No global statuses yet. Add one below.": "Henüz genel durum yok. Aşağıdan bir tane ekleyin.", + "No imports yet.": "Henüz içe aktarma yok.", + "No integrations configured.": "Yapılandırılmış entegrasyon yok.", + "No issue types yet.": "Henüz sorun tipi yok.", + "No issues found.": "Sorun bulunamadı.", + "No issues reported": "Bildirilen sorun yok", + "No issues reported for the selected period.": "Seçilen dönem için bildirilen sorun yok.", + "No issues.": "Sorun yok.", + "No line": "Hat yok", + "No lines assigned": "Atanmış hat yok", + "No maintenance events found.": "Bakım etkinliği bulunamadı.", + "No mappings defined — messages will be logged only.": "Tanımlı eşleme yok — mesajlar yalnızca günlüğe kaydedilecek.", + "No matching orders found": "Eşleşen sipariş bulunamadı", + "No materials found.": "Malzeme bulunamadı.", + "No materials in BOM yet.": "Malzeme listesinde henüz malzeme yok.", + "No operators assigned yet": "Henüz operatör atanmadı", + "No orders": "Sipariş yok", + "No orders in this period.": "Bu dönemde sipariş yok.", + "No period grouping": "Dönem gruplaması yok", + "No process templates yet": "Henüz süreç şablonu yok", + "No product types assigned — all types are allowed.": "Atanmış ürün tipi yok — tüm tiplere izin verilir.", + "No product types yet": "Henüz ürün tipi yok", + "No production lines yet": "Henüz üretim hattı yok", + "No production steps yet": "Henüz üretim adımı yok", + "No recent issues": "Son zamanlarda sorun yok", + "No saved profiles yet. Profiles are saved during import.": "Henüz kayıtlı profil yok. Profiller içe aktarma sırasında kaydedilir.", + "No shifts defined yet.": "Henüz vardiya tanımlanmadı.", + "No skills defined yet.": "Henüz beceri tanımlanmadı.", + "No skills yet": "Henüz beceri yok", + "No specific batch": "Belirli bir parti yok", + "No specific workstation": "Belirli bir iş istasyonu yok", + "No statuses yet. Add one below or": "Henüz durum yok. Aşağıdan bir tane ekleyin veya", + "No subassemblies yet": "Henüz alt montaj yok", + "No templates created yet.": "Henüz şablon oluşturulmadı.", + "No tokens created yet": "Henüz belirteç oluşturulmadı", + "No tools yet": "Henüz takım yok", + "No topics subscribed yet.": "Henüz abone olunan konu yok.", + "No unassigned orders": "Atanmamış sipariş yok", + "No users found": "Kullanıcı bulunamadı", + "No view templates yet": "Henüz görünüm şablonu yok", + "No wage groups yet": "Henüz ücret grubu yok", + "No work orders found.": "İş emri bulunamadı.", + "No work orders scheduled for this week.": "Bu hafta için çizelgelenmiş iş emri yok.", + "No work orders yet": "Henüz iş emri yok", + "No workers yet": "Henüz çalışan yok", + "No workstation types yet": "Henüz iş istasyonu tipi yok", + "No workstations configured — line itself acts as a single workstation.": "Yapılandırılmış iş istasyonu yok — hat kendisi tek bir iş istasyonu olarak işlev görür.", + "No workstations yet": "Henüz iş istasyonu yok", + "Non-blocking": "Engelleyici olmayan", + "None": "Yok", + "Not Started": "Başlamadı", + "Not assigned": "Atanmadı", + "Not configured": "Yapılandırılmadı", + "Note": "Not", + "Note:": "Not:", + "Notes": "Notlar", + "Number Padding": "Sayı Dolgusu", + "Number of production shifts in a 24-hour period.": "24 saatlik dönemdeki üretim vardiyalarının sayısı.", + "OEE": "OEE", + "OEE Overview": "OEE Genel Bakış", + "OEE Report": "OEE Raporu", + "OEE Trend": "OEE Eğilimi", + "OEE data will appear once production batches are completed and downtimes are reported.": "OEE verisi, üretim partileri tamamlandığında ve duruşlar bildirildiğinde görünecek.", + "Open Issues": "Açık Sorunlar", + "Operating": "Çalışıyor", + "Operators": "Operatörler", + "Optional classification for this workstation": "Bu iş istasyonu için isteğe bağlı sınıflandırma", + "Optional description": "İsteğe bağlı açıklama", + "Optional description...": "İsteğe bağlı açıklama...", + "Optional notes": "İsteğe bağlı notlar", + "Optionally link this account to a production worker record.": "İsteğe bağlı olarak bu hesabı bir üretim çalışanı kaydına bağlayın.", + "Order": "Sipariş", + "Order #": "Sipariş #", + "Order Details": "Sipariş Ayrıntıları", + "Order Number": "Sipariş Numarası", + "Orders": "Siparişler", + "Other (custom)": "Diğer (özel)", + "Overall": "Genel", + "Overall Equipment Effectiveness per line": "Hat başına Genel Ekipman Etkinliği", + "Overall Equipment Effectiveness per line (A×P×Q)": "Hat başına Genel Ekipman Etkinliği (A×P×Q)", + "Overall Equipment Effectiveness — Availability × Performance × Quality": "Genel Ekipman Etkinliği — Kullanılabilirlik × Performans × Kalite", + "Overdue": "Gecikmiş", + "Overdue Work Orders": "Gecikmiş İş Emirleri", + "Overview": "Genel Bakış", + "Overview of all machine communication channels.": "Tüm makine iletişim kanallarına genel bakış.", + "Overview of production performance for the selected period.": "Seçilen dönem için üretim performansına genel bakış.", + "PASS": "GEÇTİ", + "PIN": "PIN", + "PIN (4–6 digits)": "PIN (4–6 hane)", + "PIN Setup": "PIN Kurulumu", + "PIN is active": "PIN aktif", + "Packaging Checklist": "Paketleme Kontrol Listesi", + "Packaging Material": "Paketleme Malzemesi", + "Packaging in good condition": "Paketleme iyi durumda", + "Padding width. 4 = 0001, 6 = 000001": "Dolgu genişliği. 4 = 0001, 6 = 000001", + "Parameters Unconfirmed Today": "Bugün Onaylanmamış Parametreler", + "Params": "Parametreler", + "Pass": "Geçti", + "Password": "Şifre", + "Passwords do not match": "Şifreler eşleşmiyor", + "Passwords match": "Şifreler eşleşiyor", + "Pattern": "Desen", + "Pause": "Duraklat", + "Paused": "Duraklatıldı", + "Payload format": "Yük biçimi", + "Pending": "Beklemede", + "Per Unit": "Birim Başına", + "Performance": "Performans", + "Period Type": "Dönem Tipi", + "Phone": "Telefon", + "Place the module folder directly in": "Modül klasörünü doğrudan şuraya yerleştirin:", + "Plan a maintenance, inspection, or calibration": "Bir bakım, muayene veya kalibrasyon planlayın", + "Plan by day": "Güne göre planla", + "Plan by month": "Aya göre planla", + "Plan by week": "Haftaya göre planla", + "Planned": "Planlandı", + "Planned Qty": "Planlanan Mik.", + "Planned Quantity": "Planlanan Miktar", + "Planned:": "Planlandı:", + "Planner": "Planlayıcı", + "Planning Period": "Planlama Dönemi", + "Planning horizon": "Planlama ufku", + "Port": "Port", + "Prefix": "Ön Ek", + "Preventive": "Önleyici", + "Preview": "Önizleme", + "Preview:": "Önizleme:", + "Previous": "Önceki", + "Previous week": "Önceki hafta", + "Print": "Yazdır", + "Priority": "Öncelik", + "Process": "Süreç", + "Process Confirmations": "Süreç Onayları", + "Process Template": "Süreç Şablonu", + "Process Templates": "Süreç Şablonları", + "Process templates define how this product is manufactured.": "Süreç şablonları bu ürünün nasıl üretildiğini tanımlar.", + "Processed": "İşlendi", + "Prod": "Üretim", + "Produced": "Üretildi", + "Produced Qty": "Üretilen Mik.", + "Produced Quantity": "Üretilen Miktar", + "Produced:": "Üretildi:", + "Product": "Ürün", + "Product Code": "Ürün Kodu", + "Product Name": "Ürün Adı", + "Product Type": "Ürün Tipi", + "Product Type Details": "Ürün Tipi Ayrıntıları", + "Product Types": "Ürün Tipleri", + "Product being produced": "Üretilen ürün", + "Product types that can be produced on this line.": "Bu hatta üretilebilen ürün tipleri.", + "Production": "Üretim", + "Production Anomalies": "Üretim Anomalileri", + "Production Controls": "Üretim Kontrolleri", + "Production Line": "Üretim Hattı", + "Production Line Details": "Üretim Hattı Ayrıntıları", + "Production Lines": "Üretim Hatları", + "Production Period Split": "Üretim Dönemi Bölümü", + "Production Planner": "Üretim Planlayıcısı", + "Production Planning": "Üretim Planlama", + "Production Quantity": "Üretim Miktarı", + "Production Reports": "Üretim Raporları", + "Production Rules": "Üretim Kuralları", + "Production Schedule": "Üretim Çizelgesi", + "Production Series Report": "Üretim Serisi Raporu", + "Production Steps": "Üretim Adımları", + "Production analytics and insights": "Üretim analizleri ve içgörüleri", + "Production by Line": "Hatta Göre Üretim", + "Production line": "Üretim hattı", + "Profile": "Profil", + "Profile name (e.g. ERP Export)": "Profil adı (örn. ERP Dışa Aktarımı)", + "Progress": "İlerleme", + "QC": "Kalite Kontrol", + "QC Checks Needed": "Gereken Kalite Kontrolleri", + "QoS default": "Varsayılan QoS", + "Qty": "Mik.", + "Qty/Unit": "Mik./Birim", + "Quality": "Kalite", + "Quality Check": "Kalite Kontrolü", + "Quality Checks": "Kalite Kontrolleri", + "Quantity": "Miktar", + "Quantity per Unit": "Birim Başına Miktar", + "Queue": "Kuyruk", + "Quick Links": "Hızlı Bağlantılar", + "Quick PIN": "Hızlı PIN", + "Quick PIN Login": "Hızlı PIN ile Giriş", + "Quick-fill": "Hızlı doldur", + "Raw Material": "Hammadde", + "Re-enter your new password": "Yeni şifrenizi tekrar girin", + "Re-enter your password": "Şifrenizi tekrar girin", + "Re-launch the onboarding wizard": "Tanıtım sihirbazını yeniden başlat", + "Reason": "Neden", + "Recent Batch Cycle Times": "Son Parti Çevrim Süreleri", + "Recent Imports": "Son İçe Aktarmalar", + "Recent Issues": "Son Sorunlar", + "Recent Work Orders": "Son İş Emirleri", + "Recipe / Materials": "Reçete / Malzemeler", + "Reconnect delay (seconds)": "Yeniden bağlanma gecikmesi (saniye)", + "Record Anomaly": "Anomali Kaydet", + "Record Production Anomaly": "Üretim Anomalisi Kaydet", + "Record deleted": "Kayıt silindi", + "Register a production worker": "Bir üretim çalışanı kaydet", + "Register a tool or machine": "Bir takım veya makine kaydet", + "Reject": "Reddet", + "Reject this work order?": "Bu iş emri reddedilsin mi?", + "Reject work order": "İş emrini reddet", + "Rejected": "Reddedildi", + "Release Batch": "Partiyi Serbest Bırak", + "Release this batch?": "Bu parti serbest bırakılsın mı?", + "Released": "Serbest Bırakıldı", + "Released By": "Serbest Bırakan", + "Remaining": "Kalan", + "Remember me": "Beni hatırla", + "Remove": "Kaldır", + "Remove PIN": "PIN'i Kaldır", + "Remove from schedule": "Çizelgeden kaldır", + "Remove operator": "Operatörü kaldır", + "Remove this material from BOM?": "Bu malzeme malzeme listesinden kaldırılsın mı?", + "Remove this order from schedule?": "Bu sipariş çizelgeden kaldırılsın mı?", + "Reopen": "Yeniden Aç", + "Reopen this work order?": "Bu iş emri yeniden açılsın mı?", + "Reported": "Bildirildi", + "Reported by": "Bildiren", + "Reports": "Raporlar", + "Required Fields": "Zorunlu Alanlar", + "Reset": "Sıfırla", + "Resolution": "Çözüm", + "Resolution notes (optional)": "Çözüm notları (isteğe bağlı)", + "Resolve": "Çöz", + "Resolve Issue": "Sorunu Çöz", + "Result": "Sonuç", + "Resume": "Devam Et", + "Retired": "Kullanımdan Kaldırıldı", + "Revoke": "İptal Et", + "Role": "Rol", + "Roles": "Roller", + "Sale": "Satış", + "Sample": "Numune", + "Sample Data": "Örnek Veri", + "Sample files": "Örnek dosyalar", + "Save": "Kaydet", + "Save Assignment": "Atamayı Kaydet", + "Save Changes": "Değişiklikleri Kaydet", + "Save Mapping Profile": "Eşleme Profilini Kaydet", + "Save View Columns": "Görünüm Sütunlarını Kaydet", + "Save this mapping for later": "Bu eşlemeyi sonrası için kaydet", + "Saved Mapping Profiles": "Kayıtlı Eşleme Profilleri", + "Saved! Redirecting...": "Kaydedildi! Yönlendiriliyor...", + "Saving...": "Kaydediliyor...", + "Schedule": "Planlama", + "Schedule / Planner": "Çizelge / Planlayıcı", + "Schedule Event": "Etkinlik Çizelgele", + "Schedule Maintenance Event": "Bakım Etkinliği Çizelgele", + "Scheduled": "Çizelgelendi", + "Scheduled At": "Çizelgelenme Zamanı", + "Scheduled:": "Çizelgelendi:", + "Scrap": "Fire", + "Scrap %": "Fire %", + "Scrap quantity": "Fire miktarı", + "Search": "Ara", + "Search by order number or product...": "Sipariş numarası veya ürüne göre ara...", + "Search order #": "Sipariş no ara", + "Search orders...": "Sipariş ara...", + "Select Production Line": "Üretim Hattı Seç", + "Select WO": "İş Emri Seç", + "Select a role": "Bir rol seçin", + "Select a view template that defines which columns operators see in the Workstation view for this line.": "Bu hat için operatörlerin İş İstasyonu görünümünde hangi sütunları göreceğini tanımlayan bir görünüm şablonu seçin.", + "Select an operator...": "Bir operatör seçin...", + "Select an unassigned order to place in this slot": "Bu yuvaya yerleştirmek için atanmamış bir sipariş seçin", + "Select language": "Dil seçin", + "Select material...": "Malzeme seçin...", + "Select reason": "Neden seçin", + "Select type...": "Tip seçin...", + "Select work order": "İş emri seçin", + "Select workstation": "İş istasyonu seçin", + "Select...": "Seçin...", + "Semi-Finished Product": "Yarı Mamul Ürün", + "Serial": "Seri", + "Series Report": "Seri Raporu", + "Set PIN": "PIN Belirle", + "Set a 4–6 digit PIN for fast sign-in": "Hızlı giriş için 4–6 haneli bir PIN belirleyin", + "Settings": "Ayarlar", + "Setup Complete!": "Kurulum Tamamlandı!", + "Setup Wizard": "Kurulum Sihirbazı", + "Severity": "Önem Derecesi", + "Shift": "Vardiya", + "Shifts": "Vardiyalar", + "Shifts per day": "Günlük vardiya sayısı", + "Short code displayed as column header in Workstation view (e.g. Z1, Z2, Z3).": "İş İstasyonu görünümünde sütun başlığı olarak gösterilen kısa kod (örn. Z1, Z2, Z3).", + "Shortcuts to common admin pages": "Yaygın yönetici sayfalarına kısayollar", + "Show Filters": "Filtreleri Göster", + "Show errors": "Hataları göster", + "Show weekends": "Hafta sonlarını göster", + "Showing 10 most recent of": "En son 10 tanesi gösteriliyor / toplam", + "Sign In": "Giriş Yap", + "Sign in": "Giriş yap", + "Signing in...": "Giriş yapılıyor...", + "Skills": "Yetenekler", + "Skip existing records": "Mevcut kayıtları atla", + "Skip wizard": "Sihirbazı atla", + "Sort": "Sırala", + "Sort Order": "Sıralama Düzeni", + "Source": "Kaynak", + "Source System": "Kaynak Sistem", + "Standard work order list with status, batches, priority and actions.": "Durum, partiler, öncelik ve işlemlerle standart iş emri listesi.", + "Start": "Başlat", + "Start Date": "Başlangıç Tarihi", + "Start Setup Wizard": "Kurulum Sihirbazını Başlat", + "Start Time": "Başlangıç Saati", + "Start of step": "Adım başlangıcı", + "Started": "Başladı", + "Started By": "Başlatan", + "Started:": "Başladı:", + "Status": "Durum", + "Status name (line-specific)": "Durum adı (hatta özel)", + "Step": "Adım", + "Step (optional)": "Adım (isteğe bağlı)", + "Step Name": "Adım Adı", + "Stock": "Stok", + "Store": "Mağaza", + "Stored as": "Şu şekilde saklanır", + "Strategy": "Strateji", + "Structure": "Yapı", + "Subassemblies": "Alt Montajlar", + "Subiekt GT Export": "Subiekt GT Dışa Aktarımı", + "Submit Checklist": "Kontrol Listesini Gönder", + "Submit QC": "Kalite Kontrolü Gönder", + "Suffix": "Son Ek", + "Suggestion": "Öneri", + "Supervisor": "Süpervizör", + "Supervisor Dashboard": "Süpervizör Paneli", + "Supplier": "Tedarikçi", + "Supplier LOT": "Tedarikçi LOT'u", + "Supported Formats": "Desteklenen Biçimler", + "System": "Sistem", + "System Fields": "Sistem Alanları", + "System Settings": "Sistem Ayarları", + "System Type": "Sistem Tipi", + "TLS / Security": "TLS / Güvenlik", + "Target line": "Hedef hat", + "Tax ID": "Vergi Numarası", + "Template": "Şablon", + "Template Name": "Şablon Adı", + "Template Steps": "Şablon Adımları", + "Templates": "Şablonlar", + "The ZIP must contain a": "ZIP şunu içermelidir:", + "The module store is being prepared. Soon you will be able to browse, purchase and install certified OpenMES extensions with a single click.": "Modül mağazası hazırlanıyor. Yakında sertifikalı OpenMES uzantılarına göz atabilecek, satın alabilecek ve tek tıkla kurabileceksiniz.", + "This is a": "Bu bir", + "Time": "Saat", + "Timeline": "Zaman Çizelgesi", + "Timestamp": "Zaman Damgası", + "Title": "Başlık", + "To": "Bitiş", + "To add line-specific statuses (only visible on one line), go to": "Hatta özel durumlar eklemek için (yalnızca bir hatta görünür) şuraya gidin:", + "To change your username or role, contact an administrator.": "Kullanıcı adınızı veya rolünüzü değiştirmek için bir yöneticiyle iletişime geçin.", + "To export materials from Subiekt GT:": "Subiekt GT'den malzeme dışa aktarmak için:", + "Today": "Bugün", + "Toggle skills and set the proficiency level for each.": "Becerileri açıp kapatın ve her biri için yetkinlik düzeyini belirleyin.", + "Token Name": "Belirteç Adı", + "Tool": "Takım", + "Tools": "Araçlar", + "Top 5 Issues by Type": "Tipe Göre İlk 5 Sorun", + "Topic pattern": "Konu deseni", + "Topics": "Konular", + "Topics & Mappings": "Konular ve Eşlemeler", + "Total": "Toplam", + "Total Lines": "Toplam Hat", + "Total Orders": "Toplam Sipariş", + "Total Produced Qty": "Toplam Üretilen Mik.", + "Total Product Types": "Toplam Ürün Tipi", + "Total Templates": "Toplam Şablon", + "Total Users": "Toplam Kullanıcı", + "Total Work Orders": "Toplam İş Emri", + "Total rows": "Toplam satır", + "Track all system changes and user activities": "Tüm sistem değişikliklerini ve kullanıcı etkinliklerini izleyin", + "Tracking": "İzleme", + "Tracking Type": "İzleme Tipi", + "Type": "Tip", + "UDI code readable": "UDI kodu okunabilir", + "Uninstall": "Kaldır", + "Unique identifier": "Benzersiz tanımlayıcı", + "Unique identifier for this production line": "Bu üretim hattı için benzersiz tanımlayıcı", + "Unique identifier for this workstation": "Bu iş istasyonu için benzersiz tanımlayıcı", + "Unique identifier, uppercase recommended": "Benzersiz tanımlayıcı, büyük harf önerilir", + "Unit": "Birim", + "Unit Cost": "Birim Maliyet", + "Unit of Measure": "Ölçü Birimi", + "Unknown": "Bilinmeyen", + "Update": "Güncelle", + "Update Check": "Güncelleme Kontrolü", + "Update Line": "Hattı Güncelle", + "Update Material": "Malzemeyi Güncelle", + "Update Only": "Yalnızca Güncelle", + "Update Product Type": "Ürün Tipini Güncelle", + "Update Sequence": "Diziyi Güncelle", + "Update Template": "Şablonu Güncelle", + "Update User": "Kullanıcıyı Güncelle", + "Update Workstation": "İş İstasyonunu Güncelle", + "Update existing only (skip new)": "Yalnızca mevcudu güncelle (yeniyi atla)", + "Update if exists, create if new": "Varsa güncelle, yeniyse oluştur", + "Update your password to keep your account secure.": "Hesabınızı güvende tutmak için şifrenizi güncelleyin.", + "Update your profile and account info": "Profilinizi ve hesap bilgilerinizi güncelleyin", + "Updated": "Güncellendi", + "Upload & Configure Mapping": "Yükle ve Eşlemeyi Yapılandır", + "Upload & Map Columns": "Yükle ve Sütunları Eşle", + "Upload File": "Dosya Yükle", + "Upload ZIP file": "ZIP dosyası yükle", + "Upload a module from a ZIP file or place the folder manually": "Bir ZIP dosyasından modül yükleyin veya klasörü manuel olarak yerleştirin", + "Urgent": "Acil", + "Use arrows to reorder. Modules can register additional widgets.": "Yeniden sıralamak için okları kullanın. Modüller ek bileşenler kaydedebilir.", + "Use line_code column from file": "Dosyadaki line_code sütununu kullan", + "Used for login": "Giriş için kullanılır", + "Used hooks": "Kullanılan kancalar", + "User": "Kullanıcı", + "User (Personal)": "Kullanıcı (Kişisel)", + "User Management": "Kullanıcı Yönetimi", + "Username": "Kullanıcı Adı", + "Users": "Kullanıcılar", + "Value": "Değer", + "Version": "Sürüm", + "View": "Görüntüle", + "View All": "Tümünü Görüntüle", + "View Details": "Ayrıntıları Görüntüle", + "View Steps": "Adımları Görüntüle", + "View Template": "Görünüm Şablonu", + "View Templates": "Görünüm Şablonları", + "View all": "Tümünü gör", + "View issues": "Sorunları görüntüle", + "View mode": "Görünüm modu", + "Wage Group": "Ücret Grubu", + "Wage Groups": "Ücret Grupları", + "Waiting for messages...": "Mesajlar bekleniyor...", + "Week": "Hafta", + "Week Number": "Hafta Numarası", + "Weekly": "Haftalık", + "Welcome to OpenMES!": "OpenMES'e Hoş Geldiniz!", + "Work Order": "İş Emri", + "Work Order Details": "İş Emri Ayrıntıları", + "Work Orders": "İş Emirleri", + "Work Orders by Status": "Duruma Göre İş Emirleri", + "Work order status is changed manually. Board statuses are visual labels.": "İş emri durumu manuel olarak değiştirilir. Pano durumları görsel etiketlerdir.", + "Work orders, issues, and lines summary cards": "İş emirleri, sorunlar ve hatlar özet kartları", + "Worker Code": "Çalışan Kodu", + "Worker Profile": "Çalışan Profili", + "Worker profile active": "Çalışan profili aktif", + "Workers": "Çalışanlar", + "Workers regularly operating at this workstation.": "Bu iş istasyonunda düzenli olarak çalışan personel.", + "Workflow Mode": "İş Akışı Modu", + "Workstation": "İş İstasyonu", + "Workstation (Optional)": "İş İstasyonu (İsteğe Bağlı)", + "Workstation (Shared)": "İş İstasyonu (Paylaşılan)", + "Workstation Code": "İş İstasyonu Kodu", + "Workstation Name": "İş İstasyonu Adı", + "Workstation Type": "İş İstasyonu Tipi", + "Workstation Types": "İstasyon Tipleri", + "Workstation View": "İş İstasyonu Görünümü", + "Workstation View Columns": "İş İstasyonu Görünümü Sütunları", + "Workstations": "İş İstasyonları", + "World-class": "Dünya standartlarında", + "XLS (Excel 97-2003)": "XLS (Excel 97-2003)", + "XLSX (Excel 2007+)": "XLSX (Excel 2007+)", + "Year": "Yıl", + "You can log in using your username and PIN.": "Kullanıcı adınız ve PIN'inizle giriş yapabilirsiniz.", + "You have unsaved changes!": "Kaydedilmemiş değişiklikleriniz var!", + "You must change your password before continuing.": "Devam etmeden önce şifrenizi değiştirmelisiniz.", + "all lines": "tüm hatlar", + "and a": "ve bir", + "and enable it": "ve etkinleştirin", + "browse": "göz at", + "by": "tarafından", + "created": "oluşturuldu", + "currently at": "şu anda", + "day": "gün", + "deadline": "son tarih", + "default": "varsayılan", + "demo account": "demo hesabı", + "dzienna": "gündüz", + "existing materials will be skipped.": "mevcut malzemeler atlanacak.", + "failed": "başarısız", + "file in the root directory or inside a single subfolder": "kök dizinde veya tek bir alt klasör içinde dosya", + "first to last": "ilkten sona", + "first.": "önce.", + "for identification": "tanımlama için", + "free slots": "boş yuvalar", + "from this line?": "bu hattan mı?", + "global": "genel", + "grid": "ızgara", + "horizon": "ufuk", + "import from CSV": "CSV'den içe aktar", + "imported": "içe aktarıldı", + "inactive": "pasif", + "incl. accepted": "onaylananlar dahil", + "issues total": "toplam sorun", + "it will be automatically deleted after": "şu süreden sonra otomatik olarak silinecek:", + "items": "öğe", + "kpi": "kpi", + "leave blank to keep current": "mevcudu korumak için boş bırakın", + "load": "yükle", + "main": "ana", + "manage global statuses": "genel durumları yönet", + "mapped": "eşlendi", + "material name": "malzeme adı", + "material(s) linked": "malzeme bağlandı", + "more needed": "daha fazla gerekli", + "new materials will be created, existing ones updated with new data.": "yeni malzemeler oluşturulacak, mevcutlar yeni verilerle güncellenecek.", + "noc": "gece", + "only existing materials will be updated, new ones skipped.": "yalnızca mevcut malzemeler güncellenecek, yeniler atlanacak.", + "optional": "isteğe bağlı", + "or": "veya", + "order": "sipariş", + "orders": "siparişler", + "orders total": "toplam sipariş", + "order|orders": "sipariş|sipariş", + "overload": "aşırı yük", + "overload / alert": "aşırı yük / uyarı", + "pcs": "adet", + "planned": "planlandı", + "popoł.": "öğleden sonra", + "priority level (1–5)": "öncelik düzeyi (1–5)", + "production month": "üretim ayı", + "production split": "üretim bölümü", + "production year": "üretim yılı", + "pull from work order fields (order_no, description, due_date, priority).": "iş emri alanlarından çek (order_no, description, due_date, priority).", + "rano": "sabah", + "reads": "okuma", + "redistribute to next week": "gelecek haftaya yeniden dağıt", + "required": "zorunlu", + "required field": "zorunlu alan", + "rows": "satır", + "shift planning": "vardiya planlama", + "shifts": "vardiyalar", + "shift|shifts": "vardiya|vardiya", + "sidebar": "kenar çubuğu", + "skipped": "atlandı", + "slots": "yuvalar", + "soon": "yakında", + "steps": "adım", + "supports + and # wildcards": "+ ve # joker karakterlerini destekler", + "system is configured for": "sistem şunun için yapılandırıldı:", + "then go to": "ardından şuraya gidin:", + "to": "—", + "to import": "içe aktarmak için", + "total done": "toplam tamamlanan", + "total pcs": "toplam adet", + "total rows": "toplam satır", + "total work orders": "toplam iş emri", + "unassigned": "atanmamış", + "units/day": "birim/gün", + "unknown": "bilinmeyen", + "updated": "güncellendi", + "urgent": "acil", + "virtual (line = workstation)": "sanal (hat = iş istasyonu)", + "week": "hafta", + "weeks": "hafta", + "week|weeks": "hafta|hafta", + "wiecz.": "akşam", + "with PIN": "PIN ile", + "wk": "hf", + "work order description": "iş emri açıklaması", + "workstation(s) on this line.": "bu hattaki iş istasyonları.", + "— No crew —": "— Ekip yok —", + "— No division —": "— Bölüm yok —", + "— No leader —": "— Lider yok —", + "— No wage group —": "— Ücret grubu yok —", + "— None —": "— Yok —", + "— Not a line —": "— Hat değil —", + "— Not a tool —": "— Takım değil —", + "— Not a workstation —": "— İş istasyonu değil —", + "— Not assigned —": "— Atanmadı —", + "— Select factory —": "— Fabrika seçin —", + "— Select type —": "— Tip seçin —", + "— Unassigned —": "— Atanmamış —", + "← Back": "← Geri" +} diff --git a/backend/lang/tr/auth.php b/backend/lang/tr/auth.php new file mode 100644 index 00000000..813f74d6 --- /dev/null +++ b/backend/lang/tr/auth.php @@ -0,0 +1,7 @@ + 'Bu kimlik bilgileri kayıtlarımızla eşleşmiyor.', + 'password' => 'Girilen şifre geçersiz.', + 'throttle' => 'Çok fazla oturum açma girişimi. Lütfen :seconds saniye sonra tekrar deneyin.', +]; diff --git a/backend/lang/tr/pagination.php b/backend/lang/tr/pagination.php new file mode 100644 index 00000000..a4c56e53 --- /dev/null +++ b/backend/lang/tr/pagination.php @@ -0,0 +1,6 @@ + '« Önceki', + 'next' => 'Sonraki »', +]; diff --git a/backend/lang/tr/passwords.php b/backend/lang/tr/passwords.php new file mode 100644 index 00000000..56796879 --- /dev/null +++ b/backend/lang/tr/passwords.php @@ -0,0 +1,9 @@ + 'Şifreniz sıfırlandı!', + 'sent' => 'Şifre sıfırlama bağlantınızı e-posta ile gönderdik!', + 'throttled' => 'Lütfen tekrar denemeden önce bekleyin.', + 'token' => 'Bu şifre sıfırlama kodu geçersiz.', + 'user' => "Bu e-posta adresiyle kayıtlı bir kullanıcı bulamadık.", +]; diff --git a/backend/lang/tr/validation.php b/backend/lang/tr/validation.php new file mode 100644 index 00000000..7d0aa558 --- /dev/null +++ b/backend/lang/tr/validation.php @@ -0,0 +1,159 @@ + ':attribute kabul edilmelidir.', + 'accepted_if' => ':other :value olduğunda :attribute kabul edilmelidir.', + 'active_url' => ':attribute geçerli bir URL olmalıdır.', + 'after' => ':attribute şundan daha sonra bir tarih olmalıdır: :date.', + 'after_or_equal' => ':attribute şuna eşit veya daha sonra bir tarih olmalıdır: :date.', + 'alpha' => ':attribute sadece harflerden oluşmalıdır.', + 'alpha_dash' => ':attribute sadece harfler, rakamlar, tireler ve alt çizgilerden oluşmalıdır.', + 'alpha_num' => ':attribute sadece harfler ve rakamlardan oluşmalıdır.', + 'array' => ':attribute bir dizi olmalıdır.', + 'ascii' => ':attribute sadece tek baytlık alfanümerik karakterler ve semboller içermelidir.', + 'before' => ':attribute şundan daha önce bir tarih olmalıdır: :date.', + 'before_or_equal' => ':attribute şuna eşit veya daha önce bir tarih olmalıdır: :date.', + 'between' => [ + 'array' => ':attribute :min ile :max öğe arasında olmalıdır.', + 'file' => ':attribute :min ile :max kilobayt arasında olmalıdır.', + 'numeric' => ':attribute :min ile :max arasında olmalıdır.', + 'string' => ':attribute :min ile :max karakter arasında olmalıdır.', + ], + 'boolean' => ':attribute alanı true veya false olmalıdır.', + 'confirmed' => ':attribute onayı eşleşmiyor.', + 'date' => ':attribute geçerli bir tarih olmalıdır.', + 'date_equals' => ':attribute şuna eşit bir tarih olmalıdır: :date.', + 'date_format' => ':attribute şuna uygun olmalıdır: :format.', + 'decimal' => ':attribute :decimal ondalık basamağa sahip olmalıdır.', + 'declined' => ':attribute reddedilmelidir.', + 'declined_if' => ':other :value olduğunda :attribute reddedilmelidir.', + 'different' => ':attribute ve :other farklı olmalıdır.', + 'digits' => ':attribute :digits basamaklı olmalıdır.', + 'digits_between' => ':attribute :min ile :max basamak arasında olmalıdır.', + 'dimensions' => ':attribute geçersiz resim boyutlarına sahip.', + 'distinct' => ':attribute alanı yinelenen bir değere sahip.', + 'doesnt_end_with' => ':attribute şunlardan biriyle bitmemelidir: :values.', + 'doesnt_start_with' => ':attribute şunlardan biriyle başlamamalıdır: :values.', + 'email' => ':attribute geçerli bir e-posta adresi olmalıdır.', + 'ends_with' => ':attribute şunlardan biriyle bitmelidir: :values.', + 'enum' => 'Seçilen :attribute geçersiz.', + 'exists' => 'Seçilen :attribute geçersiz.', + 'file' => ':attribute bir dosya olmalıdır.', + 'filled' => ':attribute alanının bir değeri olmalıdır.', + 'gt' => [ + 'array' => ':attribute :value öğeden fazla olmalıdır.', + 'file' => ':attribute :value kilobayttan büyük olmalıdır.', + 'numeric' => ':attribute :value değerinden büyük olmalıdır.', + 'string' => ':attribute :value karakterden fazla olmalıdır.', + ], + 'gte' => [ + 'array' => ':attribute :value veya daha fazla öğeye sahip olmalıdır.', + 'file' => ':attribute :value veya daha fazla kilobayt olmalıdır.', + 'numeric' => ':attribute :value değerine eşit veya daha büyük olmalıdır.', + 'string' => ':attribute :value veya daha fazla karakter içermelidir.', + ], + 'image' => ':attribute bir resim olmalıdır.', + 'in' => 'Seçilen :attribute geçersiz.', + 'in_array' => ':attribute alanı :other içinde mevcut değil.', + 'integer' => ':attribute bir tam sayı olmalıdır.', + 'ip' => ':attribute geçerli bir IP adresi olmalıdır.', + 'ipv4' => ':attribute geçerli bir IPv4 adresi olmalıdır.', + 'ipv6' => ':attribute geçerli bir IPv6 adresi olmalıdır.', + 'json' => ':attribute geçerli bir JSON dizesi olmalıdır.', + 'lowercase' => ':attribute küçük harf olmalıdır.', + 'lt' => [ + 'array' => ':attribute :value öğeden az olmalıdır.', + 'file' => ':attribute :value kilobayttan küçük olmalıdır.', + 'numeric' => ':attribute :value değerinden küçük olmalıdır.', + 'string' => ':attribute :value karakterden az olmalıdır.', + ], + 'lte' => [ + 'array' => ':attribute :value öğeden fazla olmamalıdır.', + 'file' => ':attribute :value veya daha az kilobayt olmalıdır.', + 'numeric' => ':attribute :value değerine eşit veya daha küçük olmalıdır.', + 'string' => ':attribute :value veya daha az karakter içermelidir.', + ], + 'mac_address' => ':attribute geçerli bir MAC adresi olmalıdır.', + 'max' => [ + 'array' => ':attribute :max öğeden fazla olmamalıdır.', + 'file' => ':attribute :max kilobayttan büyük olmamalıdır.', + 'numeric' => ':attribute :max değerinden büyük olmamalıdır.', + 'string' => ':attribute :max karakterden fazla olmamalıdır.', + ], + 'max_digits' => ':attribute :max basamaktan fazla olmamalıdır.', + 'mimes' => ':attribute şu dosya türlerinden biri olmalıdır: :values.', + 'mimetypes' => ':attribute şu dosya türlerinden biri olmalıdır: :values.', + 'min' => [ + 'array' => ':attribute en az :min öğeye sahip olmalıdır.', + 'file' => ':attribute en az :min kilobayt olmalıdır.', + 'numeric' => ':attribute en az :min olmalıdır.', + 'string' => ':attribute en az :min karakter olmalıdır.', + ], + 'min_digits' => ':attribute en az :min basamaklı olmalıdır.', + 'missing' => ':attribute alanı eksik olmalıdır.', + 'missing_if' => ':other :value olduğunda :attribute alanı eksik olmalıdır.', + 'missing_unless' => ':other :value olmadıkça :attribute alanı eksik olmalıdır.', + 'missing_with' => ':values mevcut olduğunda :attribute alanı eksik olmalıdır.', + 'missing_with_all' => ':values mevcut olduğunda :attribute alanı eksik olmalıdır.', + 'multiple_of' => ':attribute :value katı olmalıdır.', + 'not_in' => 'Seçilen :attribute geçersiz.', + 'not_regex' => ':attribute formatı geçersiz.', + 'numeric' => ':attribute bir sayı olmalıdır.', + 'password' => [ + 'letters' => ':attribute en az bir harf içermelidir.', + 'mixed' => ':attribute en az bir büyük harf ve bir küçük harf içermelidir.', + 'numbers' => ':attribute en az bir rakam içermelidir.', + 'symbols' => ':attribute en az bir sembol içermelidir.', + 'uncompromised' => 'Verilen :attribute veri sızıntılarında tespit edildi. Lütfen farklı bir :attribute seçin.', + ], + 'present' => ':attribute alanı mevcut olmalıdır.', + 'prohibited' => ':attribute alanı yasaktır.', + 'prohibited_if' => ':other :value olduğunda :attribute alanı yasaktır.', + 'prohibited_unless' => ':other :values içinde olmadıkça :attribute alanı yasaktır.', + 'prohibits' => ':attribute alanı :other alanının mevcut olmasını engelliyor.', + 'regex' => ':attribute formatı geçersiz.', + 'required' => ':attribute alanı gereklidir.', + 'required_array_keys' => ':attribute alanı şu girişleri içermelidir: :values.', + 'required_if' => ':other :value olduğunda :attribute alanı gereklidir.', + 'required_if_accepted' => ':other kabul edildiğinde :attribute alanı gereklidir.', + 'required_unless' => ':other :values içinde olmadıkça :attribute alanı gereklidir.', + 'required_with' => ':values mevcut olduğunda :attribute alanı gereklidir.', + 'required_with_all' => ':values mevcut olduğunda :attribute alanı gereklidir.', + 'required_without' => ':values mevcut olmadığında :attribute alanı gereklidir.', + 'required_without_all' => ':values öğelerinden hiçbiri mevcut olmadığında :attribute alanı gereklidir.', + 'same' => ':attribute ve :other eşleşmelidir.', + 'size' => [ + 'array' => ':attribute :size öğe içermelidir.', + 'file' => ':attribute :size kilobayt olmalıdır.', + 'numeric' => ':attribute :size olmalıdır.', + 'string' => ':attribute :size karakter olmalıdır.', + ], + 'starts_with' => ':attribute şunlardan biriyle başlamalıdır: :values.', + 'string' => ':attribute bir metin olmalıdır.', + 'timezone' => ':attribute geçerli bir saat dilimi olmalıdır.', + 'unique' => ':attribute zaten alınmış.', + 'uploaded' => ':attribute yüklenemedi.', + 'uppercase' => ':attribute büyük harf olmalıdır.', + 'url' => ':attribute geçerli bir URL olmalıdır.', + 'ulid' => ':attribute geçerli bir ULID olmalıdır.', + 'uuid' => ':attribute geçerli bir UUID olmalıdır.', + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'özel-mesaj', + ], + ], + + 'attributes' => [ + 'name' => 'ad', + 'username' => 'kullanıcı adı', + 'email' => 'e-posta', + 'password' => 'şifre', + 'order_no' => 'sipariş no', + 'planned_qty' => 'planlanan miktar', + 'line_id' => 'hat', + 'product_type_id' => 'ürün tipi', + 'status' => 'durum', + 'due_date' => 'teslim tarihi', + ], +]; 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/resources/views/admin/connectivity/_runtime-status.blade.php b/backend/resources/views/admin/connectivity/_runtime-status.blade.php new file mode 100644 index 00000000..221b12a3 --- /dev/null +++ b/backend/resources/views/admin/connectivity/_runtime-status.blade.php @@ -0,0 +1,34 @@ +{{-- Runtime status banner. Expects $runtime = RuntimeMonitor::connectionRuntime(). --}} +@if($runtime['alive']) +
+ +

+ {{ $runtime['label'] }} {{ __('is running') }} + @if($runtime['seconds_ago'] !== null)({{ __('last seen :n s ago', ['n' => $runtime['seconds_ago']]) }})@endif +

+
+@else +
+
+ + + +
+

+ {{ $runtime['label'] }} {{ __('is not running') }} +

+

+ {{ __('This connection is configured but no process is reading from the machine. Start one of the following:') }} +

+ @if($runtime['command']) +

{{ __('Bare metal / local:') }}

+ {{ $runtime['command'] }} + @endif + @if($runtime['docker']) +

{{ __('Docker:') }}

+ {{ $runtime['docker'] }} + @endif +
+
+
+@endif diff --git a/backend/resources/views/admin/connectivity/modbus/_form.blade.php b/backend/resources/views/admin/connectivity/modbus/_form.blade.php new file mode 100644 index 00000000..01ce01db --- /dev/null +++ b/backend/resources/views/admin/connectivity/modbus/_form.blade.php @@ -0,0 +1,56 @@ +@php $m = $connection->modbusConnection ?? null; @endphp +
+
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + +
+
+ + + @error('host')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
diff --git a/backend/resources/views/admin/connectivity/modbus/create.blade.php b/backend/resources/views/admin/connectivity/modbus/create.blade.php new file mode 100644 index 00000000..f8aa6a6b --- /dev/null +++ b/backend/resources/views/admin/connectivity/modbus/create.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.app') +@section('title', __('New Modbus Connection')) +@section('content') +
+ ← {{ __('Back') }} +

{{ __('New Modbus Connection') }}

+
+ @csrf + @php $connection = new \App\Models\MachineConnection(); @endphp + @include('admin.connectivity.modbus._form') +
+ {{ __('Cancel') }} + +
+
+
+@endsection diff --git a/backend/resources/views/admin/connectivity/modbus/edit.blade.php b/backend/resources/views/admin/connectivity/modbus/edit.blade.php new file mode 100644 index 00000000..b3a7c59f --- /dev/null +++ b/backend/resources/views/admin/connectivity/modbus/edit.blade.php @@ -0,0 +1,22 @@ +@extends('layouts.app') +@section('title', __('Edit Modbus Connection')) +@section('content') +
+ ← {{ __('Back') }} +

{{ __('Edit Modbus Connection') }}

+
+ @csrf @method('PUT') + @include('admin.connectivity.modbus._form') +
+ + @csrf @method('DELETE') + + +
+ {{ __('Cancel') }} + +
+
+ +
+@endsection diff --git a/backend/resources/views/admin/connectivity/modbus/index.blade.php b/backend/resources/views/admin/connectivity/modbus/index.blade.php new file mode 100644 index 00000000..913f0ce3 --- /dev/null +++ b/backend/resources/views/admin/connectivity/modbus/index.blade.php @@ -0,0 +1,41 @@ +@extends('layouts.app') + +@section('title', __('Modbus Connections')) + +@section('content') + + + +@endsection diff --git a/backend/resources/views/admin/connectivity/modbus/show.blade.php b/backend/resources/views/admin/connectivity/modbus/show.blade.php new file mode 100644 index 00000000..ae72baf3 --- /dev/null +++ b/backend/resources/views/admin/connectivity/modbus/show.blade.php @@ -0,0 +1,101 @@ +@extends('layouts.app') +@section('title', $connection->name) +@section('content') +@php $m = $connection->modbusConnection; @endphp +
+ ← {{ __('Back') }} +
+
+

{{ $connection->name }}

+

{{ $m?->host }}:{{ $m?->port }} · unit {{ $m?->unit_id }} · {{ $m?->poll_interval_ms }}ms

+
+
+ {{ $connection->status }} + {{ __('Edit') }} +
+
+ + @if(session('success'))
{{ session('success') }}
@endif + @if($connection->status_message)
{{ $connection->status_message }}
@endif + +
+ @include('admin.connectivity._runtime-status', ['runtime' => $runtime]) +
+ + {{-- Tags --}} +
+

{{ __('Tags') }} ({{ $connection->tags->count() }})

+ @if($connection->tags->isNotEmpty()) +
+ + + + + + + + @foreach($connection->tags as $tag) + + + + + + + + + + @endforeach + +
{{ __('Name') }}{{ __('Address') }}{{ __('Signal') }}{{ __('Type') }}{{ __('Workstation') }}{{ __('Transform') }}
{{ $tag->name }}{{ $tag->register_type }}:{{ $tag->address }}{{ $tag->signal_type }}{{ $tag->data_type }}{{ $tag->workstation?->name ?? '—' }}{{ $tag->transform ? json_encode($tag->transform) : '—' }} +
+ @csrf @method('DELETE') + +
+
+
+ @endif + + {{-- Add tag --}} +
+ @csrf +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+@endsection diff --git a/backend/resources/views/admin/connectivity/opcua/_form.blade.php b/backend/resources/views/admin/connectivity/opcua/_form.blade.php new file mode 100644 index 00000000..ee175a64 --- /dev/null +++ b/backend/resources/views/admin/connectivity/opcua/_form.blade.php @@ -0,0 +1,59 @@ +@php $o = $connection->opcuaConnection ?? null; @endphp +
+
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + +
+
+ + + @error('endpoint_url')

{{ $message }}

@enderror +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
diff --git a/backend/resources/views/admin/connectivity/opcua/create.blade.php b/backend/resources/views/admin/connectivity/opcua/create.blade.php new file mode 100644 index 00000000..53f23f37 --- /dev/null +++ b/backend/resources/views/admin/connectivity/opcua/create.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.app') +@section('title', __('New OPC UA Connection')) +@section('content') +
+ ← {{ __('Back') }} +

{{ __('New OPC UA Connection') }}

+
+ @csrf + @php $connection = new \App\Models\MachineConnection(); @endphp + @include('admin.connectivity.opcua._form') +
+ {{ __('Cancel') }} + +
+
+
+@endsection diff --git a/backend/resources/views/admin/connectivity/opcua/edit.blade.php b/backend/resources/views/admin/connectivity/opcua/edit.blade.php new file mode 100644 index 00000000..59684c07 --- /dev/null +++ b/backend/resources/views/admin/connectivity/opcua/edit.blade.php @@ -0,0 +1,22 @@ +@extends('layouts.app') +@section('title', __('Edit OPC UA Connection')) +@section('content') +
+ ← {{ __('Back') }} +

{{ __('Edit OPC UA Connection') }}

+
+ @csrf @method('PUT') + @include('admin.connectivity.opcua._form') +
+ + @csrf @method('DELETE') + + +
+ {{ __('Cancel') }} + +
+
+ +
+@endsection diff --git a/backend/resources/views/admin/connectivity/opcua/index.blade.php b/backend/resources/views/admin/connectivity/opcua/index.blade.php new file mode 100644 index 00000000..1d1d488d --- /dev/null +++ b/backend/resources/views/admin/connectivity/opcua/index.blade.php @@ -0,0 +1,41 @@ +@extends('layouts.app') +@section('title', __('OPC UA Connections')) +@section('content') + +
+
+

{{ __('OPC UA Connections') }}

+ + {{ __('New Connection') }} +
+ +
+

{{ __('OPC UA requires the gateway sidecar') }}

+

{{ __('OpenMES does not speak OPC UA directly. A separate gateway service (opcua-gateway) connects to your OPC UA server and forwards readings. Each connection page shows whether its gateway is running.') }}

+
+ + @if($connections->isEmpty()) +

{{ __('No OPC UA connections defined yet.') }}

+ @else + + @endif +
+@endsection diff --git a/backend/resources/views/admin/connectivity/opcua/show.blade.php b/backend/resources/views/admin/connectivity/opcua/show.blade.php new file mode 100644 index 00000000..bd980593 --- /dev/null +++ b/backend/resources/views/admin/connectivity/opcua/show.blade.php @@ -0,0 +1,103 @@ +@extends('layouts.app') +@section('title', $connection->name) +@section('content') +@php $o = $connection->opcuaConnection; @endphp +
+ ← {{ __('Back') }} +
+
+

{{ $connection->name }}

+

{{ $o?->endpoint_url }} · {{ $o?->security_policy }} · {{ $o?->publishing_interval_ms }}ms

+
+
+ {{ $connection->status }} + {{ __('Edit') }} +
+
+ + @if(session('success'))
{{ session('success') }}
@endif + + {{-- Runtime status (gateway sidecar) --}} +
+ @include('admin.connectivity._runtime-status', ['runtime' => $runtime]) +
+ + {{-- Gateway wiring help --}} +
+

{{ __('Gateway endpoints') }}

+

{{ __('The opcua-gateway sidecar uses these (with an API token):') }}

+
+
GET  /api/v1/machine-connections/{{ $connection->id }}/gateway-config
+
POST /api/v1/machine-connections/{{ $connection->id }}/signals
+
POST /api/v1/machine-connections/{{ $connection->id }}/heartbeat
+
+
+ + {{-- Tags (OPC UA nodes) --}} +
+

{{ __('Nodes') }} ({{ $connection->tags->count() }})

+ @if($connection->tags->isNotEmpty()) +
+ + + + + + + + @foreach($connection->tags as $tag) + + + + + + + + + + @endforeach + +
{{ __('Name') }}{{ __('Node ID') }}{{ __('Signal') }}{{ __('Type') }}{{ __('Workstation') }}{{ __('Transform') }}
{{ $tag->name }}{{ $tag->address }}{{ $tag->signal_type }}{{ $tag->data_type }}{{ $tag->workstation?->name ?? '—' }}{{ $tag->transform ? json_encode($tag->transform) : '—' }} +
+ @csrf @method('DELETE') + +
+
+
+ @endif + +
+ @csrf +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+@endsection 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/machine-monitor/index.blade.php b/backend/resources/views/admin/machine-monitor/index.blade.php new file mode 100644 index 00000000..3f2432ff --- /dev/null +++ b/backend/resources/views/admin/machine-monitor/index.blade.php @@ -0,0 +1,89 @@ +@extends('layouts.app') + +@section('title', __('Machine Monitor')) + +@section('content') + + +
+
+
+

{{ __('Machine Monitor') }}

+

{{ __('Live workstation states from connected machines.') }}

+
+ + {{ __('Live') }} + +
+ + + +
+ +
+
+ + +@endsection diff --git a/backend/resources/views/admin/maintenance-events/partials/form-fields.blade.php b/backend/resources/views/admin/maintenance-events/partials/form-fields.blade.php index 5e6d19fe..913454cd 100644 --- a/backend/resources/views/admin/maintenance-events/partials/form-fields.blade.php +++ b/backend/resources/views/admin/maintenance-events/partials/form-fields.blade.php @@ -106,12 +106,19 @@ class="form-input w-full"

{{ __('When') }}

- + @error('scheduled_at')

{{ $message }}

@enderror
+
+ + + @error('scheduled_end_at')

{{ $message }}

@enderror +
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/admin/schedule/_hourly.blade.php b/backend/resources/views/admin/schedule/_hourly.blade.php index 3079fffe..f973ee5a 100644 --- a/backend/resources/views/admin/schedule/_hourly.blade.php +++ b/backend/resources/views/admin/schedule/_hourly.blade.php @@ -73,7 +73,8 @@
@foreach($data['lines'] as $lineRow) -
{{ $lineRow['line']->code ?? $lineRow['line']->name }} @@ -118,7 +119,7 @@ {{-- Lane rows --}} @foreach($data['lines'] as $lineRow)
@endforeach - @if($lineRow['orders']->isEmpty()) + {{-- Maintenance events on this line today --}} + @php + $lineMaint = ($maintenanceEvents ?? collect())->filter(fn($m) => + $m->line_id == $lineRow['line']->id && + $m->scheduled_at->format('Y-m-d') === $startDate->format('Y-m-d') + ); + @endphp + @foreach($lineMaint as $maint) + @php + $maintMinute = (int) $startDate->copy()->startOfDay()->diffInMinutes($maint->scheduled_at, false); + $maintDuration = $maint->scheduled_end_at + ? $maint->scheduled_at->diffInMinutes($maint->scheduled_end_at) + : 60; + $maintDuration = max(30, $maintDuration); + @endphp +
+ 🔧 {{ $maint->title }} +
+ @endforeach + + @if($lineRow['orders']->isEmpty() && $lineMaint->isEmpty())
{{ __('No scheduled orders') }}
diff --git a/backend/resources/views/admin/schedule/planner-weeks.blade.php b/backend/resources/views/admin/schedule/planner-weeks.blade.php index 20e357ca..827c4264 100644 --- a/backend/resources/views/admin/schedule/planner-weeks.blade.php +++ b/backend/resources/views/admin/schedule/planner-weeks.blade.php @@ -246,6 +246,18 @@ class="h-[52px] rounded transition-all cursor-pointer relative overflow-hidden" x-text="dragOrderNo" class="absolute inset-0 flex items-center justify-center text-[9px] font-bold text-blue-700">
+ @php + $cellMaint = ($maintenanceEvents ?? collect())->filter(fn($m) => + $m->line_id == $line->id && + $m->scheduled_at->format('Y-m-d') === $cellDate + ); + @endphp + @foreach($cellMaint as $maint) +
+ 🔧 {{ $maint->title }} +
+ @endforeach @endif @endif diff --git a/backend/resources/views/admin/schedule/planner.blade.php b/backend/resources/views/admin/schedule/planner.blade.php index 323a0864..446f652f 100644 --- a/backend/resources/views/admin/schedule/planner.blade.php +++ b/backend/resources/views/admin/schedule/planner.blade.php @@ -202,7 +202,23 @@ 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; + // Derive planned_start_at/planned_end_at from the shift slot so the + // WO lands at the right time in minute-level views. The day is split + // into `shiftsPerDay` equal slots, matching the planner grid columns. + const shiftsPerDay = {{ (int) $shiftsPerDay }}; + const slotMinutes = Math.round(24 * 60 / shiftsPerDay); + const shiftIndex = this.assignShift ? parseInt(this.assignShift) - 1 : 0; + const toIso = (offsetMinutes) => { + const d = new Date(this.assignDate + 'T00:00:00'); + d.setMinutes(d.getMinutes() + offsetMinutes); + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:00`; + }; + data.planned_start_at = toIso(shiftIndex * slotMinutes); + data.planned_end_at = toIso((shiftIndex + 1) * slotMinutes); + } if (this.assignWeekNumber) data.week_number = this.assignWeekNumber; if (this.assignShift) data.shift_number = this.assignShift; @@ -814,7 +830,20 @@ class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white text-
@endforeach - @if($dayOrders->isEmpty()) + @php + $dayMaint = ($maintenanceEvents ?? collect())->filter(fn($m) => + $m->line_id == $line->id && + $m->scheduled_at->format('Y-m-d') === $day['date']->format('Y-m-d') + ); + @endphp + @foreach($dayMaint as $maint) +
+ + {{ $maint->title }} +
+ @endforeach + @if($dayOrders->isEmpty() && $dayMaint->isEmpty())
@endif
@@ -836,6 +865,7 @@ class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white text- 'woTextColors' => $woTextColors, 'statusLabels' => $statusLabels, 'shiftsPerDay' => $shiftsPerDay, + 'maintenanceEvents' => $maintenanceEvents, ]) {{-- ===== MONTHLY VIEW ===== --}} diff --git a/backend/resources/views/admin/traceability/_ingredient-tree.blade.php b/backend/resources/views/admin/traceability/_ingredient-tree.blade.php new file mode 100644 index 00000000..e96914a7 --- /dev/null +++ b/backend/resources/views/admin/traceability/_ingredient-tree.blade.php @@ -0,0 +1,24 @@ +{{-- Recursive backward genealogy node. Expects $node = backwardTraceLot() output. --}} +@if(!empty($node['ingredients'])) +
    + @foreach($node['ingredients'] as $child) +
  • +
    + {{ $child['lot']['lot_number'] ?? '—' }} + {{ $child['material']['name'] ?? '' }} + @if(!empty($child['supplier_lot_no'])) + {{ __('Supplier LOT') }}: {{ $child['supplier_lot_no'] }} + @endif + @if(!empty($child['source_batch_id'])) + {{ __('semi-finished') }} + @endif +
    + @if(!empty($child['truncated'])) +

    {{ __('Trace truncated (max depth reached).') }}

    + @else + @include('admin.traceability._ingredient-tree', ['node' => $child]) + @endif +
  • + @endforeach +
+@endif diff --git a/backend/resources/views/admin/traceability/index.blade.php b/backend/resources/views/admin/traceability/index.blade.php new file mode 100644 index 00000000..5884de7d --- /dev/null +++ b/backend/resources/views/admin/traceability/index.blade.php @@ -0,0 +1,221 @@ +@extends('layouts.app') + +@section('title', __('Traceability')) + +@section('content') + + +
+
+

{{ __('Traceability') }}

+

{{ __('Trace a finished LOT, material lot, supplier LOT or serial number through its full genealogy.') }}

+
+ + {{-- Search --}} +
+ +
+ + +
+
+ + @if($term !== '' && !$result) +
+ + + +

{{ __('No finished LOT, material lot or serial number matches') }} {{ $term }}.

+
+ @endif + + {{-- ════════════ FINISHED BATCH GENEALOGY (backward) ════════════ --}} + @if($result && $result['type'] === 'batch') + @php $b = $result['data']['batch']; @endphp +
+
+
+ {{ __('Finished LOT') }} +

{{ $b->lot_number }}

+

+ {{ __('Work Order') }}: {{ $b->workOrder?->order_no ?? '—' }} + · {{ __('Product') }}: {{ $b->workOrder?->productType?->name ?? '—' }} + · {{ __('Batch') }} #{{ $b->batch_number }} +

+
+ {{ __('Backward trace') }} +
+
+ + @php $distinct = $result['data']['distinct_input_lots']; @endphp +
+

{{ __('Ingredient lots') }} ({{ $distinct->count() }})

+ @if($distinct->isEmpty()) +

{{ __('No material lots were recorded as consumed for this batch.') }}

+ @else +
+ + + + + + + + + @foreach($distinct as $lot) + + + + + + + @endforeach + +
{{ __('Material') }}{{ __('LOT') }}{{ __('Supplier LOT') }}{{ __('Status') }}
{{ $lot->material?->name ?? '—' }} {{ $lot->material?->code }}{{ $lot->lot_number }}{{ $lot->supplier_lot_no ?? '—' }}{{ $lot->status }}
+
+ @endif +
+ + {{-- Per-step consumption timeline --}} +
+

{{ __('Process history') }}

+
+ @foreach($b->steps as $step) + @php $stepConsumptions = $result['data']['consumptions_by_step'][$step->id] ?? collect(); @endphp +
+
+ {{ __('Step') }} {{ $step->step_number }}: {{ $step->name }} + @if($step->workstation){{ $step->workstation->name }}@endif + @if($step->completedBy){{ __('by') }} {{ $step->completedBy->name }}@endif + @if($step->completed_at){{ \Carbon\Carbon::parse($step->completed_at)->format('Y-m-d H:i') }}@endif +
+ @if($stepConsumptions->isNotEmpty()) +
    + @foreach($stepConsumptions as $c) +
  • + + {{ $c->materialLot?->lot_number }} + {{ $c->materialLot?->material?->name }} + — {{ number_format((float) $c->quantity_consumed, 2) }} +
  • + @endforeach +
+ @endif +
+ @endforeach +
+ + @if($b->outputLots->isNotEmpty()) +
+

{{ __('Output lots') }}

+ @foreach($b->outputLots as $out) + {{ $out->lot_number }} + @endforeach +
+ @endif +
+ @endif + + {{-- ════════════ MATERIAL LOT (forward + backward) ════════════ --}} + @if($result && $result['type'] === 'material_lot') + @php $fwd = $result['forward']; $bwd = $result['backward']; @endphp +
+ {{ __('Material lot') }} +

{{ $fwd['lot']['lot_number'] }}

+ @if($bwd['supplier_lot_no']) +

{{ __('Supplier LOT') }}: {{ $bwd['supplier_lot_no'] }}

+ @endif +
+ + {{-- Forward: impact analysis --}} +
+
+

{{ __('Forward trace') }} — {{ __('where did this lot go?') }}

+ {{ $fwd['work_orders']->count() }} {{ __('work orders') }} +
+ @if($fwd['work_orders']->isEmpty()) +

{{ __('This lot has not been consumed yet.') }}

+ @else +
    + @foreach($fwd['work_orders'] as $wo) +
  • + {{ $wo->order_no }} + {{ $wo->productType?->name }} + {{ $wo->status }} +
  • + @endforeach +
+

{{ __('Total consumed') }}: {{ number_format($fwd['total_consumed'], 2) }}

+ @endif +
+ + {{-- Backward: ingredients --}} +
+

{{ __('Backward trace') }} — {{ __('what fed into this lot?') }}

+ @if($bwd['source_batch_id']) +

{{ __('Produced by batch') }} #{{ $bwd['source_batch']['batch_number'] ?? $bwd['source_batch_id'] }} + @if(($bwd['source_batch']['lot_number'] ?? null)) + ({{ $bwd['source_batch']['lot_number'] }}) + @endif +

+ @include('admin.traceability._ingredient-tree', ['node' => $bwd]) + @else +
+

{{ __('Inbound raw lot (terminal).') }}

+ @if($bwd['supplier_reference'])

{{ __('Supplier reference') }}: {{ $bwd['supplier_reference'] }}

@endif + @if($bwd['inspection_id'])

{{ __('Inbound inspection') }} #{{ $bwd['inspection_id'] }}

@endif +
+ @endif +
+ @endif + + {{-- ════════════ SERIAL UNIT (per-unit history) ════════════ --}} + @if($result && $result['type'] === 'serial') + @php $u = $result['data']; @endphp +
+ {{ __('Serial unit') }} +

{{ $u->serial_no }}

+

+ {{ __('Product') }}: {{ $u->workOrder?->productType?->name ?? $u->material?->name ?? '—' }} + @if($u->workOrder) · {{ __('Work Order') }}: {{ $u->workOrder->order_no }}@endif + · {{ $u->status }} +

+
+ +
+

{{ __('Process history') }} ({{ $u->history->count() }})

+ @if($u->history->isEmpty()) +

{{ __('No processing steps recorded for this unit yet.') }}

+ @else +
+ @foreach($u->history as $h) +
+
+ {{ $h->workstation?->name ?? __('Unknown') }} + @if($h->batchStep){{ $h->batchStep->name }}@endif + @if($h->operator){{ __('by') }} {{ $h->operator->name }}@endif + {{ \Carbon\Carbon::parse($h->processed_at)->format('Y-m-d H:i:s') }} + @if($h->result) + {{ $h->result }} + @endif +
+ @if(!empty($h->parameters)) +
+ @foreach($h->parameters as $pk => $pv) + {{ $pk }}: {{ is_scalar($pv) ? $pv : json_encode($pv) }} + @endforeach +
+ @endif +
+ @endforeach +
+ @endif +
+ @endif +
+@endsection 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/auth/two-factor-challenge.blade.php b/backend/resources/views/auth/two-factor-challenge.blade.php new file mode 100644 index 00000000..3eaac944 --- /dev/null +++ b/backend/resources/views/auth/two-factor-challenge.blade.php @@ -0,0 +1,89 @@ +@extends('layouts.auth') + +@section('title', __('Two-Factor Authentication')) + +@section('content') +
+

{{ __('Two-Factor Authentication') }}

+

+ {{ __('Enter the 6-digit code from your authenticator app.') }} +

+

+ {{ __('Enter one of your recovery codes.') }} +

+ + {{-- TOTP Code form --}} +
+ @csrf +
+ + @error('code') +

{{ $message }}

+ @enderror +
+ + +
+ + {{-- Recovery Code form --}} +
+ @csrf +
+ + @error('recovery_code') +

{{ $message }}

+ @enderror +
+ + +
+ + {{-- Toggle link --}} +
+ +
+ + {{-- Back to login --}} + +
+ + +@endsection diff --git a/backend/resources/views/auth/two-factor-recovery-codes.blade.php b/backend/resources/views/auth/two-factor-recovery-codes.blade.php new file mode 100644 index 00000000..160ceaab --- /dev/null +++ b/backend/resources/views/auth/two-factor-recovery-codes.blade.php @@ -0,0 +1,80 @@ +@extends('layouts.app') + +@section('title', __('Recovery Codes')) + +@section('content') +
+
+

{{ __('Recovery Codes') }}

+

{{ __('Two-factor authentication is now enabled.') }}

+
+ +
+ {{-- Warning --}} +
+
+ + + +
+

{{ __('Save these codes in a safe place!') }}

+

{{ __('Each recovery code can only be used once. If you lose your authenticator device, use one of these codes to log in.') }}

+
+
+
+ + {{-- Codes grid --}} +
+ @foreach($recoveryCodes as $code) +
+ {{ $code }} +
+ @endforeach +
+ + {{-- Actions --}} +
+ + +
+ + + {{ __('Done') }} + +
+
+ + +@endsection diff --git a/backend/resources/views/auth/two-factor-setup.blade.php b/backend/resources/views/auth/two-factor-setup.blade.php new file mode 100644 index 00000000..e31518ab --- /dev/null +++ b/backend/resources/views/auth/two-factor-setup.blade.php @@ -0,0 +1,67 @@ +@extends('layouts.app') + +@section('title', __('Enable Two-Factor Authentication')) + +@section('content') +
+
+ + + + + {{ __('Back to Profile') }} + +

{{ __('Enable Two-Factor Authentication') }}

+

{{ __('Scan the QR code below with your authenticator app (Google Authenticator, Authy, Microsoft Authenticator).') }}

+
+ +
+ {{-- QR Code --}} +
+ QR Code +
+ + {{-- Manual entry key --}} +
+

{{ __("Can't scan? Enter this key manually:") }}

+
+ {{ $secret }} + +
+
+ + {{-- Confirmation form --}} +
+ @csrf +
+ +

{{ __('Enter the 6-digit code from your authenticator app to verify setup.') }}

+ + @error('code') +

{{ $message }}

+ @enderror +
+ + +
+
+
+ + +@endsection diff --git a/backend/resources/views/components/maintenance-reminder.blade.php b/backend/resources/views/components/maintenance-reminder.blade.php new file mode 100644 index 00000000..190d822c --- /dev/null +++ b/backend/resources/views/components/maintenance-reminder.blade.php @@ -0,0 +1,82 @@ +
+
+
+
🔧
+
+

{{ __('Maintenance Reminder') }}

+ @foreach($upcoming as $event) +
+ {{ $event->title }} +
+ + {{ $event->line?->name ?? $event->workstation?->name ?? '' }} + — {{ $event->scheduled_at->format('H:i') }} + ({{ $event->scheduled_at->diffForHumans() }}) + +
+ @endforeach +
+ +
+
+
+ + diff --git a/backend/resources/views/layouts/app.blade.php b/backend/resources/views/layouts/app.blade.php index 2980a6de..0edad30b 100644 --- a/backend/resources/views/layouts/app.blade.php +++ b/backend/resources/views/layouts/app.blade.php @@ -243,6 +243,9 @@ class="flex items-center justify-center gap-2 px-4 py-1.5 text-sm font-medium te
+{{-- Maintenance reminder (all authenticated users) --}} + + +@endpush diff --git a/backend/resources/views/packaging/station.blade.php b/backend/resources/views/packaging/station.blade.php index 070d3737..13797cea 100644 --- a/backend/resources/views/packaging/station.blade.php +++ b/backend/resources/views/packaging/station.blade.php @@ -4,7 +4,7 @@ @section('content')
{{-- Header ──────────────────────────────────────────────────────────── --}} @@ -16,20 +16,223 @@ d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10" /> {{ __('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

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

- - - {{ __('Scanning active') }} + + +
+ {{-- ═══════ 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 ────────────────────────────────────────────────────────── --}}
@@ -210,8 +413,10 @@ @push('scripts') -@endpush -@endsection diff --git a/opcua-gateway/Dockerfile b/opcua-gateway/Dockerfile new file mode 100644 index 00000000..100a7e81 --- /dev/null +++ b/opcua-gateway/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine + +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev + +COPY index.js ./ + +CMD ["node", "index.js"] diff --git a/opcua-gateway/README.md b/opcua-gateway/README.md new file mode 100644 index 00000000..64d9c7e6 --- /dev/null +++ b/opcua-gateway/README.md @@ -0,0 +1,56 @@ +# OpenMES OPC UA Gateway + +OpenMES does not speak OPC UA natively. This small Node.js sidecar bridges an +OPC UA server to OpenMES: it subscribes to the nodes you configure in +**Admin → Connectivity → OPC UA** and forwards every value change to the +OpenMES machine-signal ingest API, where it flows through the same pipeline as +Modbus and MQTT (state machine, auto-downtime, OEE, live monitor). + +## Why a separate service? + +OPC UA is a binary protocol with security policies (Basic256Sha256), X.509 +certificates and a secure-channel/subscription model that PHP cannot implement +practically. A dedicated gateway is the standard, production-correct approach. + +## Configure in OpenMES first + +1. **Admin → Connectivity → OPC UA → New Connection** — set the endpoint + (`opc.tcp://host:4840`), security and auth. +2. Add **nodes** (tags): node id (`ns=2;s=State`), data type, signal type + (state/good_count/…), and the workstation it belongs to. +3. **Admin → API Tokens** — create a token for the gateway. + +## Run + +### Docker (recommended) + +```bash +OPCUA_CONNECTION_ID= OPENMES_API_TOKEN= \ + docker compose --profile connectivity up -d opcua-gateway +``` + +### Bare metal + +```bash +cd opcua-gateway +npm install +OPENMES_API_URL=http://localhost:8000 \ +OPENMES_API_TOKEN= \ +OPCUA_CONNECTION_ID= \ + npm start +``` + +## Environment + +| Var | Required | Default | Notes | +|---|---|---|---| +| `OPENMES_API_URL` | yes | `http://localhost:8000` | OpenMES backend base URL | +| `OPENMES_API_TOKEN` | yes | — | Sanctum API token | +| `OPCUA_CONNECTION_ID` | yes | `1` | machine_connection id (protocol = opcua) | +| `OPCUA_PASSWORD` | if username auth | — | OPC UA user password | +| `HEARTBEAT_MS` | no | `10000` | runtime heartbeat interval | +| `RECONNECT_MS` | no | `5000` | reconnect backoff | + +The connection page in OpenMES shows whether this gateway is currently running +(it heartbeats every `HEARTBEAT_MS`). If it is stopped, the page tells the user +exactly how to start it. diff --git a/opcua-gateway/index.js b/opcua-gateway/index.js new file mode 100644 index 00000000..9e9775fe --- /dev/null +++ b/opcua-gateway/index.js @@ -0,0 +1,156 @@ +/** + * OpenMES OPC UA Gateway + * ---------------------- + * OpenMES cannot speak OPC UA natively (binary protocol, security policies, + * X.509 secure channel). This sidecar connects to an OPC UA server, subscribes + * to the nodes configured in OpenMES, and forwards every value change to the + * OpenMES machine-signal ingest API — where it flows through the exact same + * pipeline as Modbus and MQTT. + * + * Required env: + * OPENMES_API_URL e.g. http://backend:8000 + * OPENMES_API_TOKEN Sanctum token (Admin → API Tokens) + * OPCUA_CONNECTION_ID machine_connection id (protocol = opcua) + * Optional: + * HEARTBEAT_MS default 10000 + * RECONNECT_MS default 5000 + */ +import { + OPCUAClient, + MessageSecurityMode, + SecurityPolicy, + AttributeIds, + ClientSubscription, + TimestampsToReturn, +} from "node-opcua-client"; + +const API_URL = process.env.OPENMES_API_URL || "http://localhost:8000"; +const API_TOKEN = process.env.OPENMES_API_TOKEN || ""; +const CONNECTION_ID = process.env.OPCUA_CONNECTION_ID || "1"; +const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || "10000", 10); +const RECONNECT_MS = parseInt(process.env.RECONNECT_MS || "5000", 10); + +const api = (path) => `${API_URL}/api/v1${path}`; +const authHeaders = { + Authorization: `Bearer ${API_TOKEN}`, + "Content-Type": "application/json", + Accept: "application/json", +}; + +const log = (...a) => console.log(new Date().toISOString(), ...a); + +async function fetchConfig() { + const res = await fetch(api(`/machine-connections/${CONNECTION_ID}/gateway-config`), { + headers: authHeaders, + }); + if (!res.ok) throw new Error(`config fetch failed: ${res.status}`); + return res.json(); +} + +async function postReadings(readings) { + if (readings.length === 0) return; + try { + await fetch(api(`/machine-connections/${CONNECTION_ID}/signals`), { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ readings }), + }); + } catch (e) { + log("post readings error:", e.message); + } +} + +async function heartbeat() { + try { + await fetch(api(`/machine-connections/${CONNECTION_ID}/heartbeat`), { + method: "POST", + headers: authHeaders, + }); + } catch (e) { + log("heartbeat error:", e.message); + } +} + +const securityPolicyMap = { + None: SecurityPolicy.None, + Basic256Sha256: SecurityPolicy.Basic256Sha256, +}; +const securityModeMap = { + None: MessageSecurityMode.None, + Sign: MessageSecurityMode.Sign, + SignAndEncrypt: MessageSecurityMode.SignAndEncrypt, +}; + +async function run() { + const cfg = await fetchConfig(); + if (!cfg.opcua) throw new Error("connection has no OPC UA config"); + log(`config: ${cfg.connection.name}, ${cfg.tags.length} nodes, endpoint ${cfg.opcua.endpoint_url}`); + + const client = OPCUAClient.create({ + applicationName: "OpenMES-Gateway", + securityMode: securityModeMap[cfg.opcua.security_mode] ?? MessageSecurityMode.None, + securityPolicy: securityPolicyMap[cfg.opcua.security_policy] ?? SecurityPolicy.None, + endpointMustExist: false, + connectionStrategy: { maxRetry: 3, initialDelay: 1000, maxDelay: 10000 }, + }); + + client.on("backoff", (retry, delay) => log(`retry ${retry}, next in ${delay}ms`)); + + await client.connect(cfg.opcua.endpoint_url); + log("connected to OPC UA server"); + + const userIdentity = + cfg.opcua.auth_mode === "username" && cfg.opcua.username + ? { userName: cfg.opcua.username, password: process.env.OPCUA_PASSWORD || "" } + : undefined; + + const session = await client.createSession(userIdentity); + log("session created"); + + const subscription = ClientSubscription.create(session, { + requestedPublishingInterval: cfg.opcua.publishing_interval_ms || 1000, + requestedMaxKeepAliveCount: 10, + requestedLifetimeCount: 100, + maxNotificationsPerPublish: 100, + publishingEnabled: true, + priority: 10, + }); + + for (const tag of cfg.tags) { + const item = await subscription.monitor( + { nodeId: tag.node_id, attributeId: AttributeIds.Value }, + { samplingInterval: cfg.opcua.publishing_interval_ms || 1000, discardOldest: true, queueSize: 10 }, + TimestampsToReturn.Both + ); + item.on("changed", (dataValue) => { + const value = dataValue.value?.value; + postReadings([{ tag_id: tag.id, value }]); + }); + log(`monitoring ${tag.name} (${tag.node_id}) → ${tag.signal_type}`); + } + + const hb = setInterval(heartbeat, HEARTBEAT_MS); + heartbeat(); + + const shutdown = async () => { + clearInterval(hb); + try { await subscription.terminate(); await session.close(); await client.disconnect(); } catch {} + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +async function main() { + for (;;) { + try { + await run(); + return; // run() keeps the process alive via subscription + heartbeat + } catch (e) { + log("fatal:", e.message, `— retrying in ${RECONNECT_MS}ms`); + await new Promise((r) => setTimeout(r, RECONNECT_MS)); + } + } +} + +main(); diff --git a/opcua-gateway/package.json b/opcua-gateway/package.json new file mode 100644 index 00000000..0f696bbf --- /dev/null +++ b/opcua-gateway/package.json @@ -0,0 +1,14 @@ +{ + "name": "openmes-opcua-gateway", + "version": "1.0.0", + "description": "OPC UA → OpenMES gateway. Subscribes to OPC UA nodes and forwards normalized readings to the OpenMES machine-signal ingest API.", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "license": "MIT", + "dependencies": { + "node-opcua-client": "^2.130.0" + } +}