[FEAT] 優化取貨碼管理介面與功能

1. 新增取貨碼使用次數上限功能(限制 1-20 次)。
2. 優化取貨碼管理介面,採用極簡奢華風計數器並調整寬度與間距。
3. 整合 Flatpickr 24小時制日期時間選擇器,並實作模式切換時的時間自動帶入邏輯。
4. 取貨碼清單新增「剩餘使用次數 / 總次數」顯示。
5. 完善相關中文化翻譯(如「小時」、「自定義日期」等)。
6. 新增資料庫欄位 migration 並更新相關模型與 Service 邏輯。
7. 更新 API 技術規格文檔以符合最新取貨碼邏輯。
This commit is contained in:
sky121113 2026-05-06 16:33:15 +08:00
parent d740fc5ab8
commit fa6406b9ad
11 changed files with 367 additions and 73 deletions

View File

@ -162,7 +162,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
- **Response Body (Success 200):**
- **Response Body (Success 200 - Object):**
| 欄位 (Key) | 說明 | 備註 |
| :--- | :--- | :--- |

View File

@ -239,17 +239,25 @@ class SalesController extends Controller
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'slot_no' => 'required|string',
'expires_hours' => 'nullable|integer|min:1|max:720', // 最長一個月
'usage_limit' => 'nullable|integer|min:1|max:20',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:12',
]);
$machine = Machine::findOrFail($validated['machine_id']);
$expiresAt = now()->addHours((int) ($request->expires_hours ?? 24));
// 處理過期時間:優先使用直接傳入的日期,否則使用時數計算
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
: now()->addHours((int) ($request->expires_hours ?? 24));
$pickupCode = PickupCode::create([
'machine_id' => $validated['machine_id'],
'slot_no' => $validated['slot_no'],
'code' => $request->custom_code ?? PickupCode::generateUniqueCode($validated['machine_id']),
'usage_limit' => $validated['usage_limit'] ?? 1,
'usage_count' => 0,
'expires_at' => $expiresAt,
'status' => 'active',
'company_id' => $machine->company_id,
@ -275,14 +283,16 @@ class SalesController extends Controller
public function updatePickupCode(Request $request, PickupCode $pickupCode)
{
$validated = $request->validate([
'expires_at' => 'required|date|after:now',
'expires_at' => 'nullable|date|after:now',
'usage_limit' => 'nullable|integer|min:1|max:20',
]);
$oldValues = $pickupCode->toArray();
$pickupCode->update([
'expires_at' => $validated['expires_at'],
]);
$pickupCode->update(array_filter([
'expires_at' => $validated['expires_at'] ?? null,
'usage_limit' => $validated['usage_limit'] ?? null,
]));
SystemOperationLog::create([
'company_id' => $pickupCode->company_id,

View File

@ -498,7 +498,7 @@ class MachineController extends Controller
return response()->json([
'success' => true,
'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列
'data' => $data
]);
}

View File

@ -20,7 +20,8 @@ class PickupCode extends Model
'slug',
'expires_at',
'used_at',
'status',
'usage_limit',
'usage_count',
'created_by',
'order_id',
];
@ -77,7 +78,9 @@ class PickupCode extends Model
*/
public function isValid(): bool
{
return $this->status === 'active' && $this->expires_at->isFuture();
return $this->status === 'active'
&& ($this->usage_count < $this->usage_limit)
&& ($this->expires_at->isFuture());
}
/**

View File

@ -247,10 +247,14 @@ class TransactionService
->where('status', 'active')
->first();
if ($pickupCode) {
$newCount = $pickupCode->usage_count + 1;
$isUsedUp = $newCount >= $pickupCode->usage_limit;
$pickupCode->update([
'order_id' => $order->id,
'status' => 'used',
'used_at' => now()
'usage_count' => $newCount,
'status' => $isUsedUp ? 'used' : 'active',
'used_at' => $isUsedUp ? now() : $pickupCode->used_at
]);
// 建立核銷日誌 (閉環最後一哩路)
@ -259,11 +263,13 @@ class TransactionService
'machine_id' => $order->machine_id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'consume',
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}",
'action' => $isUsedUp ? 'used' : 'consume',
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
'raw_data' => [
'slot' => $pickupCode->slot_no,
'code' => $pickupCode->code
'code' => $pickupCode->code,
'count' => $newCount,
'limit' => $pickupCode->usage_limit
]
]);
}

View File

@ -97,16 +97,14 @@ return [
'example' => true
],
'data' => [
'type' => 'array',
'description' => '配置物件陣列。包含t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
'type' => 'object',
'description' => '配置物件。包含t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
'example' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
],
],
@ -115,15 +113,13 @@ return [
'success' => true,
'code' => 200,
'data' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v42' => '9001',
't050v43' => 'hash_key',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v42' => '9001',
't050v43' => 'hash_key',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
],
'notes' => '此 API 為機台初始化引導用,目前不強制驗證 User Token。'

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->integer('usage_limit')->default(1)->after('code')->comment('使用次數上限');
$table->integer('usage_count')->default(0)->after('usage_limit')->comment('已使用次數');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->dropColumn(['usage_limit', 'usage_count']);
});
}
};

View File

@ -175,6 +175,7 @@
"Basic Information": "Basic Information",
"Basic Settings": "Basic Settings",
"Basic Specifications": "Basic Specifications",
"Based on Hours": "Based on Hours",
"Batch": "Batch",
"Batch No": "Batch No",
"Batch Number": "Batch Number",
@ -337,6 +338,8 @@
"Current Stocks": "Current Stocks",
"Current Type": "Current Type",
"Current:": "Cur:",
"Custom Date": "Custom Date",
"Custom Date Set": "Custom Date Set",
"Customer Details": "Customer Details",
"Customer Info": "Customer Info",
"Customer List": "Customer List",
@ -1544,6 +1547,9 @@
"View Slots": "View Slots",
"View all warehouses": "View all warehouses",
"View slot-level inventory for each machine": "View slot-level inventory for each machine",
"Usage Limit": "Usage Limit",
"Usage Logs": "Usage Logs",
"Usage Progress": "Usage Progress",
"Visit Gift": "Visit Gift",
"Visual overview of all machine locations": "Visual overview of all machine locations",
"Void Invoice": "Void Invoice",

View File

@ -183,6 +183,7 @@
"Batch No": "批號",
"Batch Number": "批號",
"Before Qty": "異動前數量",
"Based on Hours": "依據時數計算",
"Belongs To": "公司名稱",
"Belongs To Company": "公司名稱",
"Branch": "分倉",
@ -343,6 +344,8 @@
"Current Stocks": "目前庫存",
"Current Type": "當前類型",
"Current:": "現:",
"Custom Date": "自定義日期",
"Custom Date Set": "已設定自定義日期",
"Customer Details": "客戶詳情",
"Customer Info": "客戶資訊",
"Customer List": "客戶列表",
@ -666,6 +669,7 @@
"Hopper error (0424)": "料斗箱異常 (0424)",
"Hopper heating timeout": "料斗箱加熱逾時",
"Hopper overheated": "料斗箱過熱",
"Hours": "小時",
"Hrs": "小時",
"IC Card UID": "IC 卡號",
"ITEM": "ITEM",
@ -1632,7 +1636,9 @@
"Upload New Images": "上傳新照片",
"Upload Video": "上傳影片",
"Uploading new images will replace all existing images.": "上傳新照片將會取代所有現有照片。",
"Usage Limit": "使用次數上限",
"Usage Logs": "使用紀錄",
"Usage Progress": "使用進度",
"Verified": "驗證成功",
"Usage Completed": "使用完成",
"Used": "已使用",

View File

@ -90,7 +90,115 @@ transition-all duration-300",
</div> {{-- End relative min-h --}}
</div> {{-- End mt-6 --}}
{{-- Create Modal --}}
{{-- 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"
@ -146,6 +254,41 @@ transition-all duration-300",
</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 --}}
@ -168,43 +311,73 @@ transition-all duration-300",
maxlength="12" required>
</div>
{{-- Validity Period Section (Compact) --}}
{{-- Validity Period Section (Flexible) --}}
<div class="space-y-4">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period') }}</label>
<div
class="bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] p-6 border border-slate-100 dark:border-slate-800/50 shadow-sm">
<div class="flex items-center gap-6 mb-6">
<div class="flex-1 relative">
<input type="range" name="expires_hours" x-model="expiresHours" min="1"
:max="maxHours"
class="w-full h-1.5 bg-slate-200 dark:bg-slate-700/50 rounded-lg appearance-none cursor-pointer accent-cyan-500 transition-all hover:accent-cyan-400">
<div 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"
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="shrink-0 w-20 py-3 bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm text-center">
<span class="text-xl font-black text-slate-800 dark:text-white leading-none"
x-text="expiresHours"></span>
<span
class="block text-[9px] font-black text-slate-400 uppercase mt-0.5 tracking-widest">{{
__('Hrs') }}</span>
<div 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"
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">{{ __('Max 24 Hours') }}</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" />
<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>
<span class="text-xl font-mono font-black text-slate-700 dark:text-slate-200 tracking-wider" x-text="getDisplayExpiry()"></span>
</div>
</div>
</div>
@ -321,7 +494,10 @@ transition-all duration-300",
selectedMachine: '',
selectedSlot: '',
expiresHours: 24,
maxHours: 24,
maxHours: 168,
expiryMode: 'hours',
customExpiry: '',
usageLimit: 1,
showQrModal: false,
activeQrCode: '',
activeTicketUrl: '',
@ -333,6 +509,12 @@ transition-all duration-300",
slotSelectConfig: config,
customCode: '',
// Edit Pickup Code State
showEditModal: false,
editPickupCodeId: '',
editUsageLimit: 1,
editExpiresAt: '',
async fetchTabData(tab, url = null) {
@ -397,6 +579,10 @@ transition-all duration-300",
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') + ' ' +
@ -404,6 +590,13 @@ transition-all duration-300",
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 = [];
@ -426,19 +619,19 @@ transition-all duration-300",
window.showToast('{{ __("Please select a machine") }}', 'error');
return;
}
if (!this.selectedSlot || this.selectedSlot.toString().trim() === '') {
if (!this.selectedSlot) {
window.showToast('{{ __("Please select a slot") }}', 'error');
return;
}
if (!this.customCode || this.customCode.toString().trim() === '') {
window.showToast('{{ __("Please enter or generate a pickup code") }}', 'error');
return;
}
this.$el.submit();
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(() => {
@ -571,10 +764,23 @@ transition-all duration-300",
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();

View File

@ -139,7 +139,12 @@
$code->status;
@endphp
<div class="flex flex-col items-start gap-1">
<x-status-badge :status="$displayStatus" />
<div class="flex items-center gap-2 mb-1">
<x-status-badge :status="$displayStatus" />
<span class="text-[10px] font-black {{ ($code->usage_limit - $code->usage_count) <= 0 ? 'text-slate-400' : 'text-cyan-600' }} bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded-full border border-slate-200 dark:border-slate-700">
{{ $code->usage_limit - $code->usage_count }} / {{ $code->usage_limit }}
</span>
</div>
@if($code->status === 'used' && $code->order_id)
<a href="{{ route('admin.sales.index', ['tab' => 'orders', 'search' => $code->order?->order_no]) }}"
class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 hover:underline flex items-center gap-0.5">
@ -151,6 +156,17 @@
</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())
<button type="button"
@click="openEditModal('{{ $code->id }}', '{{ $code->usage_limit }}', '{{ $code->expires_at->format('Y-m-d H:i') }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all"
title="{{ __('Edit 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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</button>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
{{-- View QR Code Button --}}
<button
@ -234,7 +250,12 @@
$code->status;
@endphp
<div class="flex flex-col items-end gap-1">
<x-status-badge :status="$displayStatus" size="sm" />
<div class="flex items-center gap-2">
<span class="text-[10px] font-black {{ ($code->usage_limit - $code->usage_count) <= 0 ? 'text-slate-400' : 'text-cyan-600' }} bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded-full border border-slate-200 dark:border-slate-700">
{{ $code->usage_limit - $code->usage_count }} / {{ $code->usage_limit }}
</span>
<x-status-badge :status="$displayStatus" size="sm" />
</div>
@if($code->status === 'used' && $code->order_id)
<a href="{{ route('admin.sales.index', ['tab' => 'orders', 'search' => $code->order?->order_no]) }}"
class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 hover:underline flex items-center gap-0.5">
@ -279,6 +300,17 @@
{{-- Action Buttons --}}
<div class="flex items-center gap-3">
@if($code->status === 'active' && $code->expires_at->isFuture())
<button type="button"
@click="openEditModal('{{ $code->id }}', '{{ $code->usage_limit }}', '{{ $code->expires_at->format('Y-m-d H:i') }}')"
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-cyan-500 hover:bg-cyan-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="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>
{{ __('Edit') }}
</button>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
{{-- QR Button (Mobile) --}}
<button