[FEAT] 重構機台狀態判定邏輯並優化全站多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m18s

1. 重構機台在線狀態判定機制:移除資料庫 status 欄位,改由 Model 根據心跳時間動態計算。
2. 修正儀表板 (Dashboard) 與機台管理頁面的多語系顯示問題,解決換行導致翻譯失效的 Bug。
3. 修正個人檔案頁面的麵包屑 (Breadcrumbs) 導航,補齊「個人設定」層級。
4. 更新 IoT API (B010, B600) 的認證機制與日誌處理邏輯。
5. 同步更新繁中、英文、日文語言檔,確保 UI 標籤一致性。
This commit is contained in:
2026-04-07 10:21:07 +08:00
parent 08b6c60d2e
commit bbdc5bad9f
18 changed files with 2476 additions and 1813 deletions

View File

@@ -105,7 +105,6 @@ class MachineSettingController extends AdminController
}
$machine = Machine::create(array_merge($validated, [
'status' => 'offline',
'api_token' => \Illuminate\Support\Str::random(60),
'creator_id' => auth()->id(),
'updater_id' => auth()->id(),

View File

@@ -12,28 +12,31 @@ class DashboardController extends Controller
{
// 每頁顯示筆數限制 (預設為 10)
$perPage = (int) request()->input('per_page', 10);
if ($perPage <= 0) $perPage = 10;
if ($perPage <= 0)
$perPage = 10;
// 從資料庫獲取真實統計數據
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
$activeMachines = Machine::where('status', 'online')->count();
$alertsPending = Machine::where('status', 'error')->count();
$activeMachines = Machine::online()->count();
$offlineMachines = Machine::offline()->count();
$alertsPending = Machine::hasError()->count();
$memberCount = \App\Models\Member\Member::count();
// 獲取機台列表 (分頁)
$machines = Machine::when($request->search, function($query, $search) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%");
});
})
->latest()
$machines = Machine::when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%");
});
})
->orderByDesc('last_heartbeat_at')
->paginate($perPage)
->withQueryString();
return view('admin.dashboard', compact(
'totalRevenue',
'activeMachines',
'offlineMachines',
'alertsPending',
'memberCount',
'machines'

View File

@@ -9,7 +9,9 @@ use Illuminate\View\View;
class MachineController extends AdminController
{
public function __construct(protected MachineService $machineService) {}
public function __construct(protected MachineService $machineService)
{
}
public function index(Request $request): View
{
@@ -21,7 +23,7 @@ class MachineController extends AdminController
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%");
->orWhere('serial_no', 'like', "%{$search}%");
});
}
@@ -34,14 +36,31 @@ class MachineController extends AdminController
return view('admin.machines.index', compact('machines'));
}
/**
* 更新機台基本資訊 (目前僅名稱)
*/
public function update(Request $request, Machine $machine)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$machine->update($validated);
return redirect()->route('admin.machines.index')
->with('success', __('Machine updated successfully.'));
}
/**
* 顯示特定機台的日誌與詳細資訊
*/
public function show(int $id): View
{
$machine = Machine::with(['logs' => function ($query) {
$query->latest()->limit(50);
}])->findOrFail($id);
$machine = Machine::with([
'logs' => function ($query) {
$query->latest()->limit(50);
}
])->findOrFail($id);
return view('admin.machines.show', compact('machine'));
}

View File

@@ -18,7 +18,7 @@ class MachineController extends Controller
public function heartbeat(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key
// 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data);
@@ -84,7 +84,7 @@ class MachineController extends Controller
public function recordRestock(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no;
\App\Jobs\Machine\ProcessRestockReport::dispatch($data);
@@ -135,7 +135,7 @@ class MachineController extends Controller
public function syncTimer(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessTimerStatus::dispatch($machine->serial_no, $data);
@@ -148,7 +148,7 @@ class MachineController extends Controller
public function syncCoinInventory(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessCoinInventory::dispatch($machine->serial_no, $data);

View File

@@ -16,7 +16,7 @@ class TransactionController extends Controller
public function store(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no;
ProcessTransaction::dispatch($data);
@@ -34,7 +34,7 @@ class TransactionController extends Controller
public function recordInvoice(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no;
ProcessInvoice::dispatch($data);

View File

@@ -38,7 +38,6 @@ class Machine extends Model
'serial_no',
'model',
'location',
'status',
'current_page',
'door_status',
'temperature',
@@ -69,7 +68,88 @@ class Machine extends Model
'updater_id',
];
protected $appends = ['image_urls'];
protected $appends = ['image_urls', 'calculated_status'];
/**
* 動態計算機台當前狀態
* 1. 離線 (offline):超過 30 秒未收到心跳
* 2. 異常 (error):在線但過去 15 分鐘內有錯誤/警告日誌
* 3. 在線 (online):正常在線
*/
public function getCalculatedStatusAttribute(): string
{
// 判定離線
if (!$this->last_heartbeat_at || $this->last_heartbeat_at->diffInSeconds(now()) >= 30) {
return 'offline';
}
// 判定異常 (檢查過去 15 分鐘內是否有 error 或 warning 日誌)
$hasRecentErrors = $this->logs()
->whereIn('level', ['error', 'warning'])
->where('created_at', '>=', now()->subMinutes(15))
->exists();
if ($hasRecentErrors) {
return 'error';
}
return 'online';
}
/**
* Scope: 判定在線 (30 秒內有心跳)
*/
public function scopeOnline($query)
{
return $query->where('last_heartbeat_at', '>=', now()->subSeconds(30));
}
/**
* Scope: 判定離線 (超過 30 秒未收到心跳或從未收到心跳)
*/
public function scopeOffline($query)
{
return $query->where(function ($q) {
$q->whereNull('last_heartbeat_at')
->orWhere('last_heartbeat_at', '<', now()->subSeconds(30));
});
}
/**
* Scope: 判定異常 (過去 15 分鐘內有錯誤或警告日誌)
*/
public function scopeHasError($query)
{
return $query->whereExists(function ($q) {
$q->select(\Illuminate\Support\Facades\DB::raw(1))
->from('machine_logs')
->whereColumn('machine_logs.machine_id', 'machines.id')
->whereIn('level', ['error', 'warning'])
->where('created_at', '>=', now()->subMinutes(15));
});
}
/**
* Scope: 判定運行中 (在線且無近期異常)
*/
public function scopeRunning($query)
{
return $query->online()->whereNotExists(function ($q) {
$q->select(\Illuminate\Support\Facades\DB::raw(1))
->from('machine_logs')
->whereColumn('machine_logs.machine_id', 'machines.id')
->whereIn('level', ['error', 'warning'])
->where('created_at', '>=', now()->subMinutes(15));
});
}
/**
* Scope: 判定異常在線 (在線且有近期異常)
*/
public function scopeErrorOnline($query)
{
return $query->online()->hasError();
}
protected $casts = [
'last_heartbeat_at' => 'datetime',

View File

@@ -30,7 +30,6 @@ class MachineService
$model = $data['model'] ?? $machine->model;
$updateData = [
'status' => 'online',
'temperature' => $temperature,
'current_page' => $currentPage,
'door_status' => $doorStatus,
@@ -176,10 +175,10 @@ class MachineService
$start = Carbon::parse($date)->startOfDay();
$end = Carbon::parse($date)->endOfDay();
// 1. Online Count (Base on current status)
// 1. Online Count (Base on new heartbeat logic)
$machines = Machine::all(); // This is filtered by TenantScoped
$totalMachines = $machines->count();
$onlineCount = $machines->where('status', 'online')->count();
$onlineCount = Machine::online()->count();
$machineIds = $machines->pluck('id')->toArray();

View File

@@ -14,7 +14,6 @@ class MachineFactory extends Factory
return [
'name' => 'Machine-' . fake()->unique()->numberBetween(101, 999),
'location' => fake()->address(),
'status' => fake()->randomElement(['online', 'offline', 'error']),
'temperature' => fake()->randomFloat(2, 2, 10),
'firmware_version' => 'v' . fake()->randomElement(['1.0.0', '1.1.2', '2.0.1']),
'serial_no' => 'SN-' . strtoupper(fake()->unique()->bothify('??###?')),

View File

@@ -11,9 +11,9 @@ return new class extends Migration
*/
public function up(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
if (DB::getDriverName() !== 'sqlite') {
DB::statement("ALTER TABLE remote_commands MODIFY COLUMN status ENUM('pending', 'sent', 'success', 'failed', 'superseded') DEFAULT 'pending'");
});
}
}
/**
@@ -21,8 +21,8 @@ return new class extends Migration
*/
public function down(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
if (DB::getDriverName() !== 'sqlite') {
DB::statement("ALTER TABLE remote_commands MODIFY COLUMN status ENUM('pending', 'sent', 'success', 'failed') DEFAULT 'pending'");
});
}
}
};

View File

@@ -0,0 +1,30 @@
<?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('machines', function (Blueprint $table) {
if (Schema::hasColumn('machines', 'status')) {
$table->dropColumn('status');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->string('status')->default('offline')->after('location');
});
}
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -194,8 +194,7 @@
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Machine Settings') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{
__('Management of operational parameters and models') }}</p>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Management of operational parameters and models') }}</p>
</div>
<div class="flex items-center gap-3">
@if($tab === 'machines')
@@ -499,8 +498,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2m16-10a4 4 0 11-8 0 4 4 0 018 0zM23 21v-2a4 4 0 00-3-3.87m-4-12a4 4 0 010 7.75" />
</svg>
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No
accounts found') }}</p>
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No accounts found') }}</p>
</div>
</td>
</tr>
@@ -628,8 +626,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
<div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add
Machine') }}</h3>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add Machine') }}</h3>
<button @click="showCreateMachineModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -732,8 +729,7 @@
</div>
<div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showCreateMachineModal = false" class="btn-luxury-ghost">{{
__('Cancel') }}</button>
<button type="button" @click="showCreateMachineModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button>
</div>
</form>
@@ -757,8 +753,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
<div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add
Machine Model') }}</h3>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add Machine Model') }}</h3>
<button @click="showCreateModelModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -774,16 +769,14 @@
<div class="px-8 py-8 space-y-6">
<div>
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Model Name') }}</label>
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Model Name') }}</label>
<input type="text" name="name" required class="luxury-input w-full"
placeholder="{{ __('Enter model name') }}">
</div>
</div>
<div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{
__('Cancel') }}</button>
<button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Create') }}</button>
</div>
</form>
@@ -807,8 +800,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
<div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{
__('Edit Machine Model') }}</h3>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Edit Machine Model') }}</h3>
<button @click="showEditModelModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -824,16 +816,14 @@
<div class="px-8 py-8 space-y-6">
<div>
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Model Name') }}</label>
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Model Name') }}</label>
<input type="text" name="name" x-model="currentModel.name" required
class="luxury-input w-full" placeholder="{{ __('Enter model name') }}">
</div>
</div>
<div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showEditModelModal = false" class="btn-luxury-ghost">{{
__('Cancel') }}</button>
<button type="button" @click="showEditModelModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button>
</div>
</form>
@@ -1103,8 +1093,7 @@
</section>
</template>
<section class="space-y-6">
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware
& Network') }}</h3>
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware & Network') }}</h3>
<div class="grid grid-cols-1 gap-4">
<div
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
@@ -1291,9 +1280,7 @@
<div
class='w-10 h-10 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin'>
</div>
<span
class='text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] animate-pulse'>{{
__('Syncing Permissions...') }}</span>
<span class='text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] animate-pulse'>{{ __('Syncing Permissions...') }}</span>
</div>
</div>
</template>

View File

@@ -11,9 +11,11 @@
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Connectivity Status') }}</h3>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time status monitoring') }}</p>
</div>
<div class="flex items-center gap-x-1.5 px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 border border-cyan-500/20">
<div
class="flex items-center gap-x-1.5 px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 border border-cyan-500/20">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span>
</span>
<span class="text-[10px] font-black uppercase tracking-wider">{{ __('LIVE') }}</span>
@@ -35,14 +37,14 @@
<div class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.6)]"></div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Offline Machines') }}</span>
</div>
<span class="text-2xl font-black text-rose-500">{{ $alertsPending }}</span>
<span class="text-2xl font-black text-rose-500">{{ $offlineMachines }}</span>
</div>
<div class="flex items-center justify-between pr-10">
<div class="flex items-center gap-x-4">
<div class="w-2 h-2 rounded-full bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.6)]"></div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Alerts Pending') }}</span>
</div>
<span class="text-2xl font-black text-slate-900 dark:text-white">0</span>
<span class="text-2xl font-black text-slate-900 dark:text-white">{{ $alertsPending }}</span>
</div>
</div>
@@ -51,8 +53,11 @@
<!-- Right: Big Total -->
<div class="w-40 text-center">
<p class="text-7xl font-black text-cyan-500 drop-shadow-[0_0_20px_rgba(6,182,212,0.3)] leading-none">{{ $activeMachines }}</p>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] mt-4">{{ __('Total Connected') }}</p>
<p
class="text-7xl font-black text-cyan-500 drop-shadow-[0_0_20px_rgba(6,182,212,0.3)] leading-none">
{{ $activeMachines }}</p>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] mt-4">
{{ __('Total Connected') }}</p>
</div>
</div>
</div>
@@ -64,25 +69,38 @@
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Monthly Transactions') }}</h3>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Monthly cumulative revenue overview') }}</p>
</div>
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800/80 text-slate-400 dark:text-slate-500 border border-transparent dark:border-slate-700/50">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800/80 text-slate-400 dark:text-slate-500 border border-transparent dark:border-slate-700/50">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div class="flex-1 flex flex-col space-y-4 justify-center">
<!-- Today Stat Card -->
<div class="group flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:shadow-none border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/30">
<div
class="group flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:shadow-none border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/30">
<div class="flex items-center gap-x-4">
<div class="w-12 h-12 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 flex items-center justify-center text-cyan-600 dark:text-cyan-400 shadow-sm transition-transform group-hover:scale-110">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.5 4.5L21.75 7.5M21.75 7.5V12m0-4.5H17.25"/></svg>
<div
class="w-12 h-12 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 flex items-center justify-center text-cyan-600 dark:text-cyan-400 shadow-sm transition-transform group-hover:scale-110">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 18L9 11.25l4.5 4.5L21.75 7.5M21.75 7.5V12m0-4.5H17.25" />
</svg>
</div>
<div>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Today's Transactions") }}</p>
<p class="text-4xl font-black text-slate-900 dark:text-white mt-1 tracking-tight drop-shadow-sm">${{ number_format($totalRevenue / 30, 0) }}</p>
<p
class="text-4xl font-black text-slate-900 dark:text-white mt-1 tracking-tight drop-shadow-sm">
${{ number_format($totalRevenue / 30, 0) }}</p>
</div>
</div>
<div class="flex flex-col items-end gap-y-1">
<span class="text-[10px] font-black text-emerald-500 bg-emerald-500/10 px-2.5 py-0.5 rounded-full">+12.5%</span>
<span
class="text-[10px] font-black text-emerald-500 bg-emerald-500/10 px-2.5 py-0.5 rounded-full">+12.5%</span>
<p class="text-[9px] font-bold text-slate-300 dark:text-slate-500 uppercase tracking-tighter">{{ __('vs Yesterday') }}</p>
</div>
</div>
@@ -90,25 +108,39 @@
<!-- Previous Days Stats Row -->
<div class="grid grid-cols-2 gap-4">
<!-- Yesterday Card -->
<div class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
<div
class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
<div class="flex justify-between items-start mb-2">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Yesterday") }}</p>
<div class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div
class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue / 25, 0) }}</p>
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue
/ 25, 0) }}</p>
</div>
<!-- Before Yesterday Card -->
<div class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
<div
class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
<div class="flex justify-between items-start mb-2">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Day Before") }}</p>
<div class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
<div
class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</div>
</div>
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue / 40, 0) }}</p>
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue
/ 40, 0) }}</p>
</div>
</div>
</div>
@@ -121,7 +153,8 @@
<div>
<div class="flex items-center gap-x-3">
<h2 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Status List') }}</h2>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-tighter">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-tighter">
{{ __('Total items', ['count' => $machines->total()]) }}
</span>
</div>
@@ -131,12 +164,16 @@
<form action="{{ route('admin.dashboard') }}" method="GET" class="flex flex-wrap items-center gap-4">
<div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<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="3" stroke-linecap="round" stroke-linejoin="round">
<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="3"
stroke-linecap="round" stroke-linejoin="round">
<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" name="search" value="{{ request('search') }}" class="py-3 pl-12 pr-6 block w-64 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 rounded-2xl text-sm font-bold text-slate-700 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 transition-all outline-none" placeholder="{{ __('Quick search...') }}">
<input type="text" name="search" value="{{ request('search') }}"
class="py-3 pl-12 pr-6 block w-64 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 rounded-2xl text-sm font-bold text-slate-700 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 transition-all outline-none"
placeholder="{{ __('Quick search...') }}">
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
</div>
</form>
@@ -146,63 +183,115 @@
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/30">
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">{{ __('Machine Info') }}</th>
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Running Status') }}</th>
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Today Cumulative Sales') }}</th>
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Current Stock') }}</th>
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Last Signal') }}</th>
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Alert Summary') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
{{ __('Machine Info') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Running Status') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Today Cumulative Sales') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Current Stock') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Last Signal') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Alert Summary') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/50">
@forelse($machines as $machine)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6">
<div class="flex items-center gap-x-5">
<div class="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-300 group-hover:bg-cyan-500 group-hover:text-white transition-all shadow-sm">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path 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 2zM9 9h6v6H9V9z"/></svg>
</div>
<div class="flex flex-col">
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $machine->name }}</span>
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN: {{ $machine->serial_no }})</span>
</div>
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6">
<div class="flex items-center gap-x-5">
<div
class="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-300 group-hover:bg-cyan-500 group-hover:text-white transition-all shadow-sm">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2">
<path
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 2zM9 9h6v6H9V9z" />
</svg>
</div>
</td>
<td class="px-6 py-6 text-center">
@if($machine->status === 'online')
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">
{{ __('Online') }}
</span>
@else
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">
{{ __('Offline') }}
</span>
@endif
</td>
<td class="px-6 py-6 text-center">
<span class="text-base font-extrabold text-slate-900 dark:text-slate-100">$ 0</span>
</td>
<td class="px-6 py-6">
<div class="flex flex-col items-center gap-y-2.5">
<div class="w-32 h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
<div class="h-full bg-rose-500 rounded-full shadow-[0_0_8px_rgba(244,63,94,0.4)]" style="width: 15.5%"></div>
</div>
<span class="text-[11px] font-black text-rose-500 uppercase tracking-[0.2em]">15.5% {{ __('Low Stock') }}</span>
<div class="flex flex-col">
<span
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{
$machine->name }}</span>
<span
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN:
{{ $machine->serial_no }})</span>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="text-xs font-black text-slate-400 dark:text-slate-400/80 uppercase tracking-widest leading-none">
{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->format('Y/m/d H:i') : '---' }}
</div>
</td>
<td class="px-6 py-6 text-center">
@php
$cStatus = $machine->calculated_status;
@endphp
@if($cStatus === 'online')
<div
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<div class="relative flex h-2 w-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</div>
</td>
<td class="px-6 py-6 text-right">
<span class="text-[11px] font-bold text-slate-400/30 dark:text-slate-500 uppercase tracking-widest group-hover:text-slate-400 transition-colors">{{ __('No alert summary') }}</span>
</td>
</tr>
<span
class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-widest uppercase">{{
__('Online') }}</span>
</div>
@elseif($cStatus === 'error')
<div
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
<div class="h-2 w-2 rounded-full bg-rose-500 animate-pulse"></div>
<span
class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-widest uppercase">{{
__('Error') }}</span>
</div>
@else
<div
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
<span
class="text-[10px] font-black text-slate-600 dark:text-slate-400 tracking-widest uppercase">{{
__('Offline') }}</span>
</div>
@endif
</td>
<td class="px-6 py-6 text-center">
<span class="text-base font-extrabold text-slate-900 dark:text-slate-100">$ 0</span>
</td>
<td class="px-6 py-6">
<div class="flex flex-col items-center gap-y-2.5">
<div
class="w-32 h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
<div class="h-full bg-rose-500 rounded-full shadow-[0_0_8px_rgba(244,63,94,0.4)]"
style="width: 15.5%"></div>
</div>
<span class="text-[11px] font-black text-rose-500 uppercase tracking-[0.2em]">15.5% {{
__('Low Stock') }}</span>
</div>
</td>
<td class="px-6 py-6 text-center">
<div
class="text-xs font-black text-slate-400 dark:text-slate-400/80 uppercase tracking-widest leading-none">
{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->format('Y/m/d H:i') :
'---' }}
</div>
</td>
<td class="px-6 py-6 text-right">
<span
class="text-[11px] font-bold text-slate-400/30 dark:text-slate-500 uppercase tracking-widest group-hover:text-slate-400 transition-colors">{{
__('No alert summary') }}</span>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-32 text-center text-slate-400">{{ __('No data available') }}</td>
</tr>
<tr>
<td colspan="6" class="px-6 py-32 text-center text-slate-400">{{ __('No data available') }}</td>
</tr>
@endforelse
</tbody>
</table>

View File

@@ -3,84 +3,93 @@
@section('content')
<script>
window.machineApp = function() {
return {
showLogPanel: false,
activeTab: 'status',
currentMachineId: '',
currentMachineSn: '',
currentMachineName: '',
logs: [],
loading: false,
startDate: '',
endDate: '',
tab: 'list',
viewMode: 'fleet',
selectedMachine: null,
slots: [],
window.machineApp = function () {
return {
showLogPanel: false,
showEditModal: false,
editMachineId: '',
editMachineName: '',
activeTab: 'status',
currentMachineId: '',
currentMachineSn: '',
currentMachineName: '',
logs: [],
loading: false,
startDate: '',
endDate: '',
tab: 'list',
viewMode: 'fleet',
selectedMachine: null,
slots: [],
init() {
const d = new Date();
const today = [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0')
].join('-');
this.startDate = today;
this.endDate = today;
this.$watch('activeTab', () => this.fetchLogs());
},
init() {
const d = new Date();
const today = [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0')
].join('-');
this.startDate = today;
this.endDate = today;
this.$watch('activeTab', () => this.fetchLogs());
},
async openLogPanel(id, sn, name) {
this.currentMachineId = id;
this.currentMachineSn = sn;
this.currentMachineName = name;
this.slots = [];
this.showLogPanel = true;
this.activeTab = 'status';
await this.fetchLogs();
},
async openLogPanel(id, sn, name) {
this.currentMachineId = id;
this.currentMachineSn = sn;
this.currentMachineName = name;
this.slots = [];
this.showLogPanel = true;
this.activeTab = 'status';
await this.fetchLogs();
},
openEditModal(id, name) {
this.editMachineId = id;
this.editMachineName = name;
this.showEditModal = true;
},
async fetchLogs() {
this.loading = true;
try {
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab;
if (this.startDate) url += '&start_date=' + this.startDate;
if (this.endDate) url += '&end_date=' + this.endDate;
const res = await fetch(url);
const data = await res.json();
if (data.success) this.logs = data.data.data || data.data || [];
} catch(e) { console.error('fetchLogs error:', e); }
finally { this.loading = false; }
},
async fetchLogs() {
this.loading = true;
try {
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab;
if (this.startDate) url += '&start_date=' + this.startDate;
if (this.endDate) url += '&end_date=' + this.endDate;
const res = await fetch(url);
const data = await res.json();
if (data.success) this.logs = data.data.data || data.data || [];
} catch (e) { console.error('fetchLogs error:', e); }
finally { this.loading = false; }
},
async openCabinet(id) {
this.loading = true;
this.viewMode = 'cabinet';
try {
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
const data = await res.json();
if (data.success) {
this.selectedMachine = data.machine;
this.slots = data.slots;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch(e) { console.error('openCabinet error:', e); }
finally { this.loading = false; }
},
async openCabinet(id) {
this.loading = true;
this.viewMode = 'cabinet';
try {
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
const data = await res.json();
if (data.success) {
this.selectedMachine = data.machine;
this.slots = data.slots;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch (e) { console.error('openCabinet error:', e); }
finally { this.loading = false; }
},
};
};
};
</script>
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()"
@keydown.escape.window="showLogPanel = false">
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()" @keydown.escape.window="showLogPanel = false">
<!-- Top Header & Actions -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center gap-4">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display transition-all duration-300">
<h1
class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display transition-all duration-300">
{{ __('Machine List') }}
</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
@@ -167,9 +176,13 @@ window.machineApp = function() {
</td>
<td class="px-6 py-6 text-center">
<div class="flex items-center justify-center gap-2">
@if($machine->status === 'online')
<div
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
@php
$cStatus = $machine->calculated_status;
@endphp
@if($cStatus === 'online')
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 tooltip"
title="{{ __('Machine is heartbeat normal') }}">
<div class="relative flex h-2 w-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
@@ -179,17 +192,17 @@ window.machineApp = function() {
class="text-xs font-black text-emerald-600 dark:text-emerald-400 tracking-widest uppercase">{{
__('Online') }}</span>
</div>
@elseif($machine->status === 'error')
<div
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
@elseif($cStatus === 'error')
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20 tooltip"
title="{{ __('Recently reported errors or warnings in logs') }}">
<div class="h-2 w-2 rounded-full bg-rose-500 animate-pulse"></div>
<span
class="text-xs font-black text-rose-600 dark:text-rose-400 tracking-widest uppercase">{{
__('Error') }}</span>
</div>
@else
<div
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20 tooltip"
title="{{ __('No heartbeat for over 30 seconds') }}">
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
<span
class="text-xs font-black text-slate-500 dark:text-slate-400 tracking-widest uppercase">{{
@@ -230,15 +243,16 @@ window.machineApp = function() {
</td>
<td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ route('admin.machines.edit', $machine->id) }}"
<button type="button"
@click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
title="{{ __('Edit') }}">
title="{{ __('Edit Name') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</a>
</button>
<button type="button"
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
@@ -504,4 +518,57 @@ window.machineApp = function() {
</div>
</div><!-- /Offcanvas -->
@endsection
<!-- Edit Machine Name Modal -->
<div x-show="showEditModal" class="fixed inset-0 z-[100] overflow-y-auto" style="display: none;" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Background Backdrop -->
<div x-show="showEditModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" @click="showEditModal = false">
</div>
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">&#8203;</span>
<!-- Modal Panel -->
<div x-show="showEditModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] p-10 text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-100 dark:border-slate-800">
<div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-2">{{ __('Edit Machine Name') }}</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Update identification for your asset') }}</p>
<form :action="'/admin/machines/' + editMachineId" method="POST" class="mt-8 space-y-6">
@csrf
@method('PUT')
<div class="space-y-4">
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.1em]">{{ __('New Machine Name') }}</label>
<input type="text" name="name" x-model="editMachineName" required
class="luxury-input block w-full px-6 py-4 text-base font-bold text-slate-800 dark:text-white bg-slate-50/50 dark:bg-slate-900/50"
placeholder="{{ __('Enter machine name...') }}">
</div>
<div class="flex items-center gap-4 pt-4">
<button type="button" @click="showEditModal = false"
class="px-8 py-4 bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black rounded-2xl border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700 transition-all">
{{ __('Cancel') }}
</button>
<button type="submit"
class="flex-1 bg-cyan-500 hover:bg-cyan-600 text-white font-black py-4 rounded-2xl shadow-lg shadow-cyan-500/30 transition-all duration-300 transform hover:-translate-y-0.5 active:scale-95">
{{ __('Save Changes') }}
</button>
</div>
</form>
</div>
</div>
</div>
</div><!-- /Edit Modal -->
@endsection

View File

@@ -123,7 +123,7 @@
'stock' => __('Stock & Expiry'),
default => null,
},
'edit' => str_starts_with($routeName, 'profile') ? null : __('Edit'),
'edit' => str_starts_with($routeName, 'profile') ? __('Profile') : __('Edit'),
'create' => __('Create'),
'show' => __('Detail'),
'logs' => __('Machine Logs'),

View File

@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MachineStatusTest extends TestCase
{
use RefreshDatabase;
/**
* Test machine is online with recent heartbeat and no errors.
*/
public function test_machine_is_online_with_recent_heartbeat(): void
{
$machine = Machine::factory()->create([
'last_heartbeat_at' => now()->subSeconds(10),
]);
$this->assertEquals('online', $machine->calculated_status);
}
/**
* Test machine is error with recent heartbeat but recent errors.
*/
public function test_machine_is_error_with_recent_logs(): void
{
$machine = Machine::factory()->create([
'last_heartbeat_at' => now()->subSeconds(10),
]);
// Add an error log 10 mins ago
MachineLog::create([
'machine_id' => $machine->id,
'level' => 'error',
'created_at' => now()->subMinutes(10),
'message' => 'Test error',
]);
$this->assertEquals('error', $machine->calculated_status);
}
/**
* Test machine is offline with old heartbeat (30s+).
*/
public function test_machine_is_offline_with_old_heartbeat(): void
{
$machine = Machine::factory()->create([
'last_heartbeat_at' => now()->subSeconds(35),
]);
$this->assertEquals('offline', $machine->calculated_status);
}
/**
* Test machine scopes (online, offline, hasError).
*/
public function test_machine_scopes(): void
{
// 1. Online machine
$onlineMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(10)]);
// 2. Offline machine
$offlineMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(40)]);
// 3. Online machine with error
$errorMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(5)]);
MachineLog::create([
'machine_id' => $errorMachine->id,
'level' => 'error',
'created_at' => now()->subMinutes(5),
'message' => 'Error log',
]);
$this->assertEquals(2, Machine::online()->count());
$this->assertEquals(1, Machine::offline()->count());
$this->assertEquals(1, Machine::online()->hasError()->count());
$this->assertTrue(Machine::online()->get()->contains($onlineMachine));
$this->assertTrue(Machine::offline()->get()->contains($offlineMachine));
$this->assertTrue(Machine::online()->hasError()->get()->contains($errorMachine));
}
}