star-cloud/resources/views/admin/sales/pickup-codes/index.blade.php
sky121113 6ca3fa8a6b [FIX] 修正取貨碼有效期限固定為 24 小時的問題
1. 建立取貨碼時,隱藏的「自訂日期」欄位會被預填為明天並隨表單送出,導致後端 expires_at 永遠為 filled,固定套用約 24 小時。
2. 時數模式時 disabled 掉 expires_at 欄位,自訂日期模式時 disabled 掉 expires_hours 欄位,使停用欄位不被提交。
3. 修正後時數滑桿與自訂日期可各自正確生效,取貨碼有效期限依使用者設定產生。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:28:03 +08:00

795 lines
48 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')
@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="__('Usage Logs')" 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 --}}
{{-- Edit Pickup Code Modal --}}
<div x-show="showEditModal"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
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="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="showEditModal = false"></div>
<div class="relative w-full max-w-2xl luxury-card bg-white dark:bg-slate-900 rounded-[2.5rem] shadow-2xl shadow-slate-900/20 overflow-hidden border border-slate-100 dark:border-slate-800"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95 translate-y-8"
x-transition:enter-end="opacity-100 scale-100 translate-y-0">
<form :action="`/admin/sales/pickup-codes/${editPickupCodeId}`" method="POST" class="p-8">
@csrf
@method('PUT')
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-600 border border-cyan-500/20">
<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="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>
</div>
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Edit Pickup Code') }}</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Update usage and expiry') }}</p>
</div>
</div>
<button type="button" @click="showEditModal = false" class="p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 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="space-y-8">
{{-- Usage Limit --}}
<div>
<label class="block text-xs font-black text-slate-400 uppercase tracking-widest mb-4">{{ __('Usage Limit') }} (1-20)</label>
<div class="flex flex-col sm:flex-row items-center gap-10">
{{-- Unified Luxury Counter --}}
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 min-w-[200px] group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="editUsageLimit > 1 ? editUsageLimit-- : 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-[60px]">
<input type="text" name="usage_limit" x-model="editUsageLimit" readonly
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="editUsageLimit < 20 ? editUsageLimit++ : 20"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
{{-- Quick Select Buttons --}}
<div class="flex items-center gap-1.5">
<template x-for="val in [1, 5, 10, 20]">
<button type="button" @click="editUsageLimit = val"
class="w-10 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="editUsageLimit == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
</div>
{{-- Expiry Date --}}
<div>
<label class="block text-xs font-black text-slate-400 uppercase tracking-widest mb-4">{{ __('Expires At') }}</label>
<div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="w-4 h-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</span>
<input type="text" name="expires_at" x-model="editExpiresAt"
x-init="const fp = flatpickr($el, {
enableTime: true,
dateFormat: 'Y-m-d H:i',
time_24hr: true,
minuteIncrement: 1,
disableMobile: true,
locale: window.flatpickrLocale
}); $watch('editExpiresAt', v => fp.setDate(v, false))"
class="py-3 pl-12 pr-4 block w-full luxury-input text-sm font-bold">
</div>
</div>
</div>
<div class="mt-10 flex gap-3">
<button type="button" @click="showEditModal = false"
class="flex-1 py-4 rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-slate-200 dark:hover:bg-slate-700 transition-all">
{{ __('Cancel') }}
</button>
<button type="submit"
class="flex-[2] py-4 rounded-2xl bg-cyan-500 text-white font-black text-xs uppercase tracking-[0.2em] hover:bg-cyan-600 shadow-xl shadow-cyan-500/25 transition-all active:scale-[0.98]">
{{ __('Update') }}
</button>
</div>
</form>
</div>
</div>
{{-- Create Pickup Code 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" x-ref="createForm">
@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 show="loadingSlots" />
</div>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Usage Limit') }} (Max 20)</label>
<div class="flex flex-col sm:flex-row items-center gap-10">
{{-- Unified Luxury Counter --}}
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 min-w-[200px] group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="usageLimit > 1 ? usageLimit-- : 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-[60px]">
<input type="text" name="usage_limit" x-model="usageLimit" readonly
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="usageLimit < 20 ? usageLimit++ : 20"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
{{-- Quick Select Buttons - Removed scrollbar --}}
<div class="flex items-center gap-1.5">
<template x-for="val in [1, 5, 10, 20]">
<button type="button" @click="usageLimit = val"
class="w-10 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="usageLimit == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</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 (Flexible) --}}
<div class="space-y-4">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period') }}</label>
<div class="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-xl">
<button type="button" @click="expiryMode = 'hours'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="expiryMode === 'hours' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Hours') }}</button>
<button type="button" @click="expiryMode = 'date'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="expiryMode === 'date' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Custom Date') }}</button>
</div>
</div>
<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 x-show="expiryMode === 'hours'" x-transition>
<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="168"
:disabled="expiryMode !== 'hours'"
class="w-full h-1.5 bg-slate-200 dark:bg-slate-700/50 rounded-lg appearance-none cursor-pointer accent-cyan-500">
</div>
<div class="shrink-0 w-24 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" 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="flex flex-wrap gap-2 mb-6">
<template x-for="h in [24, 48, 72, 168]">
<button type="button" @click="expiresHours = h"
class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-[10px] font-black transition-all"
:class="expiresHours == h ? 'bg-cyan-500 text-white border-cyan-500' : 'bg-white dark:bg-slate-800 text-slate-500'">
<span x-text="h == 168 ? '7 {{ __('Days') }}' : h + 'h'"></span>
</button>
</template>
</div>
</div>
<div x-show="expiryMode === 'date'" x-transition>
<div class="relative group mb-6">
<input type="text" name="expires_at" x-model="customExpiry"
:disabled="expiryMode !== 'date'"
x-init="const fp = flatpickr($el, {
enableTime: true,
dateFormat: 'Y-m-d H:i',
time_24hr: true,
minuteIncrement: 1,
disableMobile: true,
locale: window.flatpickrLocale
}); $watch('customExpiry', v => fp.setDate(v, false))"
class="luxury-input w-full py-4 text-center font-mono text-xl bg-white dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 pl-4 pr-12 focus:border-cyan-500 transition-all">
<div class="absolute right-5 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-cyan-500 transition-colors pointer-events-none">
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2-2v12a2 2 0 002 2z"/>
</svg>
</div>
</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" x-text="expiryMode === 'hours' ? '{{ __('Based on Hours') }}' : '{{ __('Custom Date Set') }}'"></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="getDisplayExpiry()"></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: 168,
expiryMode: 'hours',
customExpiry: '',
usageLimit: 1,
showQrModal: false,
activeQrCode: '',
activeTicketUrl: '',
slots: [],
tabLoading: null,
loadingSlots: false,
showCancelModal: false,
cancelTargetForm: null,
slotSelectConfig: config,
customCode: '',
// Edit Pickup Code State
showEditModal: false,
editPickupCodeId: '',
editUsageLimit: 1,
editExpiresAt: '',
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 this.formatDate(date);
},
formatDate(date) {
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');
},
getDisplayExpiry() {
if (this.expiryMode === 'date' && this.customExpiry) {
return this.customExpiry.replace('T', ' ');
}
return this.calculateExpiry();
},
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) {
window.showToast('{{ __("Please select a slot") }}', 'error');
return;
}
this.$refs.createForm.submit();
},
openEditModal(id, limit, expiry) {
this.editPickupCodeId = id;
this.editUsageLimit = parseInt(limit);
this.editExpiresAt = expiry;
this.showEditModal = true;
},
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('expiryMode', (mode) => {
if (mode === 'date') {
this.customExpiry = this.calculateExpiry();
}
});
this.$watch('showCreateModal', (val) => {
if (val) {
this.selectedMachine = '';
this.selectedSlot = '';
this.usageLimit = 1;
this.expiryMode = 'hours';
this.expiresHours = 24;
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setMinutes(0);
this.customExpiry = this.formatDate(tomorrow);
this.slots = [];
this.generateRandomCode();
this.updateSlotSelect();
}
});
}
}));
});
</script>
@endsection