All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m6s
1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。 2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。 3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。 4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。 5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
908 lines
64 KiB
PHP
908 lines
64 KiB
PHP
@extends('layouts.admin')
|
|
|
|
|
|
@section('content')
|
|
<script>
|
|
window.machineApp = function () {
|
|
return {
|
|
showLogPanel: false,
|
|
showEditModal: false,
|
|
showInventoryPanel: false,
|
|
editMachineId: '',
|
|
editMachineName: '',
|
|
activeTab: 'status',
|
|
currentMachineId: '',
|
|
currentMachineSn: '',
|
|
currentMachineName: '',
|
|
logs: [],
|
|
loading: false,
|
|
inventoryLoading: false,
|
|
startDate: '',
|
|
endDate: '',
|
|
tab: 'list',
|
|
viewMode: 'fleet',
|
|
selectedMachine: null,
|
|
slots: [],
|
|
inventorySlots: [],
|
|
currentPage: 1,
|
|
lastPage: 1,
|
|
|
|
init() {
|
|
const now = new Date();
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
const formatDate = (date, time) => `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${time}`;
|
|
|
|
this.startDate = formatDate(now, '00:00');
|
|
this.endDate = formatDate(now, '23:59');
|
|
this.$watch('activeTab', () => this.fetchLogs(1));
|
|
},
|
|
|
|
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(page = 1) {
|
|
this.loading = true;
|
|
this.currentPage = page;
|
|
try {
|
|
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab + '&page=' + page;
|
|
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 || [];
|
|
this.currentPage = data.pagination.current_page;
|
|
this.lastPage = data.pagination.last_page;
|
|
}
|
|
} 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 openInventoryPanel(id, sn, name) {
|
|
this.currentMachineId = id;
|
|
this.currentMachineSn = sn;
|
|
this.currentMachineName = name;
|
|
this.inventorySlots = [];
|
|
this.showInventoryPanel = true;
|
|
this.inventoryLoading = true;
|
|
try {
|
|
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
this.inventorySlots = data.slots;
|
|
}
|
|
} catch (e) { console.error('openInventoryPanel error:', e); }
|
|
finally { this.inventoryLoading = false; }
|
|
},
|
|
|
|
formatDateTime(dateStr) {
|
|
if (!dateStr) return '--';
|
|
const d = new Date(dateStr);
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
},
|
|
|
|
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';
|
|
},
|
|
|
|
};
|
|
};
|
|
</script>
|
|
|
|
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()"
|
|
@keydown.escape.window="showLogPanel = false; showInventoryPanel = 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">
|
|
{{ __('Machine List') }}
|
|
</h1>
|
|
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
|
|
{{ __('Manage your machine fleet and operational data') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Card (Machine List) -->
|
|
<div class="luxury-card rounded-3xl p-8 animate-luxury-in overflow-hidden mt-6">
|
|
<!-- Filters Area -->
|
|
<div class="flex items-center justify-between mb-8">
|
|
<form method="GET" action="{{ route('admin.machines.index') }}" class="relative group">
|
|
<input type="hidden" name="tab" value="list">
|
|
<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-72">
|
|
</form>
|
|
</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 / Temp / Sub / Card / Scan') }}</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 Page') }}</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">
|
|
{{ __('APP Version') }}</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 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-right">
|
|
{{ __('Actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
|
@foreach ($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 cursor-pointer group"
|
|
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')">
|
|
<div class="flex items-center gap-3">
|
|
<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">
|
|
@if(isset($machine->image_urls[0]))
|
|
<img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover">
|
|
@else
|
|
<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="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>
|
|
@endif
|
|
</div>
|
|
<div>
|
|
<div
|
|
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 tracking-tight">
|
|
{{ $machine->name }}
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-0.5">
|
|
<span
|
|
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
|
{{ $machine->serial_no ?: '--' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-6 text-center">
|
|
<div class="flex items-center justify-center gap-2">
|
|
@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>
|
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
|
</div>
|
|
<span
|
|
class="text-xs font-black text-emerald-600 dark:text-emerald-400 tracking-widest uppercase">{{
|
|
__('Online') }}</span>
|
|
</div>
|
|
@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 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">{{
|
|
__('Offline') }}</span>
|
|
</div>
|
|
@endif
|
|
|
|
<span class="text-slate-200 dark:text-slate-800 font-light mx-1">|</span>
|
|
<span class="font-mono font-bold text-slate-600 dark:text-slate-300">{{
|
|
$machine->temperature ?? '--' }}°C</span>
|
|
<span class="text-slate-200 dark:text-slate-800 font-light mx-1">|</span>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500/50"></span>
|
|
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tighter">{{
|
|
__('Normal') }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500/50"></span>
|
|
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tighter">{{
|
|
__('Normal') }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500/50"></span>
|
|
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tighter">{{
|
|
__('Normal') }}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-6 font-bold text-slate-600 dark:text-slate-400 text-center">
|
|
{{ $machine->current_page_label ?: '-' }}
|
|
</td>
|
|
<td class="px-6 py-6 font-mono text-xs font-bold text-slate-500 text-center">
|
|
{{ $machine->firmware_version ?: '-' }}
|
|
</td>
|
|
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest text-center">
|
|
{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->format('Y-m-d H:i:s') : '-' }}
|
|
</td>
|
|
<td class="px-6 py-6 text-right">
|
|
<div class="flex items-center justify-end gap-2">
|
|
<button type="button"
|
|
@click="openInventoryPanel('{{ $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"
|
|
title="{{ __('View Inventory') }}">
|
|
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
</svg>
|
|
</button>
|
|
<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 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>
|
|
</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"
|
|
title="{{ __('View Logs') }}">
|
|
<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="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
|
{{ $machines->appends(request()->query())->links('vendor.pagination.luxury') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Offcanvas Log Panel -->
|
|
<div x-show="showLogPanel" class="fixed inset-0 z-[100] overflow-hidden" style="display: none;"
|
|
aria-labelledby="slide-over-title" role="dialog" aria-modal="true">
|
|
|
|
<!-- Background backdrop -->
|
|
<div x-show="showLogPanel" x-transition:enter="ease-in-out duration-300" x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100" x-transition:leave="ease-in-out duration-300"
|
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
|
class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" @click="showLogPanel = false">
|
|
</div>
|
|
|
|
<div class="fixed inset-y-0 right-0 max-w-full flex">
|
|
<!-- Sliding panel -->
|
|
<div x-show="showLogPanel"
|
|
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
|
|
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
|
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
|
|
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
|
class="w-screen max-w-4xl">
|
|
|
|
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
|
|
<!-- Header -->
|
|
<div
|
|
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="min-w-0 flex-1">
|
|
<h2
|
|
class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
|
|
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0"
|
|
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
stroke-linejoin="round">
|
|
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
|
|
<path d="M12 12v9" />
|
|
<path d="m8 17 4 4 4-4" />
|
|
</svg>
|
|
<span class="truncate">{{ __('Machine Logs') }}</span>
|
|
</h2>
|
|
<div
|
|
class="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-[10px] sm:text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
|
|
<span x-text="currentMachineSn"
|
|
class="font-mono text-cyan-600 dark:text-cyan-400 truncate"></span>
|
|
<span class="hidden sm:inline opacity-50">—</span>
|
|
<span x-text="currentMachineName" class="truncate"></span>
|
|
</div>
|
|
</div>
|
|
<div class="flex-shrink-0 h-7 flex items-center">
|
|
<button type="button" @click="showLogPanel = false"
|
|
class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
|
|
<span class="sr-only">{{ __('Close Panel') }}</span>
|
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
|
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Responsive Search within Panel -->
|
|
<div class="mt-6 flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6">
|
|
<div class="grid grid-cols-2 sm:flex sm:items-center gap-3 sm:gap-4 flex-1">
|
|
<div class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2">
|
|
<label
|
|
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
|
|
__('From') }}</label>
|
|
<input type="text" x-ref="startDatePicker" x-model="startDate"
|
|
x-init="flatpickr($refs.startDatePicker, {
|
|
enableTime: true,
|
|
dateFormat: 'Y/m/d H:i',
|
|
time_24hr: true,
|
|
locale: window.flatpickrLocale,
|
|
defaultDate: startDate,
|
|
onClose: (selectedDates, dateStr) => { startDate = dateStr; fetchLogs(1); }
|
|
})"
|
|
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-44 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
|
|
</div>
|
|
<div class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2">
|
|
<label
|
|
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
|
|
__('To') }}</label>
|
|
<input type="text" x-ref="endDatePicker" x-model="endDate"
|
|
x-init="flatpickr($refs.endDatePicker, {
|
|
enableTime: true,
|
|
dateFormat: 'Y/m/d H:i',
|
|
time_24hr: true,
|
|
locale: window.flatpickrLocale,
|
|
defaultDate: endDate,
|
|
onClose: (selectedDates, dateStr) => { endDate = dateStr; fetchLogs(1); }
|
|
})"
|
|
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-44 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-start sm:justify-end">
|
|
<button @click="startDate = ''; endDate = ''; fetchLogs(1)"
|
|
class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5">
|
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2.5">
|
|
<path d="M18 13.5V19a2 2 0 0 1-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h5.5l7.5 7.5z" />
|
|
<path d="m14 2 2 2 2 2" />
|
|
<path d="m18 2-6 6" />
|
|
</svg>
|
|
{{ __('Clear Filter') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div><!-- /Header -->
|
|
|
|
<!-- Body / Navigation Tabs -->
|
|
<div class="flex-1 flex flex-col min-h-0">
|
|
<div
|
|
class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 px-6 sm:px-8 overflow-x-auto overflow-y-hidden hide-scrollbar">
|
|
<nav class="-mb-px flex space-x-6 sm:space-x-8" aria-label="Tabs">
|
|
<button @click="activeTab = 'status'"
|
|
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'status', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'status'}"
|
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
|
|
{{ __('Machine Status') }}
|
|
</button>
|
|
<button @click="activeTab = 'login'"
|
|
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'login', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'login'}"
|
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
|
|
{{ __('Machine Login Logs') }}
|
|
</button>
|
|
<button @click="activeTab = 'submachine'"
|
|
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'submachine', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'submachine'}"
|
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
|
|
{{ __('Sub-machine Status Request') }}
|
|
</button>
|
|
<button @click="activeTab = 'device'"
|
|
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'device', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'device'}"
|
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
|
|
{{ __('Device Status Logs') }}
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Tab Contents -->
|
|
<div class="flex-1 overflow-y-auto p-6 sm:p-8">
|
|
|
|
<div class="relative min-h-[400px]">
|
|
<!-- Loading State -->
|
|
<div x-show="loading"
|
|
class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 backdrop-blur-[1px] flex items-center justify-center z-20">
|
|
<div class="flex flex-col items-center gap-3">
|
|
<div
|
|
class="w-8 h-8 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin">
|
|
</div>
|
|
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{
|
|
__('Loading...') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs Container -->
|
|
<div x-show="activeTab !== 'expiry'"
|
|
class="luxury-card border border-slate-200 dark:border-slate-800 rounded-2xl overflow-hidden shadow-sm">
|
|
<!-- Desktop Table -->
|
|
<div class="hidden sm:block overflow-x-auto">
|
|
<table
|
|
class="w-full text-left border-separate border-spacing-y-0 text-sm whitespace-nowrap">
|
|
<thead class="bg-slate-50 dark:bg-slate-800/50">
|
|
<tr>
|
|
<th
|
|
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-200 dark:border-slate-700 w-40">
|
|
{{ __('Timestamp') }}</th>
|
|
<th
|
|
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-200 dark:border-slate-700 w-24 text-center">
|
|
{{ __('Level') }}</th>
|
|
<th
|
|
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-200 dark:border-slate-700">
|
|
{{ __('Message') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
|
<template x-for="log in logs" :key="log.id">
|
|
<tr
|
|
class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all">
|
|
<td class="px-6 py-4">
|
|
<div class="text-[12px] font-bold text-slate-600 dark:text-slate-300"
|
|
x-text="formatDateTime(log.created_at)">
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 text-center">
|
|
<span :class="{
|
|
'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20': log.level === 'info',
|
|
'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20': log.level === 'warning',
|
|
'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20': log.level === 'error'
|
|
}"
|
|
class="inline-flex items-center px-2 py-0.5 rounded-md border text-[10px] font-black uppercase tracking-wider">
|
|
<span x-text="log.level"></span>
|
|
</span>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<p class="text-[13px] font-medium text-slate-700 dark:text-slate-200 truncate max-w-md"
|
|
:title="log.translated_message || log.message" x-text="log.translated_message || log.message"></p>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Mobile List view -->
|
|
<div class="sm:hidden divide-y divide-slate-100 dark:divide-slate-800">
|
|
<template x-for="log in logs" :key="log.id">
|
|
<div
|
|
class="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span
|
|
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest"
|
|
x-text="formatDateTime(log.created_at)"></span>
|
|
<span :class="{
|
|
'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20': log.level === 'info',
|
|
'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20': log.level === 'warning',
|
|
'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20': log.level === 'error'
|
|
}"
|
|
class="inline-flex items-center px-1.5 py-0.5 rounded-md border text-[9px] font-black uppercase tracking-tight">
|
|
<span x-text="log.level"></span>
|
|
</span>
|
|
</div>
|
|
<p class="text-[13px] font-bold text-slate-700 dark:text-slate-200 line-clamp-3 leading-relaxed"
|
|
x-text="log.translated_message || log.message"></p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<template x-if="logs.length === 0 && !loading">
|
|
<div class="px-6 py-20 text-center">
|
|
<div class="flex flex-col items-center">
|
|
<div
|
|
class="p-4 rounded-full bg-slate-50 dark:bg-slate-800/50 mb-4 border border-slate-100 dark:border-slate-800/50">
|
|
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600"
|
|
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="1.5">
|
|
<path
|
|
d="M21 7v10c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2z" />
|
|
<path d="M12 11l4-4" />
|
|
<path d="M8 15l4-4" />
|
|
</svg>
|
|
</div>
|
|
<span
|
|
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
|
|
__('No matching logs found') }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Pagination Footer -->
|
|
<div x-show="logs.length > 0" class="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<button @click="fetchLogs(currentPage - 1)"
|
|
:disabled="currentPage <= 1"
|
|
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
|
|
<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.5" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest" x-text="currentPage"></span>
|
|
<span class="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase">/</span>
|
|
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest" x-text="lastPage"></span>
|
|
</div>
|
|
|
|
<button @click="fetchLogs(currentPage + 1)"
|
|
:disabled="currentPage >= lastPage"
|
|
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
|
|
<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.5" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div><!-- /Body -->
|
|
|
|
</div>
|
|
</div><!-- /Sliding panel -->
|
|
</div>
|
|
</div><!-- /Offcanvas -->
|
|
|
|
<!-- 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">​</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 -->
|
|
|
|
<!-- Inventory Offcanvas Panel (唯讀庫存一覽) -->
|
|
<div x-show="showInventoryPanel" class="fixed inset-0 z-[100] overflow-hidden" style="display: none;"
|
|
aria-labelledby="inventory-panel-title" role="dialog" aria-modal="true">
|
|
|
|
<!-- Background backdrop -->
|
|
<div x-show="showInventoryPanel" x-transition:enter="ease-in-out duration-300"
|
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
|
x-transition:leave="ease-in-out duration-300" x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
|
|
@click="showInventoryPanel = false">
|
|
</div>
|
|
|
|
<div class="fixed inset-y-0 right-0 max-w-full flex">
|
|
<!-- Sliding panel -->
|
|
<div x-show="showInventoryPanel"
|
|
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
|
|
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
|
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
|
|
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
|
class="w-screen max-w-4xl">
|
|
|
|
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
|
|
<!-- Header -->
|
|
<div
|
|
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="min-w-0 flex-1">
|
|
<h2 id="inventory-panel-title"
|
|
class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
|
|
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0"
|
|
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
stroke-linejoin="round">
|
|
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
</svg>
|
|
<span class="truncate">{{ __('Stock & Expiry Overview') }}</span>
|
|
</h2>
|
|
<div
|
|
class="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-[10px] sm:text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
|
|
<span x-text="currentMachineSn"
|
|
class="font-mono text-cyan-600 dark:text-cyan-400 truncate"></span>
|
|
<span class="hidden sm:inline opacity-50">—</span>
|
|
<span x-text="currentMachineName" class="truncate"></span>
|
|
</div>
|
|
</div>
|
|
<div class="flex-shrink-0 h-7 flex items-center">
|
|
<button type="button" @click="showInventoryPanel = false"
|
|
class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
|
|
<span class="sr-only">{{ __('Close Panel') }}</span>
|
|
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
|
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 統計摘要 -->
|
|
<div class="mt-6 flex items-center gap-4">
|
|
<div
|
|
class="px-5 py-3 rounded-2xl bg-white dark:bg-slate-800/50 flex flex-col items-center min-w-[100px] border border-slate-100 dark:border-slate-800/50">
|
|
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{
|
|
__('Total Slots') }}</span>
|
|
<span class="text-2xl font-black text-slate-700 dark:text-slate-200"
|
|
x-text="inventorySlots.length"></span>
|
|
</div>
|
|
<div
|
|
class="px-5 py-3 rounded-2xl bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[100px]">
|
|
<span class="text-[9px] font-black text-rose-500 uppercase tracking-widest mb-0.5">{{
|
|
__('Low Stock') }}</span>
|
|
<span class="text-2xl font-black text-rose-600"
|
|
x-text="inventorySlots.filter(s => s != null && s.stock <= 5).length"></span>
|
|
</div>
|
|
<div
|
|
class="px-5 py-3 rounded-2xl bg-amber-500/5 border border-amber-500/10 flex flex-col items-center min-w-[100px]">
|
|
<span class="text-[9px] font-black text-amber-500 uppercase tracking-widest mb-0.5">{{
|
|
__('Expiring') }}</span>
|
|
<span class="text-2xl font-black text-amber-600"
|
|
x-text="inventorySlots.filter(s => { if (!s || !s.expiry_date) return false; const diff = Math.round((new Date(s.expiry_date) - new Date()) / 86400000); return diff >= 0 && diff <= 7; }).length"></span>
|
|
</div>
|
|
</div>
|
|
</div><!-- /Header -->
|
|
|
|
<!-- Body / Cabinet Grid -->
|
|
<div class="flex-1 overflow-y-auto p-6 sm:p-8">
|
|
<div class="relative min-h-[400px]">
|
|
<!-- Loading State -->
|
|
<div x-show="inventoryLoading"
|
|
class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 backdrop-blur-[1px] flex items-center justify-center z-20">
|
|
<div class="flex flex-col items-center gap-3">
|
|
<div
|
|
class="w-8 h-8 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin">
|
|
</div>
|
|
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{
|
|
__('Loading...') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status Legend -->
|
|
<div class="flex items-center gap-6 mb-6" x-show="!inventoryLoading">
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-3 h-3 rounded-full bg-rose-500 shadow-lg shadow-rose-500/30"></span>
|
|
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
|
|
__('Expired') }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="w-3 h-3 rounded-full bg-amber-500 shadow-lg shadow-amber-500/30"></span>
|
|
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
|
|
__('Warning') }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="w-3 h-3 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/30"></span>
|
|
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
|
|
__('Normal') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Slots Grid (唯讀) -->
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5"
|
|
x-show="!inventoryLoading">
|
|
<template x-for="slot in inventorySlots" :key="slot.id">
|
|
<div :class="getSlotColorClass(slot)"
|
|
class="min-h-[260px] rounded-[2rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-300 relative">
|
|
|
|
<!-- Slot Header -->
|
|
<div
|
|
class="absolute top-3.5 left-4 right-4 flex justify-between items-center z-10">
|
|
<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 py-1 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-16 h-16 mb-3 mt-2">
|
|
<div
|
|
class="absolute inset-0 rounded-2xl bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 dark:border-white/5 shadow-inner overflow-hidden">
|
|
<template x-if="slot.product && slot.product.image_url">
|
|
<img :src="slot.product.image_url"
|
|
class="w-full h-full object-cover">
|
|
</template>
|
|
<template x-if="!slot.product || !slot.product.image_url">
|
|
<div class="w-full h-full flex items-center justify-center">
|
|
<svg class="w-7 h-7 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-2">
|
|
<template x-if="slot.product">
|
|
<div class="text-sm font-black truncate w-full opacity-90 tracking-tight"
|
|
x-text="slot.product.name"></div>
|
|
</template>
|
|
<template x-if="!slot.product">
|
|
<div
|
|
class="text-sm font-bold text-slate-300 dark:text-slate-600 tracking-tight">
|
|
{{ __('Empty') }}</div>
|
|
</template>
|
|
|
|
<div class="space-y-2">
|
|
<!-- Stock Level -->
|
|
<div class="flex items-baseline justify-center gap-1">
|
|
<span class="text-xl 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>
|
|
|
|
<!-- Expiry Date -->
|
|
<div class="text-sm font-black tracking-tight leading-none opacity-80"
|
|
x-text="slot.expiry_date ? slot.expiry_date.replace(/-/g, '/') : '----/--/--'">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<template x-if="inventorySlots.length === 0 && !inventoryLoading">
|
|
<div class="px-6 py-20 text-center">
|
|
<div class="flex flex-col items-center">
|
|
<div
|
|
class="p-4 rounded-full bg-slate-50 dark:bg-slate-800/50 mb-4 border border-slate-100 dark:border-slate-800/50">
|
|
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600" viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path
|
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
</svg>
|
|
</div>
|
|
<span
|
|
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
|
|
__('No slot data available') }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div><!-- /Body -->
|
|
|
|
</div>
|
|
</div><!-- /Sliding panel -->
|
|
</div>
|
|
</div><!-- /Inventory Offcanvas -->
|
|
|
|
@endsection |