star-cloud/resources/views/admin/remote/stock.blade.php
sky121113 9114269a7a [FEAT] 完善遠端出貨與庫存異動的併發控制與視覺回饋
1. 修正 RemoteController 中的 MachineService 注入問題,解決遠端出貨指令失效的 500 錯誤。
2. 在 MachineService 實作貨道鎖定機制 (lockSlot/unlockSlot),防止重複扣庫存導致的數據不一致。
3. 採用樂觀扣除模式,發送指令時即更新預扣庫存,並在 ProcessCommandAck 實作失敗自動 Rollback。
4. 優化 UI 互動:在操作紀錄詳情顯示庫存變化 (如 6 -> 5),且針對執行失敗的指令加上刪除線樣式。
5. 完成指令詳情、操作紀錄與庫存歷史的語系對照表同步 (zh_TW, en, ja)。
2026-04-22 15:02:00 +08:00

823 lines
47 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' : 'history',
history: @js($history),
loading: false,
updating: false,
tabLoading: false,
// Modal State
showEditModal: false,
formData: {
stock: 0,
expiry_date: '',
batch_no: ''
},
// 機器搜尋狀態
machineSearch: '{{ request('machine_search') }}',
historySearch: '{{ request('search') }}',
historyStartDate: '{{ request('start_date') }}',
historyEndDate: '{{ request('end_date') }}',
historyStatus: '{{ request('status') }}',
async init() {
if (initialMachineId) {
const machine = this.machines.data.find(m => m.id == initialMachineId);
if (machine) {
await this.selectMachine(machine);
}
}
// 首次載入時綁定分頁連結
this.$nextTick(() => {
if (this.$refs.historyContent) this.bindPaginationLinks(this.$refs.historyContent, 'history');
if (this.$refs.machinesContent) this.bindPaginationLinks(this.$refs.machinesContent, 'machines');
});
// 觸發進度條
this.$watch('tabLoading', (val) => {
const bar = document.getElementById('top-loading-bar');
if (bar) {
if (val) bar.classList.add('loading');
else bar.classList.remove('loading');
}
});
},
// === AJAX 搜尋/分頁 ===
async searchInTab(tab = 'history', extraQuery = '') {
this.tabLoading = true;
let qs = `_ajax=1&tab=${tab}`;
if (tab === 'machines') {
if (this.machineSearch) qs += `&machine_search=${encodeURIComponent(this.machineSearch)}`;
} else {
if (this.historySearch) qs += `&search=${encodeURIComponent(this.historySearch)}`;
if (this.historyStartDate) qs += `&start_date=${encodeURIComponent(this.historyStartDate)}`;
if (this.historyEndDate) qs += `&end_date=${encodeURIComponent(this.historyEndDate)}`;
if (this.historyStatus) qs += `&status=${encodeURIComponent(this.historyStatus)}`;
}
if (extraQuery) qs += extraQuery;
// 同步 URL不含 _ajax
const visibleQs = qs.replace(/&?_ajax=1/, '');
history.pushState({}, '', `{{ route('admin.remote.stock') }}${visibleQs ? '?' + visibleQs : ''}`);
try {
const res = await fetch(
`{{ route('admin.remote.stock') }}?${qs}`,
{ headers: { 'X-Requested-With': 'XMLHttpRequest' } }
);
const html = await res.text();
const ref = (tab === 'machines') ? this.$refs.machinesContent : this.$refs.historyContent;
if (ref) {
ref.innerHTML = html;
this.$nextTick(() => {
Alpine.initTree(ref);
this.bindPaginationLinks(ref, tab);
if (window.HSStaticMethods) {
setTimeout(() => window.HSStaticMethods.autoInit(), 100);
}
});
}
} catch (e) {
console.error('Search failed:', e);
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Failed to load content') }}', type: 'error' } }));
} finally {
this.tabLoading = false;
}
},
// 攔截分頁連結
bindPaginationLinks(container, tab) {
if (!container) return;
container.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
try {
const url = new URL(href, window.location.origin);
const pageKey = (tab === 'machines') ? 'machine_page' : 'history_page';
if (!url.searchParams.has(pageKey) || a.closest('td.px-6')) return;
a.addEventListener('click', (e) => {
if (a.title) return;
e.preventDefault();
const page = url.searchParams.get(pageKey) || 1;
const perPage = url.searchParams.get('per_page') || '';
let extra = `&${pageKey}=${page}`;
if (perPage) extra += `&per_page=${perPage}`;
this.searchInTab(tab, extra);
});
} catch (err) { }
});
container.querySelectorAll('select[onchange]').forEach(sel => {
const origOnchange = sel.getAttribute('onchange');
sel.removeAttribute('onchange');
sel.addEventListener('change', () => {
const val = sel.value;
const pageKey = (tab === 'machines') ? 'machine_page' : 'history_page';
try {
if (val.startsWith('http') || val.startsWith('/')) {
const url = new URL(val, window.location.origin);
const page = url.searchParams.get(pageKey) || 1;
const perPage = url.searchParams.get('per_page') || '';
let extra = `&${pageKey}=${page}`;
if (perPage) extra += `&per_page=${perPage}`;
this.searchInTab(tab, extra);
} else if (origOnchange && origOnchange.includes('per_page')) {
this.searchInTab(tab, `&per_page=${val}`);
}
} catch (err) { }
});
});
},
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) {
if (slot.is_pending) {
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: '{{ __("This slot is syncing with the machine. Please wait.") }}',
type: 'warning'
}
}));
return;
}
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;
window.location.href = "{{ route('admin.remote.stock') }}";
} else {
throw new Error(data.message || 'Unknown error');
}
} 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 Settlement')) }},
'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')) }},
'Superseded by new command': {{ Js::from(__('Superseded by new command')) }}
};
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 relative">
<!-- Spinner Overlay -->
<div x-show="tabLoading" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center"
x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<div
class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin">
</div>
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin"
style="animation-duration: 3s; direction: reverse;"></div>
<div class="relative w-8 h-8 flex items-center justify-center">
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
</div>
<p
class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
{{ __('Loading Data') }}...</p>
</div>
<!-- AJAX 可替換區域 -->
<div
:class="tabLoading ? 'opacity-30 pointer-events-none transition-opacity duration-300' : 'transition-opacity duration-300'">
<div x-ref="historyContent">
@include('admin.remote.partials.tab-history', ['history' => $history])
</div>
</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 relative">
<!-- AJAX Spinner (Machine List) -->
<div x-show="tabLoading"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center" x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
<div class="relative w-8 h-8 flex items-center justify-center">
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
</div>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">{{ __('Loading Data') }}...</p>
</div>
<!-- Filters Area -->
<div class="flex items-center justify-between mb-8">
<form @submit.prevent="searchInTab('machines')" 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="machineSearch"
placeholder="{{ __('Search machines...') }}"
class="luxury-input py-2.5 pl-12 pr-6 block w-72">
</form>
</div>
<div :class="tabLoading ? 'opacity-30 pointer-events-none transition-opacity duration-300' : 'transition-opacity duration-300'">
<div x-ref="machinesContent">
@include('admin.remote.partials.tab-machines', ['machines' => $machines])
</div>
</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), slot.is_pending ? 'opacity-50 cursor-not-allowed grayscale-[0.5]' : 'cursor-pointer hover:scale-[1.08] hover:-translate-y-3 hover:shadow-2xl active:scale-[0.98]']"
class="min-h-[300px] rounded-[2.5rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-500 group relative">
<!-- Pending Overlay -->
<template x-if="slot.is_pending">
<div class="absolute inset-0 z-30 flex items-center justify-center bg-white/10 dark:bg-slate-900/10 backdrop-blur-[1px] rounded-[2.5rem]">
<div class="flex flex-col items-center gap-2">
<div class="w-8 h-8 border-2 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
<span class="text-[8px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('Syncing') }}</span>
</div>
</div>
</template>
<!-- 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