1. 通行碼與取貨碼:新增代碼預覽、重新產生功能與到期時間預覽邏輯。 2. 通行碼:允許天數設為 0 (永久),並改用搜尋式下拉選單選取機台。 3. 取貨碼:加強表單檢核並整合自訂 Toast 提示。 4. 語系優化:補齊繁中、英文鍵值,並完整建立日文語系目錄 (lang/ja) 與 JSON 檔案。 5. 組件優化:更新 PageHeader 與 TabNav 組件,提升 UI 互動性與一致性。 6. 訪客功能:新增通行碼訪客端查詢介面與控制器。 7. 其他模組:同步調整機台、遠端與倉庫模組視圖以符合極簡奢華風。
660 lines
43 KiB
PHP
660 lines
43 KiB
PHP
@extends('layouts.admin')
|
|
|
|
@section('content')
|
|
<div class="space-y-4 pb-20" x-data="{
|
|
showCreateModal: false,
|
|
showDeleteModal: false,
|
|
deleteTargetForm: '',
|
|
selectedMachine: '',
|
|
passCodeName: '',
|
|
expiresDays: 7,
|
|
customCode: '',
|
|
isLoadingTable: false,
|
|
status: '{{ request('status', '') }}',
|
|
showQrModal: false,
|
|
activeQrCode: '',
|
|
activeTicketUrl: '',
|
|
|
|
generateRandomCode() {
|
|
this.customCode = Math.floor(Math.random() * 90000000 + 10000000).toString();
|
|
},
|
|
|
|
calculateExpiry() {
|
|
if (!this.expiresDays || parseInt(this.expiresDays) <= 0) {
|
|
return '{{ __('Permanent') }}';
|
|
}
|
|
const date = new Date();
|
|
const days = parseInt(this.expiresDays) || 0;
|
|
date.setDate(date.getDate() + days);
|
|
return date.toLocaleString('zh-TW', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
}).replace(/\//g, '-');
|
|
},
|
|
|
|
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;
|
|
}
|
|
},
|
|
|
|
confirmDelete(formId) {
|
|
this.deleteTargetForm = formId;
|
|
this.showDeleteModal = true;
|
|
},
|
|
|
|
submitDelete() {
|
|
if (this.deleteTargetForm) {
|
|
const form = document.getElementById(this.deleteTargetForm);
|
|
if (form) form.submit();
|
|
}
|
|
this.showDeleteModal = false;
|
|
},
|
|
|
|
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;
|
|
if (window.HSSelect) {
|
|
const inst = window.HSSelect.getInstance(el);
|
|
if (inst) inst.setValue(valStr);
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
validateAndSubmit(e) {
|
|
if (!this.selectedMachine) {
|
|
window.showToast('{{ __('Please select a machine') }}', 'error');
|
|
return;
|
|
}
|
|
if (!this.passCodeName.trim()) {
|
|
window.showToast('{{ __('Please enter a description') }}', 'error');
|
|
return;
|
|
}
|
|
if (!this.customCode.trim()) {
|
|
window.showToast('{{ __('Please enter or generate a pass code') }}', 'error');
|
|
return;
|
|
}
|
|
e.target.submit();
|
|
},
|
|
|
|
copyQrCode() {
|
|
if (!this.activeQrCode) return;
|
|
navigator.clipboard.writeText(this.activeQrCode).then(() => {
|
|
window.showToast('{{ __('Code Copied') }}', 'success');
|
|
});
|
|
},
|
|
|
|
copyQrLink() {
|
|
if (!this.activeTicketUrl) return;
|
|
navigator.clipboard.writeText(this.activeTicketUrl).then(() => {
|
|
window.showToast('{{ __('Link Copied') }}', 'success');
|
|
});
|
|
},
|
|
|
|
async 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 = `pass-code-${this.activeQrCode}.png`;
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
});
|
|
},
|
|
|
|
init() {
|
|
this.$watch('showCreateModal', (val) => {
|
|
if (val) {
|
|
this.selectedMachine = '';
|
|
this.passCodeName = '';
|
|
this.expiresDays = 7;
|
|
this.generateRandomCode();
|
|
this.syncSelect('modal-pass-machine', '');
|
|
}
|
|
});
|
|
}
|
|
} ">
|
|
{{-- Page Header --}}
|
|
<x-page-header :title="__('Pass Codes')"
|
|
:subtitle="__('Manage multi-use authorization codes for testing and maintenance')">
|
|
<button @click="showCreateModal = true; generateRandomCode()" class="btn-luxury-primary">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
</svg>
|
|
<span>{{ __('Add Pass 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': isLoadingTable }">
|
|
|
|
<form action="{{ route('admin.sales.pass-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="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
|
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="11" cy="11" r="8" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</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, name or machine...') }}">
|
|
</div>
|
|
|
|
<div class="w-full md:w-48">
|
|
<x-searchable-select name="status" id="filter-status" :placeholder="__('All Status')"
|
|
x-model="status"
|
|
@change="$el.closest('form').dispatchEvent(new Event('submit'))">
|
|
<option value="active" {{ request('status')==='active' ? 'selected' : '' }}
|
|
data-title="{{ __('Active') }}">{{ __('Active') }}</option>
|
|
<option value="expired" {{ request('status')==='expired' ? 'selected' : '' }}
|
|
data-title="{{ __('Expired') }}">{{ __('Expired') }}</option>
|
|
<option value="disabled" {{ request('status')==='disabled' ? '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"
|
|
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=''; status = ''; 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 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all"
|
|
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">
|
|
{{ __('Pass 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">
|
|
{{ __('Name / 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">
|
|
{{ __('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 ($passCodes 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 || $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-widest bg-cyan-50 dark:bg-cyan-500/10 px-4 py-1.5 rounded-xl 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-widest bg-slate-50 dark:bg-slate-900/50 px-4 py-1.5 rounded-xl 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->name }}</div>
|
|
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
|
|
$code->machine->name }}</div>
|
|
</td>
|
|
<td class="px-6 py-6 whitespace-nowrap">
|
|
@if($code->expires_at)
|
|
<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 {{ $code->expires_at->isPast() ? 'text-rose-500' : 'text-slate-400' }} mt-0.5">
|
|
{{ $code->expires_at->isPast() ? __('Expired') : $code->expires_at->diffForHumans()
|
|
}}
|
|
</div>
|
|
@else
|
|
<span
|
|
class="text-xs font-black uppercase tracking-widest text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-lg border border-emerald-500/20">{{
|
|
__('Permanent') }}</span>
|
|
@endif
|
|
</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 || $code->expires_at->isFuture()))
|
|
<button type="button"
|
|
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
|
|
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300 border border-slate-100 dark:border-slate-800"
|
|
title="{{ __('View QR') }}">
|
|
<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')
|
|
<form id="delete-form-desktop-{{ $code->id }}"
|
|
action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST">
|
|
@csrf
|
|
@method('DELETE')
|
|
<button type="button"
|
|
@click="confirmDelete('delete-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 pass codes found')" />
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{{-- Card Grid (Mobile) --}}
|
|
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
@forelse ($passCodes 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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
|
</svg>
|
|
</div>
|
|
<div class="min-w-0">
|
|
@if($code->status === 'active' && (!$code->expires_at || $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->name }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="min-w-0">
|
|
@php
|
|
$displayStatusMobile = $code->status === 'active' && $code->expires_at?->isPast() ? 'expired' : $code->status;
|
|
@endphp
|
|
<x-status-badge :status="$displayStatusMobile" size="sm" />
|
|
</div>
|
|
</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">
|
|
{{ __('Machine') }}</p>
|
|
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{
|
|
$code->machine->name }}</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 ? $code->expires_at->format('Y-m-d H:i') : __('Permanent') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Action Buttons --}}
|
|
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800 flex gap-3">
|
|
@if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture()))
|
|
<button type="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')
|
|
<form id="delete-form-mobile-{{ $code->id }}"
|
|
action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST"
|
|
class="flex-1">
|
|
@csrf
|
|
@method('DELETE')
|
|
<button type="button" @click="confirmDelete('delete-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 pass codes found')" />
|
|
</div>
|
|
@endforelse
|
|
</div>
|
|
|
|
{{-- Pagination --}}
|
|
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
|
{{ $passCodes->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 Pass 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.pass-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-pass-machine"
|
|
:placeholder="__('Please select a machine')" x-model="selectedMachine"
|
|
@change="selectedMachine = $event.target.value">
|
|
@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">
|
|
{{ __('Description / Name') }} <span class="text-rose-500">*</span>
|
|
</label>
|
|
<input type="text" name="name" x-model="passCodeName" class="luxury-input w-full"
|
|
placeholder="{{ __('e.g. Test Code for Maintenance') }}">
|
|
</div>
|
|
</div>
|
|
|
|
<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">
|
|
{{ __('Pass 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>
|
|
|
|
<div class="space-y-4">
|
|
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period (Days)') }}</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">
|
|
<button type="button" @click="expiresDays = Math.max(0, parseInt(expiresDays || 0) - 1)"
|
|
class="shrink-0 w-12 h-12 rounded-xl bg-white dark:bg-slate-800 flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:shadow-lg active:scale-90 transition-all border border-slate-100 dark:border-slate-800">
|
|
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" />
|
|
</svg>
|
|
</button>
|
|
<div class="flex-1 relative">
|
|
<input type="number" name="expires_days" x-model="expiresDays"
|
|
class="w-full bg-transparent border-none text-center font-black text-3xl text-slate-800 dark:text-white focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
placeholder="{{ __('Permanent') }}">
|
|
<div class="text-center mt-1 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Days') }}</div>
|
|
</div>
|
|
<button type="button" @click="expiresDays = parseInt(expiresDays || 0) + 1"
|
|
class="shrink-0 w-12 h-12 rounded-xl bg-white dark:bg-slate-800 flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:shadow-lg active:scale-90 transition-all border border-slate-100 dark:border-slate-800">
|
|
<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 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
</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">{{ __('Estimated Expiry') }}</span>
|
|
<template x-if="!expiresDays || expiresDays <= 0">
|
|
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest px-2 py-0.5 bg-emerald-500/10 rounded-lg border border-emerald-500/20">{{ __('Permanent Code') }}</span>
|
|
</template>
|
|
</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>
|
|
<p class="text-center mt-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Leave empty or 0 for permanent code') }}</p>
|
|
</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">
|
|
{{ __('Create') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Confirm Cancel Modal --}}
|
|
<x-confirm-modal alpineVar="showDeleteModal" :title="__('Cancel Pass Code')"
|
|
:message="__('Are you sure you want to cancel this pass code? This action cannot be undone.')" :confirmText="__('Yes, Cancel')"
|
|
confirmAction="submitDelete()" confirmColor="rose" iconType="danger" />
|
|
|
|
{{-- QR Code Modal --}}
|
|
<div x-show="showQrModal" class="fixed inset-0 z-[60] 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">
|
|
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showQrModal = false">
|
|
</div>
|
|
|
|
<div x-show="showQrModal" 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 w-full max-w-sm transition-all transform luxury-card rounded-[2.5rem] bg-white dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl overflow-hidden">
|
|
|
|
<div class="p-8 pb-0 flex justify-between items-start">
|
|
<div>
|
|
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Pass Code QR') }}</h3>
|
|
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1" x-text="activeQrCode"></p>
|
|
</div>
|
|
<button @click="showQrModal = false"
|
|
class="p-2 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>
|
|
|
|
<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 to authorize testing or maintenance.') }}
|
|
</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.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</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>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('styles')
|
|
<style>
|
|
/* 移除 Chrome, Safari, Edge, Opera 的原生加減按鈕 */
|
|
input::-webkit-outer-spin-button,
|
|
input::-webkit-inner-spin-button {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
margin: 0;
|
|
}
|
|
|
|
/* 移除 Firefox 的原生加減按鈕 */
|
|
input[type=number] {
|
|
-moz-appearance: textfield;
|
|
appearance: textfield;
|
|
}
|
|
</style>
|
|
@endpush |