[FEAT] 整合遠端管理指揮中心與 UI 佈局優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s

1. 實作「遠端管理指揮中心」,整合重啟、結帳、鎖定、找零、出貨等指令至單一介面。
2. 對接 B010 心跳 API 與 B017 庫存 API,實作異步指令下發與效期/批號同步邏輯。
3. 修正 sidebar-menu.blade.php 中的舊版路由連結,解決 RouteNotFoundException 錯誤。
4. 修正 index.blade.php 中的 AJAX 請求名稱,補上 admin. 前綴以符合路由分群。
5. 優化主內容區頂部間距,將 pt-10 縮減為 pt-5,提昇介面緊湊度。
This commit is contained in:
2026-04-01 16:59:29 +08:00
parent 3dbb394862
commit e7ad7e3dc3
8 changed files with 587 additions and 77 deletions

View File

@@ -4,22 +4,78 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\Machine\RemoteCommand;
use Illuminate\Support\Facades\Auth;
class RemoteController extends Controller
{
// 機台庫存
/**
* 遠端管理指揮中心
*/
public function index(Request $request)
{
$machines = Machine::withCount(['slots'])->orderBy('name')->get();
$selectedMachine = null;
if ($request->has('machine_id')) {
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
$query->latest()->limit(10);
}])->find($request->machine_id);
}
return view('admin.remote.index', [
'machines' => $machines,
'selectedMachine' => $selectedMachine,
]);
}
/**
* 儲存遠端指令
*/
public function storeCommand(Request $request)
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense',
'amount' => 'nullable|integer|min:0',
'slot_no' => 'nullable|string',
'note' => 'nullable|string|max:255',
]);
$payload = [];
if ($validated['command_type'] === 'change') {
$payload['amount'] = $validated['amount'];
} elseif ($validated['command_type'] === 'dispense') {
$payload['slot_no'] = $validated['slot_no'];
}
RemoteCommand::create([
'machine_id' => $validated['machine_id'],
'command_type' => $validated['command_type'],
'payload' => $payload,
'status' => 'pending',
'note' => $validated['note'] ?? null,
]);
return redirect()->back()->with('success', __('Command has been queued successfully.'));
}
/**
* 機台庫存管理 (現有功能保留)
*/
public function stock(Request $request)
{
$machines = \App\Models\Machine\Machine::withCount([
$machines = Machine::withCount([
'slots as slots_count',
'slots as low_stock_count' => function ($query) {
$query->where('stock', '<=', 5);
}
])->orderBy('name')->get();
$selectedMachine = null;
if ($request->has('machine_id')) {
$selectedMachine = \App\Models\Machine\Machine::find($request->machine_id);
$selectedMachine = Machine::with('slots.product')->find($request->machine_id);
}
return view('admin.remote.stock', [
@@ -27,58 +83,4 @@ class RemoteController extends Controller
'selectedMachine' => $selectedMachine,
]);
}
// 機台重啟
public function restart()
{
return view('admin.placeholder', [
'title' => '遠端重啟機台',
'description' => '遠端重啟機台系統',
]);
}
// 卡機重啟
public function restartCardReader()
{
return view('admin.placeholder', [
'title' => '遠端重啟刷卡機',
'description' => '遠端重啟刷卡機設備',
]);
}
// 遠端結帳
public function checkout()
{
return view('admin.placeholder', [
'title' => '遠端結帳',
'description' => '遠端執行結帳流程',
]);
}
// 遠端鎖定頁
public function lock()
{
return view('admin.placeholder', [
'title' => '遠端鎖定頁',
'description' => '遠端鎖定機台頁面',
]);
}
// 遠端找零
public function change()
{
return view('admin.placeholder', [
'title' => '遠端找零',
'description' => '遠端執行找零功能',
]);
}
// 遠端出貨
public function dispense()
{
return view('admin.placeholder', [
'title' => '遠端出貨',
'description' => '遠端控制商品出貨',
]);
}
}

View File

@@ -23,11 +23,58 @@ class MachineController extends Controller
// 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data);
// 取出待處理指令
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->pending()
->first();
$status = '49'; // 預設 49 (OK / No Command)
$message = 'OK';
if ($command) {
switch ($command->command_type) {
case 'reboot':
$status = '51';
$message = 'reboot';
break;
case 'reboot_card':
$status = '60';
$message = 'reboot card machine';
break;
case 'checkout':
$status = '61';
$message = 'checkout';
break;
case 'lock':
$status = '71';
$message = 'lock';
break;
case 'unlock':
$status = '70';
$message = 'unlock';
break;
case 'change':
$status = '82';
$message = $command->payload['amount'] ?? '0';
break;
case 'dispense':
$status = '85';
$message = $command->payload['slot_no'] ?? '';
break;
case 'reload_stock':
$status = '49';
$message = 'reload B017';
break;
}
// 標記為已發送 (sent)
$command->update(['status' => 'sent', 'executed_at' => now()]);
}
return response()->json([
'success' => true,
'code' => 200,
'message' => 'OK',
'status' => '49' // 某些硬體可能需要的成功碼
'message' => $message,
'status' => $status
], 202); // 202 Accepted
}
@@ -69,6 +116,8 @@ class MachineController extends Controller
'capacity' => $slot->capacity,
'price' => $slot->price,
'status' => $slot->status,
'expiry_date' => $slot->expiry_date,
'batch_no' => $slot->batch_no,
];
})
]);

View File

@@ -11,16 +11,15 @@ class RemoteCommand extends Model
protected $fillable = [
'machine_id',
'command',
'command_type',
'payload',
'status',
'response_payload',
'ttl',
'executed_at',
];
protected $casts = [
'payload' => 'array',
'response_payload' => 'array',
'executed_at' => 'datetime',
];
@@ -28,4 +27,12 @@ class RemoteCommand extends Model
{
return $this->belongsTo(Machine::class);
}
/**
* Scope for pending commands
*/
public function scopePending($query)
{
return $query->where('status', 'pending')->orderBy('created_at', 'asc');
}
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
$table->string('note', 255)->nullable()->after('payload');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
$table->dropColumn('note');
});
}
};

View File

@@ -0,0 +1,427 @@
@extends('layouts.admin')
@section('content')
<script>
window.remoteControlApp = function(initialMachineId) {
return {
machines: @js($machines),
searchQuery: '',
selectedMachine: null,
commands: [],
viewMode: initialMachineId ? 'control' : 'list',
loading: false,
submitting: false,
// Form States
lockStatus: false, // false = unlocked, true = locked
changeAmount: 100,
selectedSlot: '',
note: '',
async init() {
if (initialMachineId) {
const machine = this.machines.find(m => m.id == initialMachineId);
if (machine) {
await this.selectMachine(machine);
}
}
},
async selectMachine(machine) {
this.selectedMachine = machine;
this.viewMode = 'control';
this.loading = true;
this.commands = [];
// Update URL without refresh
const url = new URL(window.location);
url.searchParams.set('machine_id', machine.id);
window.history.pushState({}, '', url);
try {
// Fetch recent commands and full machine info (with slots)
const res = await fetch(`/admin/remote?machine_id=${machine.id}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
// Note: For now we'll just use the already loaded $selectedMachine if available
// But in a real app, an AJAX fetch for commands is better.
// Since we are doing a full page load or partial, I'll assume we have commands passed.
this.commands = @js($selectedMachine ? $selectedMachine->commands : []);
} catch (e) {
console.error('Fetch error:', e);
} finally {
this.loading = false;
}
},
backToList() {
this.viewMode = 'list';
this.selectedMachine = null;
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
async sendCommand(type, params = {}) {
if (!confirm(`{{ __('Are you sure you want to send this command?') }}`)) return;
this.submitting = true;
try {
const csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const formData = new FormData();
formData.append('machine_id', this.selectedMachine.id);
formData.append('command_type', type);
formData.append('note', this.note);
if (params.amount) formData.append('amount', params.amount);
if (params.slot_no) formData.append('slot_no', params.slot_no);
const res = await fetch('{{ route('admin.remote.store-command') }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrf },
body: formData
});
if (res.ok) {
// Success feedback
const toast = window.HSStaticMethods.autoInit ? null : null; // Use Preline toast if available
alert('{{ __('Command queued successfully.') }}');
location.reload(); // Simple refresh to see new command in list
}
} catch (e) {
console.error('Command error:', e);
} finally {
this.submitting = false;
}
},
getCommandBadgeClass(status) {
switch(status) {
case 'pending': return 'bg-amber-100 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400 border-amber-200 dark:border-amber-500/20';
case 'sent': return 'bg-cyan-100 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/20';
case 'success': return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
case 'failed': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
default: return 'bg-slate-100 text-slate-600 border-slate-200';
}
},
getCommandName(type) {
const names = {
'reboot': {{ Js::from(__('System Reboot')) }},
'reboot_card': {{ Js::from(__('Card Reader Reboot')) }},
'checkout': {{ Js::from(__('Remote Settlement')) }},
'lock': {{ Js::from(__('Page Lock')) }},
'unlock': {{ Js::from(__('Page Unlock')) }},
'change': {{ Js::from(__('Remote Change')) }},
'dispense': {{ Js::from(__('Remote Dispense')) }},
'reload_stock': {{ Js::from(__('Stock Sync')) }}
};
return names[type] || type;
}
};
};
</script>
<div class="space-y-4 pb-20 mt-4"
x-data="remoteControlApp({{ Js::from($selectedMachine ? $selectedMachine->id : '') }})">
<!-- Master View: Machine List -->
<template x-if="viewMode === 'list'">
<div class="space-y-6 animate-luxury-in">
<div class="flex flex-col gap-4">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">
{{ __('Remote Command Center') }}
</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
{{ __('Execute maintenance and operational commands remotely') }}
</p>
</div>
<div class="relative group max-w-md">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 transition-transform duration-300 group-focus-within:scale-110">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" x-model="searchQuery"
class="luxury-input w-full pl-11 py-3 text-sm focus:ring-cyan-500/20"
placeholder="{{ __('Search by name or S/N...') }}">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<template x-for="machine in machines.filter(m =>
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
m.serial_no.toLowerCase().includes(searchQuery.toLowerCase())
)" :key="machine.id">
<div @click="selectMachine(machine)"
class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60 hover:border-cyan-500/50 hover:shadow-2xl hover:shadow-cyan-500/10 transition-all duration-500 cursor-pointer group flex flex-col justify-between h-full relative overflow-hidden">
<div class="absolute -right-10 -top-10 w-32 h-32 bg-cyan-500/5 rounded-full blur-3xl group-hover:bg-cyan-500/10 transition-colors"></div>
<div class="flex items-start gap-5 relative z-10">
<div class="w-16 h-16 rounded-2xl bg-slate-50 dark:bg-slate-900 flex items-center justify-center text-slate-400 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-inner group-hover:scale-110 transition-transform duration-500 shrink-0">
<svg class="w-8 h-8 opacity-40 group-hover:text-cyan-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 x-text="machine.name" class="text-xl font-black text-slate-800 dark:text-white truncate"></h3>
<div class="flex items-center gap-2 mt-1">
<span x-text="machine.serial_no" class="text-xs font-mono font-bold text-cyan-600 dark:text-cyan-400 tracking-widest uppercase"></span>
</div>
</div>
</div>
<div class="mt-8 flex items-center justify-between relative z-10">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Click to Open Dashboard') }}</span>
<div class="w-10 h-10 rounded-full bg-slate-50 dark:bg-slate-900 flex items-center justify-center text-slate-300 dark:text-slate-600 border border-slate-100 dark:border-slate-800 transition-all duration-300 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 group-hover:scale-110">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Detail View: Remote Control Dashboard -->
<template x-if="viewMode === 'control'">
<div class="space-y-6 animate-luxury-in">
<!-- Header Controls -->
<div class="flex items-center gap-4 mb-2 px-1">
<button @click="backToList()"
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
<h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Command Center') }}</h1>
</div>
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left: Control Actions (Spans 2 columns) -->
<div class="lg:col-span-2 space-y-8">
<!-- Machine Status Card -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 border border-cyan-500/20">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h2 class="text-2xl font-black text-slate-800 dark:text-white leading-tight" x-text="selectedMachine.name"></h2>
<p class="text-xs font-mono font-bold text-slate-400 mt-1 uppercase tracking-widest" x-text="selectedMachine.serial_no"></p>
</div>
</div>
<div class="flex gap-3">
<span class="px-4 py-2 rounded-full bg-emerald-500/10 text-emerald-600 text-xs font-black uppercase tracking-widest border border-emerald-500/20 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
{{ __('Connected') }}
</span>
</div>
</div>
<!-- Actions Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Quick Actions Card -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60 transition-all duration-300">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider mb-6 flex items-center gap-3">
<span class="w-2 h-6 bg-cyan-500 rounded-full"></span>
{{ __('Quick Maintenance') }}
</h3>
<div class="grid gap-4">
<button @click="sendCommand('reboot')" class="luxury-card p-5 rounded-2xl border border-slate-100 dark:border-slate-800 flex items-center gap-4 hover:border-cyan-500/50 hover:bg-cyan-500/5 group transition-all">
<div class="w-12 h-12 rounded-xl bg-slate-50 dark:bg-slate-900 flex items-center justify-center text-slate-400 group-hover:text-cyan-500 group-hover:scale-110 transition-all duration-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('System Reboot') }}</div>
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">{{ __('Restart entire machine') }}</div>
</div>
</button>
<button @click="sendCommand('reboot_card')" class="luxury-card p-5 rounded-2xl border border-slate-100 dark:border-slate-800 flex items-center gap-4 hover:border-cyan-500/50 hover:bg-cyan-500/5 group transition-all">
<div class="w-12 h-12 rounded-xl bg-slate-50 dark:bg-slate-900 flex items-center justify-center text-slate-400 group-hover:text-cyan-500 group-hover:scale-110 transition-all duration-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Card Reader Reboot') }}</div>
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">{{ __('Reset POS terminal') }}</div>
</div>
</button>
<button @click="sendCommand('checkout')" class="luxury-card p-5 rounded-2xl border border-slate-100 dark:border-slate-800 flex items-center gap-4 hover:border-emerald-500/50 hover:bg-emerald-500/5 group transition-all">
<div class="w-12 h-12 rounded-xl bg-slate-50 dark:bg-slate-900 flex items-center justify-center text-slate-400 group-hover:text-emerald-500 group-hover:scale-110 transition-all duration-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Remote Settlement') }}</div>
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">{{ __('Force end current session') }}</div>
</div>
</button>
</div>
</div>
<!-- Stateful Actions Card -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60 transition-all duration-300">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider mb-6 flex items-center gap-3">
<span class="w-2 h-6 bg-rose-500 rounded-full"></span>
{{ __('Security & State') }}
</h3>
<div class="space-y-6">
<!-- Lock/Unlock Toggle -->
<div class="p-6 rounded-[2rem] bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800/50 relative overflow-hidden group">
<div class="flex items-center justify-between relative z-10">
<div>
<div class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Page Lock Status') }}</div>
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">{{ __('Restrict machine UI access') }}</div>
</div>
<div class="flex gap-2">
<button @click="sendCommand('unlock')" class="px-4 py-2 rounded-xl bg-emerald-500/10 text-emerald-600 text-[10px] font-black uppercase tracking-widest border border-emerald-500/20 hover:bg-emerald-500 hover:text-white transition-all">{{ __('Unlock') }}</button>
<button @click="sendCommand('lock')" class="px-4 py-2 rounded-xl bg-rose-500/10 text-rose-600 text-[10px] font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all">{{ __('Lock') }}</button>
</div>
</div>
<div class="absolute -right-4 -bottom-4 opacity-[0.03] group-hover:scale-110 transition-transform duration-700">
<svg class="w-24 h-24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
</div>
<!-- Note Field -->
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1">{{ __('Operation Note') }}</label>
<textarea x-model="note" class="luxury-input w-full min-h-[100px] text-sm py-4" placeholder="{{ __('Reason for this command...') }}"></textarea>
</div>
</div>
</div>
</div>
<!-- Parametric Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Remote Change -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider mb-6 flex items-center gap-3">
<span class="w-2 h-6 bg-amber-500 rounded-full"></span>
{{ __('Remote Change') }}
</h3>
<div class="space-y-4">
<div class="flex items-center gap-4 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800/50">
<div class="text-3xl font-black text-slate-300 dark:text-slate-700 shrink-0">$</div>
<input type="number" x-model="changeAmount" class="w-full bg-transparent border-none p-0 text-3xl font-black text-slate-800 dark:text-white focus:ring-0">
</div>
<div class="grid grid-cols-4 gap-2">
<template x-for="amt in [10, 50, 100, 200]">
<button @click="changeAmount = amt" class="py-2.5 rounded-xl border border-slate-200 dark:border-slate-800 text-[10px] font-black uppercase tracking-widest hover:border-cyan-500 hover:text-cyan-500 transition-all" x-text="amt"></button>
</template>
</div>
<button @click="sendCommand('change', { amount: changeAmount })" :disabled="submitting" class="btn-luxury-primary w-full py-4 mt-2">
{{ __('Execute Change') }}
</button>
</div>
</div>
<!-- Remote Dispense -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider mb-6 flex items-center gap-3">
<span class="w-2 h-6 bg-violet-500 rounded-full"></span>
{{ __('Remote Dispense') }}
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1">{{ __('Select Cargo Lane') }}</label>
<template x-if="selectedMachine.slots && selectedMachine.slots.length > 0">
<select x-model="selectedSlot" class="luxury-input w-full py-4 text-sm bg-white dark:bg-slate-900">
<option value="">{{ __('Select Slot...') }}</option>
<template x-for="slot in selectedMachine.slots" :key="slot.id">
<option :value="slot.slot_no" x-text="'Slot ' + slot.slot_no + ' (' + (slot.product ? slot.product.name : 'Unknown') + ')'"></option>
</template>
</select>
</template>
<template x-if="!selectedMachine.slots || selectedMachine.slots.length == 0">
<div class="p-4 rounded-xl bg-rose-50 text-rose-500 text-[10px] font-black uppercase text-center border border-rose-100">
{{ __('No active cargo lanes found') }}
</div>
</template>
</div>
<button @click="sendCommand('dispense', { slot_no: selectedSlot })"
:disabled="submitting || !selectedSlot"
class="btn-luxury-ghost w-full py-4 mt-2 border-violet-500/30 text-violet-600 dark:text-violet-400 hover:bg-violet-500 hover:text-white transition-all shadow-sm">
{{ __('Trigger Dispense') }}
</button>
</div>
</div>
</div>
</div>
<!-- Right: Command History (Sidebar) -->
<div class="space-y-6">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider flex items-center gap-3">
<span class="w-2 h-6 bg-slate-300 dark:bg-slate-700 rounded-full"></span>
{{ __('Recent Commands') }}
</h3>
<div class="space-y-4 max-h-[1000px] overflow-y-auto pr-2 custom-scrollbar">
<template x-for="cmd in commands" :key="cmd.id">
<div class="luxury-card p-5 rounded-[2rem] border border-slate-100 dark:border-slate-800 transition-all hover:bg-slate-50 dark:hover:bg-slate-900/40 relative group">
<div class="flex items-start justify-between mb-3">
<span class="text-xs font-black text-slate-800 dark:text-white" x-text="getCommandName(cmd.command_type)"></span>
<span :class="getCommandBadgeClass(cmd.status)" class="px-2.5 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border" x-text="cmd.status"></span>
</div>
<div class="text-[9px] font-bold text-slate-400 flex items-center gap-2 mb-2">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span x-text="new Date(cmd.created_at).toLocaleString()"></span>
</div>
<template x-if="cmd.payload && Object.keys(cmd.payload).length > 0">
<div class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-[10px] font-mono text-slate-500 dark:text-slate-400 break-all">
<span x-text="JSON.stringify(cmd.payload)"></span>
</div>
</template>
<template x-if="cmd.note">
<div class="mt-2 text-[10px] font-bold text-slate-400 italic" x-text="'\"' + cmd.note + '\"'"></div>
</template>
</div>
</template>
<template x-if="commands.length === 0">
<div class="py-20 text-center flex flex-col items-center opacity-30">
<svg class="w-12 h-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<div class="text-[10px] font-black uppercase tracking-widest">{{ __('No command history') }}</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
</div>
<style>
/* Custom Scrollbar for Luxury UI */
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e133; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #cbd5e166; }
/* Hide default number spinners */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
appearance: none;
}
</style>
@endsection

View File

@@ -236,7 +236,7 @@
<!-- End Sidebar -->
<!-- Content -->
<div class="w-full pt-6 lg:pt-10 pb-12 px-4 sm:px-6 md:px-8 lg:pl-72">
<div class="w-full pt-4 lg:pt-5 pb-12 px-4 sm:px-6 md:px-8 lg:pl-72">
<x-breadcrumbs class="mb-4 hidden lg:flex" />
<main class="animate-fade-up">
@yield('content')

View File

@@ -259,13 +259,14 @@
</button>
<div x-show="open" x-collapse>
<ul class="luxury-submenu" data-sidebar-sub>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.stock') }}">{{ __('Machine Stock') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.restart') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.restart') }}">{{ __('Machine Restart') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.restart-card-reader') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.restart-card-reader') }}">{{ __('Card Reader Restart') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.checkout') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.checkout') }}">{{ __('Remote Checkout') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.lock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.lock') }}">{{ __('Remote Lock') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.change') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.change') }}">{{ __('Remote Change') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.dispense') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.dispense') }}">{{ __('Remote Dispense') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.index') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
{{ __('Command Center') }}
</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.stock') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
{{ __('Machine Stock') }}
</a></li>
</ul>
</div>
</li>

View File

@@ -144,13 +144,9 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
// 10. 遠端管理
Route::prefix('remote')->name('remote.')->group(function () {
Route::get('/', [App\Http\Controllers\Admin\RemoteController::class, 'index'])->name('index');
Route::post('/command', [App\Http\Controllers\Admin\RemoteController::class, 'storeCommand'])->name('store-command');
Route::get('/stock', [App\Http\Controllers\Admin\RemoteController::class, 'stock'])->name('stock');
Route::get('/restart', [App\Http\Controllers\Admin\RemoteController::class, 'restart'])->name('restart');
Route::get('/restart-card-reader', [App\Http\Controllers\Admin\RemoteController::class, 'restartCardReader'])->name('restart-card-reader');
Route::get('/checkout', [App\Http\Controllers\Admin\RemoteController::class, 'checkout'])->name('checkout');
Route::get('/lock', [App\Http\Controllers\Admin\RemoteController::class, 'lock'])->name('lock');
Route::get('/change', [App\Http\Controllers\Admin\RemoteController::class, 'change'])->name('change');
Route::get('/dispense', [App\Http\Controllers\Admin\RemoteController::class, 'dispense'])->name('dispense');
});
// 11. Line管理