star-cloud/resources/views/admin/sales/pickup-codes/index.blade.php
sky121113 6b9b577978 [FEAT] 取貨碼功能強化與 UI 重構
1. 取貨碼模組新增 Slug 機制與公開憑證頁面,支援外部連結訪問。
2. 狀態篩選下拉選單重構為極簡奢華風組件 (x-searchable-select),並優化後端空值處理。
3. 機台分佈地圖新增「依型號分類」功能,提升管理便利性。
4. 新增 B660 取貨碼驗證 API 端點供機台通訊使用。
5. 更新多語言語系檔 (zh_TW.json) 確保介面一致性。
2026-04-29 10:39:35 +08:00

637 lines
45 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-4 pb-20" x-data="pickupCodeManager(@js($slotSelectConfig))">
{{-- 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>
{{-- Main Content Card --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="isLoadingTable" />
<div id="ajax-content-container"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': isLoadingTable }"
@ajax:navigate.prevent.stop="fetchPage($event.detail.url)"
@click="if ($event.target.closest('a') && $event.target.closest('a').href && ($event.target.closest('a').href.includes('page=') || $event.target.closest('a').href.includes('per_page='))) { $event.preventDefault(); fetchPage($event.target.closest('a').href); }">
<form action="{{ route('admin.sales.pickup-codes') }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())">
<div class="relative group flex-1 md:flex-none">
<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 stroke-[2.5]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" 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') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search by code, machine name or serial...') }}">
</div>
<div class="w-full md:w-48">
<x-searchable-select name="status" id="filter-status" :placeholder="__('All Status')" :selected="request('status')" :hasSearch="false" @change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }} data-title="{{ __('Active') }}">{{ __('Active') }}</option>
<option value="used" {{ request('status') === 'used' ? 'selected' : '' }} data-title="{{ __('Used') }}">{{ __('Used') }}</option>
<option value="expired" {{ request('status') === 'expired' ? 'selected' : '' }} data-title="{{ __('Expired') }}">{{ __('Expired') }}</option>
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }} data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select>
</div>
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0">
<button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; syncSelect('filter-status', ''); $el.closest('form').dispatchEvent(new Event('submit'))"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95 border border-slate-200 dark:border-slate-700" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" 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>
</button>
</div>
</form>
{{-- Table (Desktop) --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<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">
{{ __('Code') }}
</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">
{{ __('Machine') }}
</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">
{{ __('Product / Slot') }}
</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">
{{ __('Expires At') }}
</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">
{{ __('Status') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
@forelse ($pickupCodes as $code)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 whitespace-nowrap">
@if($code->status === 'active' && $code->expires_at->isFuture())
<span @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-tighter bg-cyan-50 dark:bg-cyan-500/10 px-3 py-1 rounded-lg border border-cyan-100 dark:border-cyan-500/20 shadow-sm cursor-pointer hover:bg-cyan-100 dark:hover:bg-cyan-500/20 transition-all">
{{ $code->code }}
</span>
@else
<span class="text-lg font-black font-mono text-slate-400 dark:text-slate-600 tracking-tighter bg-slate-50 dark:bg-slate-900/50 px-3 py-1 rounded-lg border border-slate-100 dark:border-slate-800/50 shadow-sm">
{{ $code->code }}
</span>
@endif
</td>
<td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $code->machine->name }}</div>
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">{{ $code->machine->serial_no }}</div>
</td>
<td class="px-6 py-6">
@php
$slot = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
@endphp
<div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{ $productName }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $code->slot_no }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">{{ $code->expires_at->format('Y-m-d H:i') }}</div>
<div class="text-xs font-bold text-slate-400 mt-0.5">{{ $code->expires_at->diffForHumans() }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? 'expired' : $code->status;
@endphp
<x-status-badge :status="$displayStatus" />
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($code->status === 'active' && $code->expires_at->isFuture())
{{-- View QR Code Button --}}
<button @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-transparent hover:border-emerald-500/20 transition-all inline-flex group/btn"
title="{{ __('View QR 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="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
</button>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
<form id="cancel-form-desktop-{{ $code->id }}" action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST">
@csrf
@method('DELETE')
<button type="button" @click="confirmCancel('cancel-form-desktop-{{ $code->id }}')" class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Cancel 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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="5" :message="__('No pickup codes found')" />
@endforelse
</tbody>
</table>
</div>
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($pickupCodes as $code)
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 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 shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<div class="min-w-0">
@if($code->status === 'active' && $code->expires_at->isFuture())
<h3 @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $code->code }}
</h3>
@else
<h3 class="text-base font-extrabold text-slate-400 dark:text-slate-600 truncate tracking-tight">
{{ $code->code }}
</h3>
@endif
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->machine->name }}
</p>
</div>
</div>
@php
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? 'expired' : $code->status;
@endphp
<x-status-badge :status="$displayStatus" size="sm" />
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Slot') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $code->slot_no }}
@php
$slotMobile = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) : __('Empty');
@endphp
<span class="ml-1 text-cyan-600 dark:text-cyan-400">({{ $prodMobile }})</span>
</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Expires At') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono tracking-tighter">{{ $code->expires_at->format('Y-m-d H:i') }}</p>
</div>
</div>
{{-- Action Buttons --}}
<div class="flex items-center gap-3">
@if($code->status === 'active' && $code->expires_at->isFuture())
{{-- QR Button (Mobile) --}}
<button @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-emerald-500 hover:bg-emerald-500/5 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="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
{{ __('QR Code') }}
</button>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
<form id="cancel-form-mobile-{{ $code->id }}" action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST" class="flex-1">
@csrf
@method('DELETE')
<button type="button" @click="confirmCancel('cancel-form-mobile-{{ $code->id }}')" class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white 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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
{{ __('Cancel') }}
</button>
</form>
@endif
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pickup codes found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $pickupCodes->links('vendor.pagination.luxury') }}
</div>
</div>
</div>
{{-- 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">
@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') }}</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') }}</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>
{{-- Validity Period Section --}}
<div class="max-w-2xl mx-auto w-full">
<div class="bg-slate-50 dark:bg-slate-800/40 rounded-[2.5rem] p-8 border border-slate-100 dark:border-slate-800/50 shadow-sm">
<div class="flex items-center justify-between mb-8 px-2">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-cyan-500/50"></div>
<label class="text-[13px] font-black text-slate-500 dark:text-slate-400 tracking-wider">{{ __('Validity Period') }}</label>
</div>
<span class="text-[11px] font-bold text-slate-400/70 tracking-wide">{{ __('Max 24 Hours') }}</span>
</div>
<div class="space-y-10">
{{-- Slider and Value --}}
<div class="flex items-center gap-8">
<div class="flex-1 relative">
<input type="range" name="expires_hours" x-model="expiresHours" min="1" :max="maxHours"
class="w-full h-1 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-24 py-4 bg-white dark:bg-slate-900 rounded-[1.5rem] border border-slate-200 dark:border-slate-700 shadow-sm text-center ring-4 ring-slate-50 dark:ring-slate-800/50">
<span class="text-2xl font-black text-slate-800 dark:text-white leading-none" x-text="expiresHours"></span>
<span class="block text-[10px] font-black text-slate-400 uppercase mt-1 tracking-widest">{{ __('Hrs') }}</span>
</div>
</div>
{{-- Result Display --}}
<div class="relative">
<div class="flex flex-col items-center justify-center py-8 px-8 bg-white dark:bg-slate-900/50 rounded-[2rem] border border-slate-200/60 dark:border-slate-700/50 shadow-inner">
<span class="text-base font-bold text-slate-600 dark:text-slate-400 mb-5 tracking-wide">{{ __('Expected Expiry Date & Time') }}</span>
<div class="flex items-center gap-3 py-3 px-6 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border border-slate-100 dark:border-slate-700/50 shadow-sm">
<svg class="w-5 h-5 text-cyan-500/50" 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 sm:text-2xl font-black text-slate-700 dark:text-cyan-100 font-mono tracking-tight" x-text="calculateExpiry()"></span>
</div>
</div>
</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" :disabled="!selectedSlot">
{{ __('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">{{ __('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) => ({
showCreateModal: false,
selectedMachine: '',
selectedSlot: '',
expiresHours: 24,
maxHours: 24,
showQrModal: false,
activeQrCode: '',
activeTicketUrl: '',
slots: [],
loadingSlots: false,
isLoadingTable: false,
showCancelModal: false,
cancelTargetForm: null,
slotSelectConfig: config,
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;
}
},
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
window.history.pushState({}, '', url);
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
}
} catch (e) {
console.error('AJAX Load Failed:', e);
} finally {
this.isLoadingTable = false;
}
},
copyQrLink() {
if (!this.activeTicketUrl) return;
navigator.clipboard.writeText(this.activeTicketUrl).then(() => {
window.showToast("{{ __('Link 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';
selectEl.required = true;
selectEl.className = 'hidden';
selectEl.setAttribute('data-hs-select', JSON.stringify(this.slotSelectConfig));
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']);
const inst = window.HSSelect?.getInstance(selectEl);
if (inst && this.selectedSlot) inst.setValue(this.selectedSlot);
}
});
},
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.$watch('slots', () => this.updateSlotSelect());
this.$watch('selectedMachine', (val) => {
this.syncSelect('modal-pickup-machine', val);
this.updateSlotSelect();
});
this.$watch('showCreateModal', (val) => {
if (val) {
this.selectedMachine = '';
this.selectedSlot = '';
this.slots = [];
this.updateSlotSelect();
}
});
}
}));
});
</script>
@endsection