Files
star-cloud/resources/views/admin/remote/stock.blade.php
sky121113 376f43fa3a
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 3m58s
[FIX] 修正 IoT 管理介面分頁持久化與實作 B055 遠端出貨 API
1. 視圖持久化優化:將 index/stock 視圖切換從 x-if 改為 x-show,解決 HSSelect 在切換分頁後失效的問題。
2. 變數存取安全:為所有 selectedMachine 屬性存取補上可選鏈 (?.) 保護,防止 x-show 模式下的 null 錯誤。
3. UI 體驗提升:放大「指令中心」與「庫存管理」歷史紀錄的時間字體至 15px 並加粗顯示。
4. API 功能實作:在 routes/api.php 與 MachineController 中實作 B055 遠端指令出貨控制端點。
5. 文件同步:更新 SKILL.md 技術規格文件,明確定義 B055 的請求與回應格式。
6. 樣式調整:修改 app.css 優化奢華風 UI 字體與間距細節。
2026-04-15 10:54:58 +08:00

848 lines
58 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@extends('layouts.admin')
@section('content')
<script>
window.stockApp = function(initialMachineId) {
return {
machines: @json($machines),
searchQuery: '',
selectedMachine: null,
slots: [],
viewMode: initialMachineId ? 'detail' : (new URLSearchParams(window.location.search).has('search') || new URLSearchParams(window.location.search).has('page') ? 'history' : 'history'),
// 預設為 history但我們會確保在有搜尋或分頁時維持 history
history: @js($history),
loading: false,
updating: false,
// Modal State
showEditModal: false,
formData: {
stock: 0,
expiry_date: '',
batch_no: ''
},
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 = 'detail';
this.loading = true;
this.slots = [];
// Update URL without refresh
const url = new URL(window.location);
url.searchParams.set('machine_id', machine.id);
window.history.pushState({}, '', url);
try {
const res = await fetch(`/admin/machines/${machine.id}/slots-ajax`);
const data = await res.json();
if (data.success) {
this.slots = data.slots;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch (e) {
console.error('Fetch slots error:', e);
} finally {
this.loading = false;
}
},
backToList() {
this.viewMode = 'list';
this.selectedMachine = null;
this.selectedSlot = null;
this.slots = [];
// Clear machine_id from URL
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
backToHistory() {
this.viewMode = 'history';
this.selectedMachine = null;
this.selectedSlot = null;
this.slots = [];
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
openEdit(slot) {
this.selectedSlot = slot;
this.formData = {
stock: slot.stock || 0,
expiry_date: slot.expiry_date ? slot.expiry_date.split('T')[0] : '',
batch_no: slot.batch_no || ''
};
this.showEditModal = true;
},
async saveChanges() {
this.updating = true;
try {
const csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const res = await fetch(`/admin/machines/${this.selectedMachine.id}/slots/expiry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
body: JSON.stringify({
slot_no: this.selectedSlot.slot_no,
...this.formData
})
});
const data = await res.json();
if (data.success) {
this.showEditModal = false;
// Redirect instantly to history tab.
// The success toast will be handled by the session() flash in the controller.
window.location.href = "{{ route('admin.remote.stock') }}";
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: '{{ __("Save error:") }} ' + e.message,
type: 'error'
}
}));
console.error('Save error:', e);
} finally {
this.updating = false;
}
},
getSlotColorClass(slot) {
if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
const todayStr = new Date().toISOString().split('T')[0];
const expiryStr = slot.expiry_date;
if (expiryStr < todayStr) {
return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30 shadow-sm shadow-rose-500/5';
}
const diffDays = Math.round((new Date(expiryStr) - new Date(todayStr)) / 86400000);
if (diffDays <= 7) {
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30 shadow-sm shadow-amber-500/5';
}
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30 shadow-sm shadow-emerald-500/5';
},
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';
case 'superseded': return 'bg-slate-100 text-slate-500 dark:bg-slate-500/10 dark:text-slate-400 border-slate-200 dark:border-slate-500/20 opacity-80';
default: return 'bg-slate-100 text-slate-600 border-slate-200';
}
},
getCommandName(type) {
const names = {
'reboot': {{ Js::from(__('Machine Reboot')) }},
'reboot_card': {{ Js::from(__('Card Reader Reboot')) }},
'checkout': {{ Js::from(__('Remote Reboot')) }},
'lock': {{ Js::from(__('Lock Page Lock')) }},
'unlock': {{ Js::from(__('Lock Page Unlock')) }},
'change': {{ Js::from(__('Remote Change')) }},
'dispense': {{ Js::from(__('Remote Dispense')) }},
'reload_stock': {{ Js::from(__('Adjust Stock & Expiry')) }}
};
return names[type] || type;
},
getCommandStatus(status) {
const statuses = {
'pending': {{ Js::from(__('Pending')) }},
'sent': {{ Js::from(__('Sent')) }},
'success': {{ Js::from(__('Success')) }},
'failed': {{ Js::from(__('Failed')) }},
'superseded': {{ Js::from(__('Superseded')) }}
};
return statuses[status] || status;
},
getOperatorName(user) {
return user ? user.name : {{ Js::from(__('System')) }};
},
getPayloadDetails(item) {
if (item.command_type === 'reload_stock' && item.payload) {
const p = item.payload;
let details = `{{ __('Slot') }} ${p.slot_no}: `;
if (p.old.stock !== p.new.stock) {
details += `{{ __('Stock') }} ${p.old.stock} → ${p.new.stock}`;
}
if (p.old.expiry_date !== p.new.expiry_date) {
if (p.old.stock !== p.new.stock) details += ', ';
details += `{{ __('Expiry') }} ${p.old.expiry_date || 'N/A'} → ${p.new.expiry_date || 'N/A'}`;
}
if (p.old.batch_no !== p.new.batch_no) {
if (p.old.stock !== p.new.stock || p.old.expiry_date !== p.new.expiry_date) details += ', ';
details += `{{ __('Batch') }} ${p.old.batch_no || 'N/A'} → ${p.new.batch_no || 'N/A'}`;
}
return details;
}
return '';
},
formatTime(dateStr) {
if (!dateStr) return '--';
const date = new Date(dateStr);
const now = new Date();
const diffSeconds = Math.floor((now - date) / 1000);
if (diffSeconds < 0) return date.toISOString().split('T')[0];
if (diffSeconds < 60) return "{{ __('Just now') }}";
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) return diffMinutes + " {{ __('mins ago') }}";
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return diffHours + " {{ __('hours ago') }}";
return date.toISOString().split('T')[0] + ' ' + date.toTimeString().split(' ')[0].substring(0, 5);
},
translateNote(note) {
if (!note) return '';
const translations = {
'Superseded by new adjustment': {{ Js::from(__('Superseded by new adjustment')) }}
};
return translations[note] || note;
}
};
};
</script>
<div class="space-y-2 pb-20"
x-data="stockApp('{{ $selectedMachine ? $selectedMachine->id : '' }}')"
@keydown.escape.window="showEditModal = false">
<div class="flex items-center gap-4">
<!-- Back Button for Detail Mode -->
<template x-if="viewMode === 'detail'">
<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>
</template>
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">
{{ __($title ?? 'Stock & Expiry Management') }}
</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
{{ __($subtitle ?? 'Manage inventory and monitor expiry dates across all machines') }}
</p>
</div>
</div>
<!-- Tab Navigation (Only visible when not in specific machine detail) -->
<template x-if="viewMode !== 'detail'">
<div class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50">
<button @click="viewMode = 'history'"
:class="viewMode === 'history' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Operation Records') }}
</button>
<button @click="viewMode = 'list'"
:class="viewMode === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Adjust Stock & Expiry') }}
</button>
</div>
</template>
<div class="mt-6">
<!-- History View: Operation Records -->
<div x-show="viewMode === 'history'" x-cloak>
<div class="space-y-6 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
<!-- Filters Area -->
<div class="mb-8">
<form method="GET" action="{{ route('admin.remote.stock') }}" class="flex flex-wrap items-center gap-4">
<!-- Search Box -->
<div class="relative group flex-[1.5] min-w-[200px]">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<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" 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') }}"
placeholder="{{ __('Search machines...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full">
</div>
<!-- Date Range -->
<div class="relative group flex-[2] min-w-[340px]"
x-data="{
fp: null,
startDate: '{{ request('start_date') }}',
endDate: '{{ request('end_date') }}'
}"
x-init="fp = flatpickr($refs.dateRange, {
mode: 'range',
dateFormat: 'Y-m-d H:i', enableTime: true, time_24hr: true,
locale: 'zh_tw',
defaultDate: startDate && endDate ? [startDate, endDate] : (startDate ? [startDate] : []),
onChange: function(selectedDates, dateStr, instance) {
if (selectedDates.length === 2) {
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
} else if (selectedDates.length === 0) {
$refs.startDate.value = '';
$refs.endDate.value = '';
}
}
})">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</span>
<input type="hidden" name="start_date" x-ref="startDate" value="{{ request('start_date') }}">
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
<input type="text" x-ref="dateRange"
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer">
</div>
<!-- Command Type -->
<!-- Status -->
<div class="flex-1 min-w-[160px]">
<x-searchable-select
name="status"
:options="[
'pending' => __('Pending'),
'sent' => __('Sent'),
'success' => __('Success'),
'failed' => __('Failed'),
'superseded' => __('Superseded'),
]"
:selected="request('status')"
:placeholder="__('All Status')"
:hasSearch="false"
onchange="this.form.submit()"
/>
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
<button type="submit" class="p-2.5 rounded-xl bg-cyan-600 text-white hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 transition-all active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</button>
<a href="{{ route('admin.remote.stock') }}" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95">
<svg class="w-5 h-5" 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>
</a>
</div>
</form>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0 text-sm">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center whitespace-nowrap">{{ __('Creation Time') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center whitespace-nowrap">{{ __('Picked up Time') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Command Type') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Operator') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@foreach ($history as $item)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(@js($item->machine))">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm overflow-hidden">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
</svg>
</div>
<div>
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight">{{ $item->machine->name }}</div>
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ $item->machine->serial_no }}</div>
</div>
</div>
</td>
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
<div class="flex flex-col">
<span>{{ $item->created_at->format('Y/m/d') }}</span>
<span class="text-[15px] font-bold text-slate-500 dark:text-slate-400">{{ $item->created_at->format('H:i:s') }}</span>
</div>
</td>
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
@if($item->executed_at)
<div class="flex flex-col text-cyan-600/80 dark:text-cyan-400/60">
<span>{{ $item->executed_at->format('Y/m/d') }}</span>
<span class="text-[15px] font-bold">{{ $item->executed_at->format('H:i:s') }}</span>
</div>
@else
<span class="text-slate-300 dark:text-slate-700">-</span>
@endif
</td>
<td class="px-6 py-6">
<div class="flex flex-col min-w-[200px]">
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(@js($item->command_type))"></span>
<div class="flex flex-col gap-0.5 mt-1">
<span x-show="getPayloadDetails(@js($item))" class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(@js($item))"></span>
@if($item->note)
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(@js($item->note))"></span>
@endif
</div>
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-cyan-500/10 flex items-center justify-center text-[10px] font-black text-cyan-600 dark:text-cyan-400 border border-cyan-500/20">
{{ mb_substr($item->user ? $item->user->name : __('System'), 0, 1) }}
</div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ $item->user ? $item->user->name : __('System') }}</span>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center gap-1.5">
<div class="inline-flex items-center px-4 py-1.5 rounded-full border text-[10px] font-black uppercase tracking-widest shadow-sm"
:class="getCommandBadgeClass(@js($item->status))">
<div class="w-1.5 h-1.5 rounded-full mr-2"
:class="{
'bg-amber-500 animate-pulse': @js($item->status) === 'pending',
'bg-cyan-500': @js($item->status) === 'sent',
'bg-emerald-500': @js($item->status) === 'success',
'bg-rose-500': @js($item->status) === 'failed',
'bg-slate-400': @js($item->status) === 'superseded'
}"></div>
<span x-text="getCommandStatus(@js($item->status))"></span>
</div>
</div>
</td>
</tr>
@endforeach
@if($history->isEmpty())
<tr>
<td colspan="6" class="px-6 py-20 text-center">
<div class="flex flex-col items-center gap-3">
<div class="w-16 h-16 rounded-full bg-slate-50 dark:bg-slate-900/50 flex items-center justify-center text-slate-200 dark:text-slate-800">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<p class="text-slate-400 font-bold tracking-widest uppercase text-xs">{{ __('No records found') }}</p>
</div>
</td>
</tr>
@endif
</tbody>
</table>
</div>
<!-- Pagination Area -->
<div class="mt-8">
{{ $history->appends(request()->query())->links('vendor.pagination.luxury') }}
</div>
</div>
</div>
</div>
<!-- Master View: Machine List -->
<div x-show="viewMode === 'list'" x-cloak>
<div class="space-y-6 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
<!-- Filters Area -->
<div class="flex items-center justify-between mb-8">
<div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<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" 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" x-model="searchQuery"
placeholder="{{ __('Search...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-72">
</div>
</div>
<div class="overflow-x-auto pb-4">
<table class="w-full text-left border-separate border-spacing-y-0 text-sm whitespace-nowrap">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Alerts') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Last Sync') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<template x-for="machine in machines.filter(m =>
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
m.serial_no.toLowerCase().includes(searchQuery.toLowerCase())
)" :key="machine.id">
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(machine)">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm">
<template x-if="machine.image_urls && machine.image_urls[0]">
<img :src="machine.image_urls[0]" class="w-full h-full object-cover">
</template>
<template x-if="!machine.image_urls || !machine.image_urls[0]">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
</svg>
</template>
</div>
<div>
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="machine.name"></div>
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.serial_no"></div>
</div>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex justify-center">
<template x-if="machine.status === 'online' || !machine.status">
<div class="flex items-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>
<span class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-[0.1em] uppercase">{{ __('Online') }}</span>
</div>
</template>
<template x-if="machine.status === 'offline'">
<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="h-2 w-2 rounded-full bg-slate-400"></div>
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 tracking-[0.1em] uppercase">{{ __('Offline') }}</span>
</div>
</template>
<template x-if="machine.status && machine.status !== 'online' && machine.status !== 'offline'">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
<div class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</div>
<span class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-[0.1em] uppercase">{{ __('Abnormal') }}</span>
</div>
</template>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center gap-1.5">
<template x-if="machine.low_stock_count > 0">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-rose-500/10 text-rose-500 text-[10px] font-black border border-rose-500/20 uppercase tracking-widest leading-none shadow-sm shadow-rose-500/5">
<span class="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse"></span>
<span x-text="machine.low_stock_count"></span>&nbsp;{{ __('Low') }}
</span>
</template>
<template x-if="machine.expiring_soon_count > 0">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-500 text-[10px] font-black border border-amber-500/20 uppercase tracking-widest leading-none shadow-sm shadow-amber-500/5">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
<span x-text="machine.expiring_soon_count"></span>&nbsp;{{ __('Expiring') }}
</span>
</template>
<template x-if="!machine.low_stock_count && !machine.expiring_soon_count">
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-600 uppercase tracking-[0.1em]">{{ __('All Stable') }}</span>
</template>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center">
<span class="text-sm font-black text-slate-700 dark:text-slate-200" x-text="formatTime(machine.last_heartbeat_at)"></span>
<span class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.last_heartbeat_at ? machine.last_heartbeat_at.split('T')[0] : '--'"></span>
</div>
</td>
<td class="px-6 py-6 text-right">
<button @click="selectMachine(machine)"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('Manage') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<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>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Detail View: Cabinet Management -->
<div x-show="viewMode === 'detail'" x-cloak>
<div class="space-y-8 animate-luxury-in">
<!-- Machine Header Info -->
<div class="luxury-card rounded-[2.5rem] p-8 md:p-10 flex flex-col md:flex-row md:items-center justify-between gap-8 border border-slate-200/60 dark:border-slate-800/60">
<div class="flex items-center gap-8">
<div class="w-24 h-24 rounded-3xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 overflow-hidden shadow-inner">
<template x-if="selectedMachine?.image_urls && selectedMachine?.image_urls[0]">
<img :src="selectedMachine.image_urls[0]" class="w-full h-full object-cover">
</template>
<template x-if="!selectedMachine?.image_urls || !selectedMachine?.image_urls[0]">
<svg class="w-12 h-12 stroke-[1.2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</template>
</div>
<div>
<h1 x-text="selectedMachine?.name" class="text-4xl font-black text-slate-800 dark:text-white tracking-tighter leading-tight"></h1>
<div class="flex items-center gap-4 mt-3">
<span x-text="selectedMachine?.serial_no" class="px-3 py-1 rounded-lg bg-cyan-500/10 text-cyan-500 text-xs font-mono font-bold uppercase tracking-widest border border-cyan-500/20"></span>
<div class="flex items-center gap-2 text-slate-400 uppercase tracking-widest text-[10px] font-black">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span x-text="selectedMachine?.location || '{{ __('No Location') }}'"></span>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-5">
<div class="px-7 py-4 rounded-[1.75rem] bg-slate-50 dark:bg-slate-800/50 flex flex-col items-center min-w-[120px] border border-slate-100 dark:border-slate-800/50">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Total Slots') }}</span>
<span class="text-3xl font-black text-slate-700 dark:text-slate-200" x-text="slots.length"></span>
</div>
<div class="px-7 py-4 rounded-[1.75rem] bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[120px]">
<span class="text-[10px] font-black text-rose-500 uppercase tracking-widest mb-1">{{ __('Low Stock') }}</span>
<span class="text-3xl font-black text-rose-600" x-text="slots.filter(s => s != null && s.stock <= 5).length"></span>
</div>
</div>
</div>
<!-- Cabinet Visualization Grid -->
<div class="space-y-6">
<!-- Status Legend -->
<div class="flex items-center justify-between px-4">
<div class="flex items-center gap-8">
<div class="flex items-center gap-2.5">
<span class="w-3.5 h-3.5 rounded-full bg-rose-500 shadow-lg shadow-rose-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{ __('Expired') }}</span>
</div>
<div class="flex items-center gap-2.5">
<span class="w-3.5 h-3.5 rounded-full bg-amber-500 shadow-lg shadow-amber-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{ __('Warning') }}</span>
</div>
<div class="flex items-center gap-2.5">
<span class="w-3.5 h-3.5 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{ __('Normal') }}</span>
</div>
</div>
</div>
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/50 dark:border-slate-800/50 bg-white/30 dark:bg-slate-900/40 backdrop-blur-xl relative overflow-hidden min-h-[500px]">
<!-- Loading Overlay -->
<div x-show="loading" class="absolute inset-0 bg-white/60 dark:bg-slate-900/60 backdrop-blur-md z-20 flex items-center justify-center transition-all duration-500">
<div class="flex flex-col items-center gap-6">
<div class="w-16 h-16 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
<span class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.3em] ml-2 animate-pulse">{{ __('Loading Cabinet...') }}</span>
</div>
</div>
<!-- Background Decorative Grid -->
<div class="absolute inset-0 opacity-[0.05] pointer-events-none"
style="background-image: radial-gradient(#00d2ff 1.2px, transparent 1.2px); background-size: 40px 40px;">
</div>
<!-- Slots Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6 relative z-10" x-show="!loading">
<template x-for="slot in slots" :key="slot.id">
<div @click="openEdit(slot)"
:class="getSlotColorClass(slot)"
class="min-h-[300px] rounded-[2.5rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-500 cursor-pointer group hover:scale-[1.08] hover:-translate-y-3 hover:shadow-2xl active:scale-[0.98] relative">
<!-- Slot Header (Pinned to top) -->
<div class="absolute top-4 left-5 right-5 flex justify-between items-center z-20">
<div class="px-2.5 py-1 rounded-xl bg-slate-900/10 dark:bg-white/10 backdrop-blur-md border border-slate-900/5 dark:border-white/10 flex-shrink-0">
<span class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white" x-text="slot.slot_no"></span>
</div>
<template x-if="slot.stock <= 2">
<div class="px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
<!-- Product Image -->
<div class="relative w-20 h-20 mb-4 mt-1">
<div class="absolute inset-0 rounded-[2rem] bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 dark:border-white/5 shadow-inner group-hover:scale-105 transition-transform duration-500 overflow-hidden">
<template x-if="slot.product && slot.product.image_url">
<img :src="slot.product.image_url" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110">
</template>
<template x-if="!slot.product">
<div class="w-full h-full flex items-center justify-center">
<svg class="w-8 h-8 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
</template>
</div>
</div>
<!-- Slot Info -->
<div class="text-center w-full space-y-3">
<template x-if="slot.product">
<div class="text-base font-black truncate w-full opacity-90 tracking-tight" x-text="slot.product.name"></div>
</template>
<div class="space-y-3">
<!-- Stock Level -->
<div class="flex flex-col items-center">
<div class="flex items-baseline gap-1">
<span class="text-2xl font-black tracking-tighter leading-none" x-text="slot.stock"></span>
<span class="text-xs font-black opacity-30">/</span>
<span class="text-sm font-bold opacity-50" x-text="slot.max_stock || 10"></span>
</div>
</div>
<!-- Expiry Date -->
<div class="flex flex-col items-center">
<span class="text-base font-black tracking-tight leading-none opacity-80" x-text="slot.expiry_date ? slot.expiry_date.replace(/-/g, '/') : '----/--/--'"></span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Integrated Edit Modal -->
<div x-show="showEditModal"
class="fixed inset-0 z-[100] overflow-y-auto"
style="display: none;"
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">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showEditModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-xl sm:w-full overflow-visible animate-luxury-in"
@click.away="showEditModal = false">
<!-- Modal Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h3 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none">
{{ __('Edit Slot') }} <span x-text="selectedSlot?.slot_no || ''" class="text-cyan-500"></span>
</h3>
<template x-if="selectedSlot && selectedSlot.product">
<p x-text="selectedSlot?.product?.name" class="text-base font-black text-slate-400 uppercase tracking-widest mt-3 ml-0.5"></p>
</template>
</div>
<button @click="showEditModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800">
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Form Controls -->
<div class="space-y-6">
<!-- Stock Count Widget -->
<div class="space-y-2">
<label class="text-base font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Stock Quantity') }}</label>
<div class="flex items-center gap-4 bg-slate-50 dark:bg-slate-900/50 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/50">
<div class="flex-1">
<input type="number" x-model="formData.stock" min="0" :max="selectedSlot ? selectedSlot.max_stock : 99"
class="w-full bg-transparent border-none p-0 text-5xl font-black text-slate-800 dark:text-white focus:ring-0 placeholder-slate-200">
<div class="text-sm font-black text-slate-400 mt-2 uppercase tracking-wider pl-0.5">
{{ __('Max Capacity:') }} <span class="text-slate-600 dark:text-slate-300" x-text="selectedSlot?.max_stock || 0"></span>
</div>
</div>
<div class="flex gap-2">
<button @click="formData.stock = 0" class="px-5 py-3 rounded-lg bg-white dark:bg-slate-800 text-slate-400 hover:text-rose-500 border border-slate-200 dark:border-slate-700 transition-all text-sm font-black uppercase tracking-widest active:scale-95 shadow-sm">
{{ __('Clear') }}
</button>
<button @click="formData.stock = selectedSlot?.max_stock || 0" class="px-5 py-3 rounded-lg bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500 hover:text-white border border-cyan-500/20 transition-all text-sm font-black uppercase tracking-widest active:scale-95 shadow-sm">
{{ __('Max') }}
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Expiry Date -->
<div class="space-y-2">
<label class="text-sm font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Expiry Date') }}</label>
<input type="date" x-model="formData.expiry_date"
class="luxury-input w-full py-4 px-5">
</div>
<!-- Batch Number -->
<div class="space-y-2">
<label class="text-sm font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Batch Number') }}</label>
<input type="text" x-model="formData.batch_no" placeholder="B2026-XXXX"
class="luxury-input w-full py-4 px-5">
</div>
</div>
</div>
<!-- Footer Actions -->
<div class="flex justify-end gap-x-4 mt-10 pt-8 border-t border-slate-100 dark:border-slate-800/50">
<button type="button" @click="showEditModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
<button type="button" @click="saveChanges()" :disabled="updating" class="btn-luxury-primary px-12 min-w-[160px]">
<div x-show="updating" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin mr-3"></div>
<span x-text="updating ? '{{ __('Saving...') }}' : '{{ __('Confirm Changes') }}'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
<style>
/* 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;
}
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.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; }
</style>
@endsection