[FEAT] 優化取貨碼管理介面與功能
1. 新增取貨碼使用次數上限功能(限制 1-20 次)。 2. 優化取貨碼管理介面,採用極簡奢華風計數器並調整寬度與間距。 3. 整合 Flatpickr 24小時制日期時間選擇器,並實作模式切換時的時間自動帶入邏輯。 4. 取貨碼清單新增「剩餘使用次數 / 總次數」顯示。 5. 完善相關中文化翻譯(如「小時」、「自定義日期」等)。 6. 新增資料庫欄位 migration 並更新相關模型與 Service 邏輯。 7. 更新 API 技術規格文檔以符合最新取貨碼邏輯。
This commit is contained in:
parent
d740fc5ab8
commit
fa6406b9ad
@ -162,7 +162,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
|
||||
|
||||
- **Response Body (Success 200):**
|
||||
- **Response Body (Success 200 - Object):**
|
||||
|
||||
| 欄位 (Key) | 說明 | 備註 |
|
||||
| :--- | :--- | :--- |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -498,7 +498,7 @@ class MachineController extends Controller
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [$data] // App 預期的是包含單一物件的陣列
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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。'
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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",
|
||||
|
||||
@ -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": "已使用",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user