1. 新增機台異常日誌手動解除功能,包含資料庫欄位更新與 UI 操作按鈕。 2. 實作機台庫存變動紀錄系統 (Machine Stock Movements),支援追蹤補貨與銷售產生的庫存異動。 3. 重構取貨碼 (Pickup Code) 與通行碼 (Pass Code) 管理介面,採用極簡奢華風 UI 並拆分 Partial Views 提升可維護性。 4. 優化取貨操作日誌,明確區分「取貨成功」與「取貨失敗」,並加入顏色標籤增強辨識度。 5. 擴充多語系支援 (繁中、英文、日文),確保所有新功能與操作狀態均有對應翻譯。 6. 更新 IoT API 規格文件與相關 Service 邏輯,加強指令確認 (ACK) 處理機制。
587 lines
32 KiB
PHP
587 lines
32 KiB
PHP
@extends('layouts.admin')
|
||
|
||
@php
|
||
$slotSelectConfig = [
|
||
"placeholder" => __("Select Slot"),
|
||
"hasSearch" => true,
|
||
"searchPlaceholder" => __("Search Slot..."),
|
||
"isHidePlaceholder" => false,
|
||
"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg
|
||
focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200
|
||
placeholder:text-slate-400 dark:placeholder:text-slate-500",
|
||
"searchWrapperClasses" => "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
|
||
"toggleClasses" => "hs-select-toggle luxury-select-toggle",
|
||
"toggleTemplate" => '<button type="button" aria-expanded="false"><span class="me-2" data-icon></span><span
|
||
class="text-slate-800 dark:text-slate-200" data-title></span>
|
||
<div class="ms-auto"><svg class="size-4 text-slate-400 transition-transform duration-300"
|
||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="m6 9 6 6 6-6" />
|
||
</svg></div>
|
||
</button>',
|
||
"dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10
|
||
rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in",
|
||
"optionClasses" => "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer
|
||
hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between
|
||
transition-all duration-300",
|
||
"optionTemplate" => '<div class="flex items-center justify-between w-full"><span data-title></span><span
|
||
class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg"
|
||
width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
|
||
stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline points="20 6 9 17 4 12"></polyline>
|
||
</svg></span></div>'
|
||
];
|
||
@endphp
|
||
|
||
@section('content')
|
||
<div class="space-y-2 pb-20" x-data="pickupCodeManager(@js($slotSelectConfig))"
|
||
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
|
||
|
||
|
||
|
||
{{-- Page Header --}}
|
||
<x-page-header :title="__('Pickup Codes')"
|
||
:subtitle="__('Generate and manage one-time pickup codes for customers')">
|
||
<button @click="showCreateModal = true"
|
||
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
|
||
<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="M12 5v14M5 12h14" />
|
||
</svg>
|
||
<span class="text-sm sm:text-base">{{ __('Add Pickup Code') }}</span>
|
||
</button>
|
||
</x-page-header>
|
||
|
||
{{-- Tab Navigation --}}
|
||
<x-tab-nav model="activeTab">
|
||
<x-tab-nav-item value="list" :label="__('Pickup Codes')" model="activeTab" />
|
||
<x-tab-nav-item value="logs" :label="__('Operation Records')" model="activeTab" />
|
||
</x-tab-nav>
|
||
|
||
{{-- Tab Contents --}}
|
||
<div class="mt-6">
|
||
<div class="relative min-h-[400px]">
|
||
{{-- List Tab --}}
|
||
<div x-show="activeTab === 'list'" class="relative min-h-[400px]"
|
||
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
|
||
x-transition:enter-end="opacity-100 translate-y-0">
|
||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
|
||
<x-luxury-spinner show="tabLoading === 'list'" />
|
||
<div id="tab-list-container" class="relative"
|
||
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'list' }">
|
||
@include('admin.sales.pickup-codes.partials.tab-list')
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{-- End List Tab --}}
|
||
|
||
{{-- Logs Tab --}}
|
||
<div x-show="activeTab === 'logs'" class="relative min-h-[400px]"
|
||
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
|
||
x-transition:enter-end="opacity-100 translate-y-0" style="display: none;">
|
||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
|
||
<x-luxury-spinner show="tabLoading === 'logs'" />
|
||
<div id="tab-logs-container" class="relative"
|
||
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'logs' }">
|
||
@include('admin.sales.pickup-codes.partials.tab-logs')
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{-- End Logs Tab --}}
|
||
</div> {{-- End relative min-h --}}
|
||
</div> {{-- End mt-6 --}}
|
||
|
||
{{-- Create Modal --}}
|
||
<div x-show="showCreateModal" class="fixed inset-0 z-50 overflow-y-auto"
|
||
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" x-cloak>
|
||
<div class="flex items-center justify-center min-h-screen px-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="showCreateModal = false"></div>
|
||
|
||
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 translate-y-4 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 sm:scale-100"
|
||
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
|
||
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-2xl sm:w-full overflow-visible">
|
||
<div class="flex justify-between items-center mb-8">
|
||
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{
|
||
__('Add Pickup Code') }}</h3>
|
||
<button @click="showCreateModal = false"
|
||
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
||
d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<form action="{{ route('admin.sales.pickup-codes.store') }}" method="POST" class="space-y-6"
|
||
@submit.prevent="validateAndSubmit">
|
||
@csrf
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div class="space-y-2">
|
||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||
__('Target Machine') }} <span class="text-rose-500">*</span></label>
|
||
<x-searchable-select name="machine_id" id="modal-pickup-machine"
|
||
placeholder="{{ __('Please select a machine') }}" x-model="selectedMachine"
|
||
@change="selectedMachine = $event.target.value; fetchSlots()">
|
||
@foreach($machines as $machine)
|
||
<option value="{{ $machine->id }}"
|
||
data-title="{{ $machine->name }} ({{ $machine->serial_no }})">{{ $machine->name }}
|
||
({{ $machine->serial_no }})</option>
|
||
@endforeach
|
||
</x-searchable-select>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||
__('Select Slot') }} <span class="text-rose-500">*</span></label>
|
||
<div class="relative">
|
||
<div id="slot-select-wrapper" class="relative">
|
||
{{-- Rebuilt by Alpine --}}
|
||
</div>
|
||
<div x-show="loadingSlots" class="absolute right-12 top-1/2 -translate-y-1/2 z-10">
|
||
<x-luxury-spinner size="sm" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Pickup Code Preview Section --}}
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
|
||
{{ __('Pickup Code (8 Digits)') }} <span class="text-rose-500">*</span>
|
||
</label>
|
||
<button type="button" @click="generateRandomCode()"
|
||
class="text-[10px] font-black text-cyan-500 hover:text-cyan-600 uppercase tracking-widest flex items-center gap-1 transition-colors">
|
||
<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="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>
|
||
{{ __('Regenerate') }}
|
||
</button>
|
||
</div>
|
||
<input type="text" name="custom_code" x-model="customCode"
|
||
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5"
|
||
maxlength="12" required>
|
||
</div>
|
||
|
||
{{-- Validity Period Section (Compact) --}}
|
||
<div class="space-y-4">
|
||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period') }}</label>
|
||
|
||
<div
|
||
class="bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] p-6 border border-slate-100 dark:border-slate-800/50 shadow-sm">
|
||
<div class="flex items-center gap-6 mb-6">
|
||
<div class="flex-1 relative">
|
||
<input type="range" name="expires_hours" x-model="expiresHours" min="1"
|
||
:max="maxHours"
|
||
class="w-full h-1.5 bg-slate-200 dark:bg-slate-700/50 rounded-lg appearance-none cursor-pointer accent-cyan-500 transition-all hover:accent-cyan-400">
|
||
</div>
|
||
<div
|
||
class="shrink-0 w-20 py-3 bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm text-center">
|
||
<span class="text-xl font-black text-slate-800 dark:text-white leading-none"
|
||
x-text="expiresHours"></span>
|
||
<span
|
||
class="block text-[9px] font-black text-slate-400 uppercase mt-0.5 tracking-widest">{{
|
||
__('Hrs') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pt-6 border-t border-slate-100 dark:border-slate-800/50">
|
||
<div class="flex items-center justify-between mb-3 px-1">
|
||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Expected Expiry') }}</span>
|
||
<span class="text-[10px] font-bold text-slate-400/70 tracking-wide">{{ __('Max 24 Hours') }}</span>
|
||
</div>
|
||
<div
|
||
class="bg-white dark:bg-slate-900/50 rounded-2xl p-4 border border-slate-100 dark:border-slate-700/50 flex items-center justify-center gap-3">
|
||
<svg class="w-5 h-5 text-cyan-500/50 shrink-0" fill="none" stroke="currentColor"
|
||
viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<span
|
||
class="text-xl font-mono font-black text-slate-700 dark:text-slate-200 tracking-wider"
|
||
x-text="calculateExpiry()"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-x-4 pt-8">
|
||
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">{{
|
||
__('Cancel') }}</button>
|
||
<button type="submit" class="btn-luxury-primary px-12">
|
||
{{ __('Generate') }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- QR Modal --}}
|
||
<div x-show="showQrModal" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak
|
||
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">
|
||
<div class="flex items-center justify-center min-h-screen px-4">
|
||
<div class="fixed inset-0 transition-opacity" @click="showQrModal = false">
|
||
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
|
||
</div>
|
||
|
||
<div
|
||
class="relative bg-white dark:bg-slate-900 rounded-[2.5rem] shadow-2xl border border-slate-100 dark:border-slate-800 w-full max-w-sm overflow-hidden animate-luxury-in">
|
||
<div
|
||
class="px-8 py-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
|
||
<div>
|
||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('QR Code') }}
|
||
</h3>
|
||
<p class="text-xs font-bold text-cyan-600 dark:text-cyan-400 mt-1 uppercase tracking-widest">
|
||
{{ __('Pickup Code') }}: <span class="font-mono" x-text="activeQrCode"></span>
|
||
</p>
|
||
</div>
|
||
<button @click="showQrModal = false" class="text-slate-400 hover:text-slate-600 transition-colors">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="p-10 flex flex-col items-center gap-6">
|
||
<div class="p-4 bg-white rounded-3xl shadow-xl border border-slate-100">
|
||
<x-qr-code data="activeQrCode" size="200" class="w-48 h-48" />
|
||
</div>
|
||
|
||
<div class="text-center w-full space-y-4">
|
||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 leading-relaxed px-4">
|
||
{{ __('Scan this code at the machine or share the link with the customer.') }}
|
||
</p>
|
||
|
||
<div class="flex items-center justify-center gap-3">
|
||
{{-- Copy Link Button --}}
|
||
<button @click="copyQrLink()"
|
||
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-slate-100 dark:border-slate-700 hover:border-cyan-500/20 transition-all flex items-center justify-center gap-2 group"
|
||
title="{{ __('Copy Link') }}">
|
||
<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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||
</svg>
|
||
<span class="text-xs font-black uppercase tracking-widest">{{ __('Link') }}</span>
|
||
</button>
|
||
|
||
{{-- Copy Code Button --}}
|
||
<button @click="copyQrCode()"
|
||
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-slate-100 dark:border-slate-700 hover:border-cyan-500/20 transition-all flex items-center justify-center gap-2 group"
|
||
title="{{ __('Copy Code') }}">
|
||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||
</svg>
|
||
<span class="text-xs font-black uppercase tracking-widest">{{ __('Copy') }}</span>
|
||
</button>
|
||
|
||
{{-- Download Image Button --}}
|
||
<button @click="downloadQrCode()"
|
||
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-slate-100 dark:border-slate-700 hover:border-emerald-500/20 transition-all flex items-center justify-center gap-2 group"
|
||
title="{{ __('Download Image') }}">
|
||
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 12l4.5 4.5m0 0l4.5-4.5M12 3v13.5" />
|
||
</svg>
|
||
<span class="text-xs font-black uppercase tracking-widest">{{ __('Save') }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-center border-t border-slate-100 dark:border-slate-800">
|
||
<button @click="showQrModal = false" class="btn-luxury-primary w-full py-4 rounded-2xl">{{
|
||
__('Close') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Confirm Cancel Modal --}}
|
||
<x-confirm-modal alpineVar="showCancelModal" :title="__('Cancel Pickup Code')"
|
||
:message="__('Are you sure you want to cancel this pickup code? This action cannot be undone.')"
|
||
:confirmText="__('Yes, Cancel')" confirmAction="submitCancel()" confirmColor="rose" iconType="danger" />
|
||
</div>
|
||
<script>
|
||
document.addEventListener('alpine:init', () => {
|
||
Alpine.data('pickupCodeManager', (config) => ({
|
||
activeTab: '{{ request("tab", "list") }}',
|
||
tabLoading: null,
|
||
showCreateModal: false,
|
||
selectedMachine: '',
|
||
selectedSlot: '',
|
||
expiresHours: 24,
|
||
maxHours: 24,
|
||
showQrModal: false,
|
||
activeQrCode: '',
|
||
activeTicketUrl: '',
|
||
slots: [],
|
||
tabLoading: null,
|
||
loadingSlots: false,
|
||
showCancelModal: false,
|
||
cancelTargetForm: null,
|
||
slotSelectConfig: config,
|
||
customCode: '',
|
||
|
||
|
||
|
||
async fetchTabData(tab, url = null) {
|
||
if (this.tabLoading === tab) return;
|
||
this.tabLoading = tab;
|
||
const container = document.getElementById('tab-' + tab + '-container');
|
||
|
||
if (!url) {
|
||
let params = new URLSearchParams();
|
||
params.set('tab', tab);
|
||
params.set('_ajax', '1');
|
||
|
||
if (container) {
|
||
const form = container.querySelector('form');
|
||
if (form) {
|
||
const formData = new FormData(form);
|
||
formData.forEach((value, key) => {
|
||
if (value.trim() !== '') params.append(key, value);
|
||
});
|
||
}
|
||
}
|
||
url = `${window.location.pathname}?${params.toString()}`;
|
||
} else {
|
||
const urlObj = new URL(url, window.location.origin);
|
||
urlObj.searchParams.set('tab', tab);
|
||
urlObj.searchParams.set('_ajax', '1');
|
||
url = urlObj.toString();
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'Accept': 'application/json'
|
||
}
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
if (container) {
|
||
container.innerHTML = data.html;
|
||
this.$nextTick(() => {
|
||
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
|
||
});
|
||
}
|
||
|
||
const historyUrl = new URL(url, window.location.origin);
|
||
historyUrl.searchParams.delete('_ajax');
|
||
window.history.pushState({}, '', historyUrl.toString());
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
window.showToast?.('{{ __("Loading failed") }}', 'error');
|
||
} finally {
|
||
this.tabLoading = null;
|
||
}
|
||
},
|
||
|
||
generateRandomCode() {
|
||
this.customCode = Math.floor(Math.random() * 90000000 + 10000000).toString();
|
||
},
|
||
|
||
calculateExpiry() {
|
||
const date = new Date();
|
||
date.setHours(date.getHours() + parseInt(this.expiresHours));
|
||
return date.getFullYear() + '-' +
|
||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||
String(date.getDate()).padStart(2, '0') + ' ' +
|
||
String(date.getHours()).padStart(2, '0') + ':' +
|
||
String(date.getMinutes()).padStart(2, '0');
|
||
},
|
||
|
||
async fetchSlots() {
|
||
if (!this.selectedMachine) {
|
||
this.slots = [];
|
||
return;
|
||
}
|
||
this.loadingSlots = true;
|
||
try {
|
||
const response = await fetch(`/admin/machines/${this.selectedMachine}/slots-ajax`);
|
||
const data = await response.json();
|
||
this.slots = data.slots || [];
|
||
} catch (error) {
|
||
console.error('Error fetching slots:', error);
|
||
} finally {
|
||
this.loadingSlots = false;
|
||
}
|
||
},
|
||
|
||
validateAndSubmit() {
|
||
if (!this.selectedMachine || this.selectedMachine.toString().trim() === '') {
|
||
window.showToast('{{ __("Please select a machine") }}', 'error');
|
||
return;
|
||
}
|
||
if (!this.selectedSlot || this.selectedSlot.toString().trim() === '') {
|
||
window.showToast('{{ __("Please select a slot") }}', 'error');
|
||
return;
|
||
}
|
||
if (!this.customCode || this.customCode.toString().trim() === '') {
|
||
window.showToast('{{ __("Please enter or generate a pickup code") }}', 'error');
|
||
return;
|
||
}
|
||
this.$el.submit();
|
||
},
|
||
|
||
|
||
|
||
copyQrLink() {
|
||
if (!this.activeTicketUrl) return;
|
||
navigator.clipboard.writeText(this.activeTicketUrl).then(() => {
|
||
window.showToast("{{ __('Link Copied') }}", 'success');
|
||
});
|
||
},
|
||
|
||
copyQrCode() {
|
||
if (!this.activeQrCode) return;
|
||
navigator.clipboard.writeText(this.activeQrCode).then(() => {
|
||
window.showToast("{{ __('Code Copied') }}", 'success');
|
||
});
|
||
},
|
||
|
||
downloadQrCode() {
|
||
const url = `{{ route('admin.basic-settings.qr-code') }}?size=500&data=` + encodeURIComponent(this.activeQrCode);
|
||
fetch(url)
|
||
.then(response => response.blob())
|
||
.then(blob => {
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = `pickup-code-${this.activeQrCode}.png`;
|
||
link.click();
|
||
URL.revokeObjectURL(link.href);
|
||
});
|
||
},
|
||
|
||
confirmCancel(formId) {
|
||
this.cancelTargetForm = formId;
|
||
this.showCancelModal = true;
|
||
},
|
||
|
||
submitCancel() {
|
||
if (this.cancelTargetForm) {
|
||
const form = document.getElementById(this.cancelTargetForm);
|
||
if (form) form.submit();
|
||
}
|
||
this.showCancelModal = false;
|
||
},
|
||
|
||
updateSlotSelect() {
|
||
this.$nextTick(() => {
|
||
const wrapper = document.getElementById('slot-select-wrapper');
|
||
if (!wrapper) return;
|
||
|
||
wrapper.querySelectorAll('select').forEach(s => {
|
||
try { window.HSSelect?.getInstance(s)?.destroy(); } catch (e) { }
|
||
});
|
||
wrapper.innerHTML = '';
|
||
|
||
if (!this.selectedMachine) {
|
||
const dummy = document.createElement('div');
|
||
dummy.className = 'luxury-input opacity-50 cursor-not-allowed flex items-center justify-between';
|
||
dummy.innerHTML = `<span>{{ __("Select Slot") }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
|
||
wrapper.appendChild(dummy);
|
||
return;
|
||
}
|
||
|
||
const selectEl = document.createElement('select');
|
||
selectEl.name = 'slot_no';
|
||
// Remove required to allow our custom validation toast to trigger
|
||
selectEl.id = 'modal-pickup-slot-' + Date.now();
|
||
selectEl.className = 'hidden';
|
||
|
||
// 清理配置字串中的換行符
|
||
const cleanConfig = JSON.parse(JSON.stringify(this.slotSelectConfig), (key, value) => {
|
||
return typeof value === 'string' ? value.replace(/\r?\n|\r/g, ' ').trim() : value;
|
||
});
|
||
selectEl.setAttribute('data-hs-select', JSON.stringify(cleanConfig));
|
||
|
||
const placeholderOpt = document.createElement('option');
|
||
placeholderOpt.value = "";
|
||
placeholderOpt.textContent = "{{ __('Select Slot') }}";
|
||
placeholderOpt.setAttribute('data-title', "{{ __('Select Slot') }}");
|
||
selectEl.appendChild(placeholderOpt);
|
||
|
||
this.slots.forEach(slot => {
|
||
const opt = document.createElement('option');
|
||
opt.value = slot.slot_no;
|
||
const text = `${slot.slot_no} - ${slot.product ? slot.product.name : 'Empty'}`;
|
||
opt.textContent = text;
|
||
opt.setAttribute('data-title', text);
|
||
if (this.selectedSlot == slot.slot_no) opt.selected = true;
|
||
selectEl.appendChild(opt);
|
||
});
|
||
|
||
wrapper.appendChild(selectEl);
|
||
selectEl.addEventListener('change', e => { this.selectedSlot = e.target.value; });
|
||
|
||
if (window.HSStaticMethods?.autoInit) {
|
||
window.HSStaticMethods.autoInit(['select']);
|
||
}
|
||
});
|
||
},
|
||
|
||
syncSelect(id, value) {
|
||
this.$nextTick(() => {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
const valStr = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
|
||
el.value = valStr;
|
||
window.HSSelect?.getInstance(el)?.setValue(valStr);
|
||
}
|
||
});
|
||
},
|
||
|
||
init() {
|
||
this.generateRandomCode();
|
||
|
||
// Tab 切換時更新 URL(同庫存管理模式)
|
||
this.$watch('activeTab', (newTab) => {
|
||
const url = new URL(window.location.origin + window.location.pathname);
|
||
url.searchParams.set('tab', newTab);
|
||
window.history.pushState({}, '', url);
|
||
|
||
// Tab data is pre-rendered; refresh only via filters/pagination or specific actions
|
||
|
||
|
||
this.$nextTick(() => {
|
||
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
|
||
});
|
||
});
|
||
|
||
this.$watch('slots', () => this.updateSlotSelect());
|
||
this.$watch('selectedMachine', (val) => {
|
||
this.syncSelect('modal-pickup-machine', val);
|
||
this.selectedSlot = ''; // Reset slot when machine changes
|
||
this.fetchSlots();
|
||
});
|
||
this.$watch('selectedSlot', (val) => {
|
||
// Selection toast removed per user request
|
||
});
|
||
this.$watch('showCreateModal', (val) => {
|
||
if (val) {
|
||
this.selectedMachine = '';
|
||
this.selectedSlot = '';
|
||
this.slots = [];
|
||
this.generateRandomCode();
|
||
this.updateSlotSelect();
|
||
}
|
||
});
|
||
}
|
||
}));
|
||
});
|
||
</script>
|
||
@endsection |