[FEAT] 取貨碼功能優化與 QR Code 生成標準化

1. 修復 PickupCode 與 PassCode 模型中 User 命名空間錯誤。
2. 建立統一的 x-qr-code 組件,取代外部 API 並收斂至系統後端路由。
3. 優化取貨碼管理介面:將機台與商品貨道資訊拆分欄位,並動態顯示關聯商品名稱。
4. 補全 status-badge 組件中已使用、已過期與已取消的狀態樣式。
5. 修復取貨碼產生過程中的 Carbon 型別錯誤與 company_id 缺失問題。
This commit is contained in:
sky121113 2026-04-28 17:25:59 +08:00
parent 0332eff9bc
commit 78321ad693
12 changed files with 594 additions and 190 deletions

View File

@ -30,7 +30,7 @@ class SalesController extends Controller
// 取貨碼設定 // 取貨碼設定
public function pickupCodes(Request $request) public function pickupCodes(Request $request)
{ {
$query = PickupCode::with(['machine', 'creator'])->latest(); $query = PickupCode::with(['machine.slots.product', 'creator'])->latest();
if ($request->search) { if ($request->search) {
$query->where(function($q) use ($request) { $query->where(function($q) use ($request) {
@ -70,7 +70,8 @@ class SalesController extends Controller
'expires_hours' => 'nullable|integer|min:1|max:720', // 最長一個月 'expires_hours' => 'nullable|integer|min:1|max:720', // 最長一個月
]); ]);
$expiresAt = now()->addHours($request->expires_hours ?? 24); $machine = Machine::findOrFail($validated['machine_id']);
$expiresAt = now()->addHours((int) ($request->expires_hours ?? 24));
$pickupCode = PickupCode::create([ $pickupCode = PickupCode::create([
'machine_id' => $validated['machine_id'], 'machine_id' => $validated['machine_id'],
@ -78,6 +79,7 @@ class SalesController extends Controller
'code' => PickupCode::generateUniqueCode($validated['machine_id']), 'code' => PickupCode::generateUniqueCode($validated['machine_id']),
'expires_at' => $expiresAt, 'expires_at' => $expiresAt,
'status' => 'active', 'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(), 'created_by' => Auth::id(),
]); ]);
@ -166,7 +168,8 @@ class SalesController extends Controller
'custom_code' => 'nullable|string|min:4|max:12', 'custom_code' => 'nullable|string|min:4|max:12',
]); ]);
$expiresAt = $request->expires_days ? now()->addDays($request->expires_days) : null; $machine = Machine::findOrFail($validated['machine_id']);
$expiresAt = $request->expires_days ? now()->addDays((int) $request->expires_days) : null;
$passCode = PassCode::create([ $passCode = PassCode::create([
'machine_id' => $validated['machine_id'], 'machine_id' => $validated['machine_id'],
@ -174,6 +177,7 @@ class SalesController extends Controller
'code' => $validated['custom_code'] ?? PassCode::generateUniqueCode($validated['machine_id']), 'code' => $validated['custom_code'] ?? PassCode::generateUniqueCode($validated['machine_id']),
'expires_at' => $expiresAt, 'expires_at' => $expiresAt,
'status' => 'active', 'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(), 'created_by' => Auth::id(),
]); ]);

View File

@ -3,7 +3,7 @@
namespace App\Models\Transaction; namespace App\Models\Transaction;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Models\User; use App\Models\System\User;
use App\Traits\TenantScoped; use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;

View File

@ -3,7 +3,7 @@
namespace App\Models\Transaction; namespace App\Models\Transaction;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Models\User; use App\Models\System\User;
use App\Traits\TenantScoped; use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;

View File

@ -0,0 +1,36 @@
<?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->foreignId('company_id')->nullable()->change();
});
Schema::table('pass_codes', function (Blueprint $table) {
$table->foreignId('company_id')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->foreignId('company_id')->nullable(false)->change();
});
Schema::table('pass_codes', function (Blueprint $table) {
$table->foreignId('company_id')->nullable(false)->change();
});
}
};

View File

@ -1435,5 +1435,40 @@
"Manage your company's sub-accounts and role permissions": "Manage your company's sub-accounts and role permissions", "Manage your company's sub-accounts and role permissions": "Manage your company's sub-accounts and role permissions",
"Tenant": "Tenant", "Tenant": "Tenant",
"Permission Tags": "Permission Tags", "Permission Tags": "Permission Tags",
"All Permissions": "All Permissions" "All Permissions": "All Permissions",
"Pickup Codes": "Pickup Codes",
"Generate and manage one-time pickup codes for customers": "Generate and manage one-time pickup codes for customers",
"Generate New Code": "Generate New Code",
"Search by code, machine name or serial...": "Search by code, machine name or serial...",
"Machine / Slot": "Machine / Slot",
"Are you sure you want to cancel this code?": "Are you sure you want to cancel this code?",
"Cancel Code": "Cancel Code",
"No pickup codes found": "No pickup codes found",
"Generate Pickup Code": "Generate Pickup Code",
"Select Slot": "Select Slot",
"Validity Period (Hours)": "Validity Period (Hours)",
"Hrs": "Hrs",
"Max 720 hours (30 days)": "Max 720 hours (30 days)",
"No Actions": "No Actions",
"Pass Codes": "Pass Codes",
"Manage multi-use authorization codes for testing and maintenance": "Manage multi-use authorization codes for testing and maintenance",
"Create Pass Code": "Create Pass Code",
"Search by code, name or machine...": "Search by code, name or machine...",
"Name / Machine": "Name / Machine",
"Are you sure you want to delete this pass code?": "Are you sure you want to delete this pass code?",
"Delete Code": "Delete Code",
"No pass codes found": "No pass codes found",
"Target Machine": "Target Machine",
"Description / Name": "Description / Name",
"e.g. Test Code for Maintenance": "e.g. Test Code for Maintenance",
"Pass Code (8 Digits)": "Pass Code (8 Digits)",
"Regenerate": "Regenerate",
"Validity Period (Days)": "Validity Period (Days)",
"Days": "Days",
"Leave empty for permanent code": "Leave empty for permanent code",
"Permanent": "Permanent",
"All Status": "All Status",
"Add Pickup Code": "Add Pickup Code",
"Add Pass Code": "Add Pass Code",
"Expected Expiry Date & Time": "Expected Expiry Date & Time"
} }

View File

@ -260,5 +260,44 @@
"Stock Level > 50%": "在庫 50% 以上", "Stock Level > 50%": "在庫 50% 以上",
"Manage your company's sub-accounts and role permissions": "会社のサブアカウントとロール権限の管理", "Manage your company's sub-accounts and role permissions": "会社のサブアカウントとロール権限の管理",
"Role Management": "ロール管理", "Role Management": "ロール管理",
"Account List": "アカウント一覧" "Account List": "アカウント一覧",
"Pickup Codes": "受取コード",
"Generate and manage one-time pickup codes for customers": "お客様向けの1回限り受取コードを生成・管理します",
"Generate New Code": "新しいコードを生成",
"Search by code, machine name or serial...": "コード、機台名、またはシリアルで検索...",
"Code": "コード",
"Machine / Slot": "機台 / 貨道",
"Are you sure you want to cancel this code?": "このコードをキャンセルしてもよろしいですか?",
"Cancel Code": "コードをキャンセル",
"No pickup codes found": "受取コードが見つかりません",
"Generate Pickup Code": "受取コードを生成",
"Select Slot": "貨道を選択",
"Validity Period (Hours)": "有効期限 (時間)",
"Hrs": "時間",
"Max 720 hours (30 days)": "最大 720 時間 (30 日間)",
"Generate": "生成",
"No Actions": "操作なし",
"Pass Codes": "通行コード",
"Manage multi-use authorization codes for testing and maintenance": "テストおよびメンテナンス用のマルチユース認証コードを管理します",
"Create Pass Code": "通行コードを作成",
"Search by code, name or machine...": "コード、名称、または機台で検索...",
"Pass Code": "通行コード",
"Name / Machine": "名称 / 機台",
"Are you sure you want to delete this pass code?": "この通行コードを削除してもよろしいですか?",
"Delete Code": "コードを削除",
"No pass codes found": "通行コードが見つかりません",
"Target Machine": "対象機台",
"Description / Name": "説明 / 名称",
"e.g. Test Code for Maintenance": "例:メンテナンス用テストコード",
"Pass Code (8 Digits)": "通行コード (8桁)",
"Regenerate": "再生成",
"Validity Period (Days)": "有効期限 (日)",
"Days": "日",
"Leave empty for permanent code": "無期限にする場合は空欄にしてください",
"Permanent": "無期限",
"Used": "使用済み",
"Expired": "期限切れ",
"Cancelled": "キャンセル済み",
"Active": "有効",
"All Status": "すべてのステータス"
} }

View File

@ -20,9 +20,8 @@
"Account:": "帳號:", "Account:": "帳號:",
"accounts": "帳號管理", "accounts": "帳號管理",
"Accounts / Machines": "帳號 / 機台", "Accounts / Machines": "帳號 / 機台",
"Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "使用中", "Active": "有效",
"Active Slots": "使用中貨道", "Active Slots": "使用中貨道",
"Active Status": "啟用狀態", "Active Status": "啟用狀態",
"Ad Settings": "廣告設置", "Ad Settings": "廣告設置",
@ -78,6 +77,7 @@
"All Stable": "狀態穩定", "All Stable": "狀態穩定",
"All Status": "所有狀態", "All Status": "所有狀態",
"All Statuses": "所有狀態", "All Statuses": "所有狀態",
"All Statuses (Codes)": "所有狀態",
"All Times System Timezone": "所有時間為系統時區", "All Times System Timezone": "所有時間為系統時區",
"All Types": "所有類型", "All Types": "所有類型",
"All Warehouses": "所有倉庫", "All Warehouses": "所有倉庫",
@ -107,7 +107,9 @@
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "確定要變更此商品的狀態嗎?停用的商品將不會在機台上顯示。", "Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "確定要變更此商品的狀態嗎?停用的商品將不會在機台上顯示。",
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "您確定要變更狀態嗎?停用之後,該帳號將會立即被登出且無法再登入系統。", "Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "您確定要變更狀態嗎?停用之後,該帳號將會立即被登出且無法再登入系統。",
"Are you sure you want to change the status? This may affect associated accounts.": "您確定要變更狀態嗎?這可能會影響相關帳號的權限效力。", "Are you sure you want to change the status? This may affect associated accounts.": "您確定要變更狀態嗎?這可能會影響相關帳號的權限效力。",
"Are you sure you want to cancel this code?": "確定要取消此代碼嗎?",
"Are you sure you want to confirm this stock-in order? This action will update your inventory levels.": "您確定要確認此進貨單嗎?此操作將會更新您的庫存數量。", "Are you sure you want to confirm this stock-in order? This action will update your inventory levels.": "您確定要確認此進貨單嗎?此操作將會更新您的庫存數量。",
"Are you sure you want to delete this pass code?": "確定要刪除此通行碼嗎?",
"Are you sure you want to confirm this transfer? This will deduct stock from source and add to target.": "您確定要確認此調撥單嗎?此操作將會扣除來源庫存並增加目標庫存。", "Are you sure you want to confirm this transfer? This will deduct stock from source and add to target.": "您確定要確認此調撥單嗎?此操作將會扣除來源庫存並增加目標庫存。",
"Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "確定要停用此帳號嗎?停用後將無法登入系統。", "Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.": "確定要停用此帳號嗎?停用後將無法登入系統。",
"Are you sure you want to delete this account?": "您確定要刪除此帳號嗎?", "Are you sure you want to delete this account?": "您確定要刪除此帳號嗎?",
@ -175,6 +177,7 @@
"by": "由", "by": "由",
"Calculate Replenishment": "計算補貨量", "Calculate Replenishment": "計算補貨量",
"Cancel": "取消", "Cancel": "取消",
"Cancel Code": "取消代碼",
"Cancel Order": "取消訂單", "Cancel Order": "取消訂單",
"Cancel Purchase": "取消購買", "Cancel Purchase": "取消購買",
"Cancelled": "已取消", "Cancelled": "已取消",
@ -222,6 +225,7 @@
"Click to Open Dashboard": "點擊開啟儀表板", "Click to Open Dashboard": "點擊開啟儀表板",
"Click to upload": "點擊上傳", "Click to upload": "點擊上傳",
"Close Panel": "關閉面板", "Close Panel": "關閉面板",
"Code": "代碼",
"Coin/Banknote Machine": "硬幣機/紙鈔機", "Coin/Banknote Machine": "硬幣機/紙鈔機",
"Coin/Bill Acceptor": "硬幣機/紙鈔機", "Coin/Bill Acceptor": "硬幣機/紙鈔機",
"Command Center": "指令中心", "Command Center": "指令中心",
@ -341,6 +345,7 @@
"Date": "日期", "Date": "日期",
"Date Range": "日期區間", "Date Range": "日期區間",
"Day Before": "前日", "Day Before": "前日",
"Days": "天",
"Default Donate": "預設捐贈", "Default Donate": "預設捐贈",
"Default Not Donate": "預設不捐贈", "Default Not Donate": "預設不捐贈",
"Define and manage security roles and permissions.": "定義並管理系統安全角色與權限。", "Define and manage security roles and permissions.": "定義並管理系統安全角色與權限。",
@ -349,6 +354,7 @@
"Delete Account": "刪除帳號", "Delete Account": "刪除帳號",
"Delete Advertisement": "刪除廣告", "Delete Advertisement": "刪除廣告",
"Delete Advertisement Confirmation": "刪除廣告確認", "Delete Advertisement Confirmation": "刪除廣告確認",
"Delete Code": "刪除代碼",
"Delete Customer": "刪除客戶", "Delete Customer": "刪除客戶",
"Delete Permanently": "確認永久刪除資料", "Delete Permanently": "確認永久刪除資料",
"Delete Product": "刪除商品", "Delete Product": "刪除商品",
@ -361,6 +367,7 @@
"Delivery door opened": "送貨門開啟", "Delivery door opened": "送貨門開啟",
"Deposit Bonus": "儲值回饋", "Deposit Bonus": "儲值回饋",
"Describe the repair or maintenance status...": "請描述維修或保養狀況...", "Describe the repair or maintenance status...": "請描述維修或保養狀況...",
"Description / Name": "描述 / 名稱",
"Deselect All": "取消全選", "Deselect All": "取消全選",
"Detail": "詳細", "Detail": "詳細",
"Details": "明細", "Details": "明細",
@ -397,6 +404,7 @@
"e.g. johndoe": "例如xiaoming", "e.g. johndoe": "例如xiaoming",
"e.g. Main Warehouse": "如總倉A", "e.g. Main Warehouse": "如總倉A",
"e.g. Taiwan Star": "例如:台灣之星", "e.g. Taiwan Star": "例如:台灣之星",
"e.g. Test Code for Maintenance": "例如:維修測試代碼",
"e.g. TWSTAR": "例如TWSTAR", "e.g. TWSTAR": "例如TWSTAR",
"e.g., Beverage": "例如:飲料", "e.g., Beverage": "例如:飲料",
"e.g., Company Standard Pay": "例如:公司標準支付", "e.g., Company Standard Pay": "例如:公司標準支付",
@ -521,6 +529,10 @@
"Functional Settings": "功能設定", "Functional Settings": "功能設定",
"Games": "互動遊戲", "Games": "互動遊戲",
"General permissions not linked to a specific menu.": "未連結到特定選單的一般權限。", "General permissions not linked to a specific menu.": "未連結到特定選單的一般權限。",
"Generate": "產生",
"Generate and manage one-time pickup codes for customers": "產生並管理客戶的一次性取貨碼",
"Generate New Code": "產生新代碼",
"Generate Pickup Code": "產生取貨碼",
"Gift Definitions": "禮品設定", "Gift Definitions": "禮品設定",
"Global roles accessible by all administrators.": "適用於所有管理者的全域角色。", "Global roles accessible by all administrators.": "適用於所有管理者的全域角色。",
"Go Back": "返回", "Go Back": "返回",
@ -546,6 +558,7 @@
"Hopper heating timeout": "料斗箱加熱逾時", "Hopper heating timeout": "料斗箱加熱逾時",
"Hopper overheated": "料斗箱過熱", "Hopper overheated": "料斗箱過熱",
"hours ago": "小時前", "hours ago": "小時前",
"Hrs": "小時",
"Identity & Codes": "識別與代碼", "Identity & Codes": "識別與代碼",
"Image": "圖片", "Image": "圖片",
"image": "圖片", "image": "圖片",
@ -588,6 +601,7 @@
"Last Updated": "最後更新日期", "Last Updated": "最後更新日期",
"Latitude": "緯度", "Latitude": "緯度",
"Lease": "租賃", "Lease": "租賃",
"Leave empty for permanent code": "留空表示永久有效",
"Level": "層級", "Level": "層級",
"LEVEL TYPE": "層級類型", "LEVEL TYPE": "層級類型",
"line": "Line 管理", "line": "Line 管理",
@ -694,11 +708,9 @@
"Manage": "管理", "Manage": "管理",
"Manage Account Access": "管理帳號存取", "Manage Account Access": "管理帳號存取",
"Manage ad materials and machine playback settings": "管理廣告素材與機台播放設定", "Manage ad materials and machine playback settings": "管理廣告素材與機台播放設定",
"Manage multi-use authorization codes for testing and maintenance": "管理用於測試與維修的多用途授權碼",
"Manage administrative and tenant accounts": "管理系統管理者與客戶帳號", "Manage administrative and tenant accounts": "管理系統管理者與客戶帳號",
"Manage all tenant accounts and validity": "管理所有客戶帳號及其效期", "Manage all tenant accounts and validity": "管理所有客戶帳號及其效期",
"Manage Expiry": "進入效期管理",
"Manage inventory and monitor expiry dates across all machines": "管理各機台庫存架位與效期監控",
"Manage machine access permissions": "管理機台存取權限",
"Manage stock levels, stock-in orders, and movement history": "追蹤庫存水位、進貨單與異動紀錄", "Manage stock levels, stock-in orders, and movement history": "追蹤庫存水位、進貨單與異動紀錄",
"Manage stock transfers between warehouses and machine returns": "建立倉庫間調撥或機台退庫單", "Manage stock transfers between warehouses and machine returns": "建立倉庫間調撥或機台退庫單",
"Manage warehouse stock levels, stock-in orders, adjustments, and movement history": "管理倉庫庫存水位、入庫單、盤點調整與異動紀錄", "Manage warehouse stock levels, stock-in orders, adjustments, and movement history": "管理倉庫庫存水位、入庫單、盤點調整與異動紀錄",
@ -721,6 +733,7 @@
"Max Capacity": "最大容量", "Max Capacity": "最大容量",
"Max Capacity:": "最大容量:", "Max Capacity:": "最大容量:",
"Max Stock": "庫存上限", "Max Stock": "庫存上限",
"Max 720 hours (30 days)": "最大 720 小時 (30 天)",
"Member": "會員價", "Member": "會員價",
"Member & External": "會員與外部系統", "Member & External": "會員與外部系統",
"Member List": "會員列表", "Member List": "會員列表",
@ -987,6 +1000,7 @@
"PI_MERCHANT_ID": "Pi 拍錢包 商店代號", "PI_MERCHANT_ID": "Pi 拍錢包 商店代號",
"Picked up": "領取", "Picked up": "領取",
"Picked up Time": "領取時間", "Picked up Time": "領取時間",
"Machine / Slot": "機台 / 貨道",
"Pickup Code": "取貨碼", "Pickup Code": "取貨碼",
"Pickup Codes": "取貨碼", "Pickup Codes": "取貨碼",
"Pickup door closed": "取貨門已關閉", "Pickup door closed": "取貨門已關閉",
@ -1479,6 +1493,9 @@
"vending": "販賣頁", "vending": "販賣頁",
"Vending Page": "販賣頁", "Vending Page": "販賣頁",
"Venue Management": "場地管理", "Venue Management": "場地管理",
"Used": "已使用",
"Validity Period (Days)": "有效期限 (天)",
"Validity Period (Hours)": "有效期限 (小時)",
"Video": "影片", "Video": "影片",
"video": "影片", "video": "影片",
"View all warehouses": "查看所有倉庫", "View all warehouses": "查看所有倉庫",
@ -1546,5 +1563,28 @@
"Manage your company's sub-accounts and role permissions": "管理您公司的子帳號與角色權限", "Manage your company's sub-accounts and role permissions": "管理您公司的子帳號與角色權限",
"Tenant": "客戶", "Tenant": "客戶",
"Permission Tags": "權限標籤", "Permission Tags": "權限標籤",
"All Permissions": "全部權限" "All Permissions": "全部權限",
"All Status": "所有狀態",
"Select Slot": "選擇貨道",
"Validity Period (Hours)": "有效期限 (小時)",
"Hrs": "小時",
"No Actions": "無操作",
"Target Machine": "目標機台",
"Description / Name": "描述 / 名稱",
"Pass Code (8 Digits)": "通行碼 (8 位數)",
"Regenerate": "重新產生",
"Validity Period (Days)": "有效期限 (天)",
"Leave empty for permanent code": "留空表示永久有效",
"Permanent": "永久有效",
"Add Pickup Code": "新增取貨碼",
"Add Pass Code": "新增通行碼",
"QR": "QR Code",
"Scan QR Code": "掃描 QR Code",
"View QR Code": "查看 QR Code",
"Expected Expiry": "預計過期時間",
"Max :max hours": "最多 :max 小時",
"Expected Expiry Date & Time": "預計過期日期與時間",
"Validity Period": "有效期限",
"Max 24 Hours": "最多 24 小時",
"Please select a machine": "請選擇機台"
} }

View File

@ -1114,9 +1114,7 @@
<div class="p-10 flex flex-col items-center gap-6"> <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"> <div class="p-4 bg-white rounded-3xl shadow-xl border border-slate-100">
<img :src="'{{ route('admin.basic-settings.qr-code') }}?data=' + encodeURIComponent(maintenanceQrUrl)" <x-qr-code data="maintenanceQrUrl" size="200" class="w-48 h-48" />
class="w-48 h-48"
alt="{{ __('Maintenance QR Code') }}">
</div> </div>
<div class="text-center space-y-2"> <div class="text-center space-y-2">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 leading-relaxed px-4"> <p class="text-xs font-bold text-slate-500 dark:text-slate-400 leading-relaxed px-4">

View File

@ -38,11 +38,11 @@
:title="__('Pass Codes')" :title="__('Pass Codes')"
:subtitle="__('Manage multi-use authorization codes for testing and maintenance')" :subtitle="__('Manage multi-use authorization codes for testing and maintenance')"
> >
<button @click="showCreateModal = true; generateRandomCode()" class="luxury-btn-primary group flex items-center gap-2"> <button @click="showCreateModal = true; generateRandomCode()" class="btn-luxury-primary">
<svg class="w-5 h-5 group-hover:rotate-90 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" stroke-width="2.5" d="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
<span class="text-sm sm:text-base">{{ __('Create Pass Code') }}</span> <span>{{ __('Add Pass Code') }}</span>
</button> </button>
</x-page-header> </x-page-header>
@ -52,11 +52,30 @@
<div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }"> <div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }">
<x-search-bar <form action="{{ route('admin.sales.pass-codes') }}" method="GET"
:action="route('admin.sales.pass-codes')" class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
:value="request('search')" @submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())">
:placeholder="__('Search by code, name or machine...')"
/> <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="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=''; $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) --}} {{-- Table (Desktop) --}}
<div class="hidden xl:block overflow-x-auto"> <div class="hidden xl:block overflow-x-auto">
@ -205,67 +224,56 @@
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0"> <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 class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div class="inline-block overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-[2.5rem] shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-200/50 dark:border-slate-700/50 animate-luxury-in"> <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"
<form action="{{ route('admin.sales.pass-codes.store') }}" method="POST"> 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">
@csrf @csrf
<div class="p-10"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-center justify-between mb-8"> <div class="space-y-2">
<h3 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Create Pass Code') }}</h3> <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Target Machine') }}</label>
<button type="button" @click="showCreateModal = false" class="w-10 h-10 flex items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> <select name="machine_id" x-model="selectedMachine" class="luxury-input w-full" required>
<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> <option value="">{{ __('Please select a machine') }}</option>
</button> @foreach($machines as $machine)
<option value="{{ $machine->id }}">{{ $machine->name }} ({{ $machine->serial_no }})</option>
@endforeach
</select>
</div> </div>
<div class="space-y-2">
<div class="space-y-8"> <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Description / Name') }}</label>
{{-- Machine Selection --}} <input type="text" name="name" x-model="name" class="luxury-input w-full" placeholder="{{ __('e.g. Test Code for Maintenance') }}" required>
<div>
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Target Machine') }}</label>
<select name="machine_id" x-model="selectedMachine" class="luxury-input w-full py-3" required>
<option value="">{{ __('Please select a machine') }}</option>
@foreach($machines as $machine)
<option value="{{ $machine->id }}">{{ $machine->name }} ({{ $machine->serial_no }})</option>
@endforeach
</select>
</div>
{{-- Name --}}
<div>
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Description / Name') }}</label>
<input type="text" name="name" x-model="name" class="luxury-input w-full py-3" placeholder="{{ __('e.g. Test Code for Maintenance') }}" required>
</div>
{{-- Code --}}
<div>
<div class="flex items-center justify-between mb-3">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]">{{ __('Pass Code (8 Digits)') }}</label>
<button type="button" @click="generateRandomCode()" class="text-xs font-bold text-cyan-500 hover:text-cyan-600 uppercase tracking-widest flex items-center gap-1">
<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" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
{{ __('Regenerate') }}
</button>
</div>
<input type="text" name="custom_code" x-model="customCode" class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5" maxlength="12" required>
</div>
{{-- Validity --}}
<div>
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Validity Period (Days)') }}</label>
<div class="relative">
<input type="number" name="expires_days" x-model="expiresDays" class="luxury-input w-full py-3 pr-16" min="1" placeholder="{{ __('Permanent') }}">
<div class="absolute right-4 top-3 text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Days') }}</div>
</div>
<p class="mt-3 text-[10px] text-slate-400 font-bold uppercase tracking-widest flex items-center gap-1.5">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ __('Leave empty for permanent code') }}
</p>
</div>
</div> </div>
</div> </div>
<div class="px-10 py-8 bg-slate-50/50 dark:bg-slate-800/50 flex justify-end gap-4 border-t border-slate-100 dark:border-slate-800"> <div class="space-y-2">
<button type="button" @click="showCreateModal = false" class="luxury-btn-secondary px-6"> <div class="flex items-center justify-between mb-1">
{{ __('Cancel') }} <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Pass Code (8 Digits)') }}</label>
</button> <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">
<button type="submit" class="luxury-btn-primary px-10"> <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-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period (Days)') }}</label>
<div class="relative">
<input type="number" name="expires_days" x-model="expiresDays" class="luxury-input w-full pr-16" min="1" placeholder="{{ __('Permanent') }}">
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-xs font-bold text-slate-400 uppercase tracking-widest pointer-events-none">{{ __('Days') }}</div>
</div>
<p class="text-[10px] font-bold text-slate-400 mt-1 uppercase tracking-widest">{{ __('Leave empty for permanent code') }}</p>
</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') }} {{ __('Create') }}
</button> </button>
</div> </div>

View File

@ -1,62 +1,36 @@
@extends('layouts.admin') @extends('layouts.admin')
@php
$slotSelectConfig = [
"placeholder" => __("Select Slot"),
"hasSearch" => true,
"searchPlaceholder" => __("Search Slot..."),
"isHidePlaceholder" => false,
"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses" => "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses" => "hs-select-toggle luxury-select-toggle",
"toggleTemplate" => '<button type="button" aria-expanded="false"><span class="me-2" data-icon></span><span class="text-slate-800 dark:text-slate-200" data-title></span><div class="ms-auto"><svg class="size-4 text-slate-400 transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></div></button>',
"dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in",
"optionClasses" => "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate" => '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>'
];
@endphp
@section('content') @section('content')
<div class="space-y-4 pb-20" x-data="{ <div class="space-y-4 pb-20" x-data="pickupCodeManager(@js($slotSelectConfig))">
showCreateModal: false,
selectedMachine: '',
selectedSlot: '',
expiresHours: 24,
slots: [],
loadingSlots: false,
isLoadingTable: false,
async fetchSlots() {
if (!this.selectedMachine) {
this.slots = [];
return;
}
this.loadingSlots = true;
try {
const response = await fetch(`/admin/machines/${this.selectedMachine}/slots-ajax`);
const data = await response.json();
this.slots = data.slots || [];
} catch (error) {
console.error('Error fetching slots:', error);
} finally {
this.loadingSlots = false;
}
},
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
window.history.pushState({}, '', url);
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
}
} catch (e) {
console.error('AJAX Load Failed:', e);
} finally {
this.isLoadingTable = false;
}
}
}">
{{-- Page Header --}} {{-- Page Header --}}
<x-page-header <x-page-header
:title="__('Pickup Codes')" :title="__('Pickup Codes')"
:subtitle="__('Generate and manage one-time pickup codes for customers')" :subtitle="__('Generate and manage one-time pickup codes for customers')"
> >
<button @click="showCreateModal = true" class="luxury-btn-primary group flex items-center gap-2"> <button @click="showCreateModal = true" class="btn-luxury-primary">
<svg class="w-5 h-5 group-hover:rotate-90 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" stroke-width="2.5" d="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
<span class="text-sm sm:text-base">{{ __('Generate New Code') }}</span> <span>{{ __('Add Pickup Code') }}</span>
</button> </button>
</x-page-header> </x-page-header>
@ -66,13 +40,21 @@
<div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }"> <div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }">
<x-search-bar <form action="{{ route('admin.sales.pickup-codes') }}" method="GET"
:action="route('admin.sales.pickup-codes')" class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
:value="request('search')" @submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())">
:placeholder="__('Search by code, machine name or serial...')"
> <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, machine name or serial...') }}">
</div>
<div class="w-full md:w-48"> <div class="w-full md:w-48">
<select name="status" class="luxury-input w-full text-sm" @change="$el.closest('form').dispatchEvent(new Event('submit', { cancelable: true }))"> <select name="status" class="luxury-input w-full text-sm" @change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="">{{ __('All Status') }}</option> <option value="">{{ __('All Status') }}</option>
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>{{ __('Active') }}</option> <option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>{{ __('Active') }}</option>
<option value="used" {{ request('status') === 'used' ? 'selected' : '' }}>{{ __('Used') }}</option> <option value="used" {{ request('status') === 'used' ? 'selected' : '' }}>{{ __('Used') }}</option>
@ -80,7 +62,18 @@
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }}>{{ __('Cancelled') }}</option> <option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }}>{{ __('Cancelled') }}</option>
</select> </select>
</div> </div>
</x-search-bar>
<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=''; $el.closest('form').querySelector('select[name=status]').value=''; $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) --}} {{-- Table (Desktop) --}}
<div class="hidden xl:block overflow-x-auto"> <div class="hidden xl:block overflow-x-auto">
@ -91,7 +84,13 @@
{{ __('Code') }} {{ __('Code') }}
</th> </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"> <th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Machine / Slot') }} {{ __('Machine') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Product / Slot') }}
</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('QR') }}
</th> </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"> <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') }} {{ __('Expires At') }}
@ -114,8 +113,23 @@
</td> </td>
<td class="px-6 py-6"> <td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $code->machine->name }}</div> <div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $code->machine->name }}</div>
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">{{ $code->machine->serial_no }}</div>
</td>
<td class="px-6 py-6">
@php
$slot = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
@endphp
<div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{ $productName }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $code->slot_no }}</div> <div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $code->slot_no }}</div>
</td> </td>
<td class="px-6 py-6 text-center">
<button @click="activeQrCode = '{{ $code->code }}'; showQrModal = true" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-cyan-500 hover:text-white transition-all duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
<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" d="M12 4v1m0 11v1m4-11h.01M17 16h.01M9 16h.01M12 12h.01M12 12v1M12 12h-1M12 12h1M9 8h.01M9 8h.01M7 8h.01M7 8h.01M7 10h.01M7 12h.01M7 14h.01M7 16h.01M17 8h.01M17 10h.01M17 12h.01M17 14h.01M17 16h.01M9 10h.01M9 12h.01M9 14h.01M9 16h.01M11 8h.01M11 10h.01M11 12h.01M11 14h.01M11 16h.01M13 8h.01M13 10h.01M13 12h.01M13 14h.01M13 16h.01M15 8h.01M15 10h.01M15 12h.01M15 14h.01M15 16h.01" />
</svg>
</button>
</td>
<td class="px-6 py-6 whitespace-nowrap"> <td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">{{ $code->expires_at->format('Y-m-d H:i') }}</div> <div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">{{ $code->expires_at->format('Y-m-d H:i') }}</div>
<div class="text-xs font-bold text-slate-400 mt-0.5">{{ $code->expires_at->diffForHumans() }}</div> <div class="text-xs font-bold text-slate-400 mt-0.5">{{ $code->expires_at->diffForHumans() }}</div>
@ -143,7 +157,7 @@
</td> </td>
</tr> </tr>
@empty @empty
<x-empty-state mode="table" :colspan="5" :message="__('No pickup codes found')" /> <x-empty-state mode="table" :colspan="6" :message="__('No pickup codes found')" />
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
@ -180,7 +194,14 @@
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4"> <div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div> <div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Slot') }}</p> <p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Slot') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $code->slot_no }}</p> <p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $code->slot_no }}
@php
$slotMobile = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) : __('Empty');
@endphp
<span class="ml-1 text-cyan-600 dark:text-cyan-400">({{ $prodMobile }})</span>
</p>
</div> </div>
<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-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Expires At') }}</p>
@ -188,6 +209,16 @@
</div> </div>
</div> </div>
{{-- QR Button (Mobile) --}}
<div class="mb-6">
<button @click="activeQrCode = '{{ $code->code }}'; showQrModal = true" class="w-full flex items-center justify-center gap-2 py-3 rounded-2xl bg-cyan-50 dark:bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 font-black text-xs uppercase tracking-widest border border-cyan-100 dark:border-cyan-500/20 hover:bg-cyan-500 hover:text-white transition-all duration-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m0 11v1m4-11h.01M17 16h.01M9 16h.01M12 12h.01M12 12v1M12 12h-1M12 12h1M9 8h.01M9 8h.01M7 8h.01M7 8h.01M7 10h.01M7 12h.01M7 14h.01M7 16h.01M17 8h.01M17 10h.01M17 12h.01M17 14h.01M17 16h.01M9 10h.01M9 12h.01M9 14h.01M9 16h.01M11 8h.01M11 10h.01M11 12h.01M11 14h.01M11 16h.01M13 8h.01M13 10h.01M13 12h.01M13 14h.01M13 16h.01M15 8h.01M15 10h.01M15 12h.01M15 14h.01M15 16h.01" />
</svg>
{{ __('View QR Code') }}
</button>
</div>
{{-- Action Buttons --}} {{-- Action Buttons --}}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@if($code->status === 'active' && $code->expires_at->isFuture()) @if($code->status === 'active' && $code->expires_at->isFuture())
@ -235,65 +266,85 @@
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0"> <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 class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div class="inline-block overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-[2.5rem] shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-200/50 dark:border-slate-700/50 animate-luxury-in"> <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"
<form action="{{ route('admin.sales.pickup-codes.store') }}" method="POST"> class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full overflow-visible">
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Add Pickup Code') }}</h3>
<button @click="showCreateModal = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form action="{{ route('admin.sales.pickup-codes.store') }}" method="POST" class="space-y-6">
@csrf @csrf
<div class="p-10"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-center justify-between mb-8"> <div class="space-y-2">
<h3 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Generate Pickup Code') }}</h3> <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Target Machine') }}</label>
<button type="button" @click="showCreateModal = false" class="w-10 h-10 flex items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> <x-searchable-select name="machine_id" id="modal-pickup-machine"
<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> placeholder="{{ __('Please select a machine') }}"
</button> x-model="selectedMachine"
@change="selectedMachine = $event.target.value; fetchSlots()">
@foreach($machines as $machine)
<option value="{{ $machine->id }}" data-title="{{ $machine->name }} ({{ $machine->serial_no }})">{{ $machine->name }} ({{ $machine->serial_no }})</option>
@endforeach
</x-searchable-select>
</div> </div>
<div class="space-y-2">
<div class="space-y-8"> <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Select Slot') }}</label>
{{-- Machine Selection --}} <div class="relative">
<div> <div id="slot-select-wrapper" class="relative">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Select Machine') }}</label> {{-- Rebuilt by Alpine --}}
<select name="machine_id" x-model="selectedMachine" @change="fetchSlots()" class="luxury-input w-full py-3" required>
<option value="">{{ __('Please select a machine') }}</option>
@foreach($machines as $machine)
<option value="{{ $machine->id }}">{{ $machine->name }} ({{ $machine->serial_no }})</option>
@endforeach
</select>
</div>
{{-- Slot Selection --}}
<div>
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Select Slot') }}</label>
<div class="relative group">
<select name="slot_no" x-model="selectedSlot" class="luxury-input w-full py-3" :disabled="!selectedMachine || loadingSlots" required>
<option value="">{{ __('Select Slot') }}</option>
<template x-for="slot in slots" :key="slot.slot_no">
<option :value="slot.slot_no" x-text="`${slot.slot_no} - ${slot.product_name || 'Empty'}`"></option>
</template>
</select>
<div x-show="loadingSlots" class="absolute right-4 top-3.5">
<svg class="animate-spin h-5 w-5 text-cyan-500" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
</div>
</div> </div>
</div> <div x-show="loadingSlots" class="absolute right-12 top-1/2 -translate-y-1/2 z-10">
<x-luxury-spinner size="sm" />
{{-- Validity --}}
<div>
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Validity Period (Hours)') }}</label>
<div class="relative">
<input type="number" name="expires_hours" x-model="expiresHours" class="luxury-input w-full py-3 pr-16" min="1" max="720" required>
<div class="absolute right-4 top-3 text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Hrs') }}</div>
</div> </div>
<p class="mt-3 text-[10px] text-slate-400 font-bold uppercase tracking-widest flex items-center gap-1.5">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ __('Max 720 hours (30 days)') }}
</p>
</div> </div>
</div> </div>
</div> </div>
<div class="px-10 py-8 bg-slate-50/50 dark:bg-slate-800/50 flex justify-end gap-4 border-t border-slate-100 dark:border-slate-800"> {{-- Validity Period Section --}}
<button type="button" @click="showCreateModal = false" class="luxury-btn-secondary px-6"> <div class="max-w-2xl mx-auto w-full">
{{ __('Cancel') }} <div class="bg-slate-50 dark:bg-slate-800/40 rounded-[2.5rem] p-8 border border-slate-100 dark:border-slate-800/50 shadow-sm">
</button> <div class="flex items-center justify-between mb-8 px-2">
<button type="submit" class="luxury-btn-primary px-10" :disabled="!selectedSlot"> <div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-cyan-500/50"></div>
<label class="text-[13px] font-black text-slate-500 dark:text-slate-400 tracking-wider">{{ __('Validity Period') }}</label>
</div>
<span class="text-[11px] font-bold text-slate-400/70 tracking-wide">{{ __('Max 24 Hours') }}</span>
</div>
<div class="space-y-10">
{{-- Slider and Value --}}
<div class="flex items-center gap-8">
<div class="flex-1 relative">
<input type="range" name="expires_hours" x-model="expiresHours" min="1" :max="maxHours"
class="w-full h-1 bg-slate-200 dark:bg-slate-700/50 rounded-lg appearance-none cursor-pointer accent-cyan-500 transition-all hover:accent-cyan-400">
</div>
<div class="shrink-0 w-24 py-4 bg-white dark:bg-slate-900 rounded-[1.5rem] border border-slate-200 dark:border-slate-700 shadow-sm text-center ring-4 ring-slate-50 dark:ring-slate-800/50">
<span class="text-2xl font-black text-slate-800 dark:text-white leading-none" x-text="expiresHours"></span>
<span class="block text-[10px] font-black text-slate-400 uppercase mt-1 tracking-widest">{{ __('Hrs') }}</span>
</div>
</div>
{{-- Result Display --}}
<div class="relative">
<div class="flex flex-col items-center justify-center py-8 px-8 bg-white dark:bg-slate-900/50 rounded-[2rem] border border-slate-200/60 dark:border-slate-700/50 shadow-inner">
<span class="text-base font-bold text-slate-600 dark:text-slate-400 mb-5 tracking-wide">{{ __('Expected Expiry Date & Time') }}</span>
<div class="flex items-center gap-3 py-3 px-6 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border border-slate-100 dark:border-slate-700/50 shadow-sm">
<svg class="w-5 h-5 text-cyan-500/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xl sm:text-2xl font-black text-slate-700 dark:text-cyan-100 font-mono tracking-tight" x-text="calculateExpiry()"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-12" :disabled="!selectedSlot">
{{ __('Generate') }} {{ __('Generate') }}
</button> </button>
</div> </div>
@ -301,5 +352,184 @@
</div> </div>
</div> </div>
</div> </div>
{{-- QR 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 p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/80 backdrop-blur-md" @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 px-8 py-12 text-center align-bottom transition-all transform luxury-card rounded-[3rem] dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-sm sm:w-full overflow-hidden">
<div class="mb-8">
<div class="w-20 h-20 bg-cyan-500 rounded-3xl mx-auto flex items-center justify-center shadow-lg shadow-cyan-500/30 mb-6">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v1m0 11v1m4-11h.01M17 16h.01M9 16h.01M12 12h.01M12 12v1M12 12h-1M12 12h1M9 8h.01M9 8h.01M7 8h.01M7 8h.01M7 10h.01M7 12h.01M7 14h.01M7 16h.01M17 8h.01M17 10h.01M17 12h.01M17 14h.01M17 16h.01M9 10h.01M9 12h.01M9 14h.01M9 16h.01M11 8h.01M11 10h.01M11 12h.01M11 14h.01M11 16h.01M13 8h.01M13 10h.01M13 12h.01M13 14h.01M13 16h.01M15 8h.01M15 10h.01M15 12h.01M15 14h.01M15 16h.01" />
</svg>
</div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight mb-2">{{ __('Scan QR Code') }}</h3>
<p class="text-sm font-bold text-slate-400 uppercase tracking-widest">{{ __('Pickup Code') }}: <span class="text-cyan-500" x-text="activeQrCode"></span></p>
</div>
<div class="relative group p-4 bg-white dark:bg-white/5 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 shadow-inner mb-8">
<x-qr-code data="activeQrCode" size="300" class="w-full h-auto aspect-square mx-auto rounded-3xl" />
</div>
<button @click="showQrModal = false" class="w-full btn-luxury-primary py-4 rounded-2xl text-lg">
{{ __('Close') }}
</button>
</div>
</div>
</div>
</div> </div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('pickupCodeManager', (config) => ({
showCreateModal: false,
selectedMachine: '',
selectedSlot: '',
expiresHours: 24,
maxHours: 24,
showQrModal: false,
activeQrCode: '',
slots: [],
loadingSlots: false,
isLoadingTable: false,
slotSelectConfig: config,
calculateExpiry() {
const date = new Date();
date.setHours(date.getHours() + parseInt(this.expiresHours));
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0');
},
async fetchSlots() {
if (!this.selectedMachine) {
this.slots = [];
return;
}
this.loadingSlots = true;
try {
const response = await fetch(`/admin/machines/${this.selectedMachine}/slots-ajax`);
const data = await response.json();
this.slots = data.slots || [];
} catch (error) {
console.error('Error fetching slots:', error);
} finally {
this.loadingSlots = false;
}
},
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
window.history.pushState({}, '', url);
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
}
} catch (e) {
console.error('AJAX Load Failed:', e);
} finally {
this.isLoadingTable = false;
}
},
updateSlotSelect() {
this.$nextTick(() => {
const wrapper = document.getElementById('slot-select-wrapper');
if (!wrapper) return;
wrapper.querySelectorAll('select').forEach(s => {
try { window.HSSelect?.getInstance(s)?.destroy(); } catch(e){}
});
wrapper.innerHTML = '';
if (!this.selectedMachine) {
const dummy = document.createElement('div');
dummy.className = 'luxury-input opacity-50 cursor-not-allowed flex items-center justify-between';
dummy.innerHTML = `<span>{{ __("Select Slot") }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
wrapper.appendChild(dummy);
return;
}
const selectEl = document.createElement('select');
selectEl.name = 'slot_no';
selectEl.required = true;
selectEl.className = 'hidden';
selectEl.setAttribute('data-hs-select', JSON.stringify(this.slotSelectConfig));
const placeholderOpt = document.createElement('option');
placeholderOpt.value = "";
placeholderOpt.textContent = "{{ __('Select Slot') }}";
placeholderOpt.setAttribute('data-title', "{{ __('Select Slot') }}");
selectEl.appendChild(placeholderOpt);
this.slots.forEach(slot => {
const opt = document.createElement('option');
opt.value = slot.slot_no;
const text = `${slot.slot_no} - ${slot.product ? slot.product.name : 'Empty'}`;
opt.textContent = text;
opt.setAttribute('data-title', text);
if (this.selectedSlot == slot.slot_no) opt.selected = true;
selectEl.appendChild(opt);
});
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', e => { this.selectedSlot = e.target.value; });
if (window.HSStaticMethods?.autoInit) {
window.HSStaticMethods.autoInit(['select']);
const inst = window.HSSelect?.getInstance(selectEl);
if (inst && this.selectedSlot) inst.setValue(this.selectedSlot);
}
});
},
syncSelect(id, value) {
this.$nextTick(() => {
const el = document.getElementById(id);
if (el) {
const valStr = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
el.value = valStr;
window.HSSelect?.getInstance(el)?.setValue(valStr);
}
});
},
init() {
this.$watch('slots', () => this.updateSlotSelect());
this.$watch('selectedMachine', (val) => {
this.syncSelect('modal-pickup-machine', val);
this.updateSlotSelect();
});
this.$watch('showCreateModal', (val) => {
if (val) {
this.selectedMachine = '';
this.selectedSlot = '';
this.slots = [];
this.updateSlotSelect();
}
});
}
}));
});
</script>
@endsection @endsection

View File

@ -0,0 +1,11 @@
@props(['data', 'size' => 250, 'dynamic' => true])
@if($dynamic)
<img :src="'{{ route('admin.basic-settings.qr-code') }}?size={{ $size }}&data=' + encodeURIComponent({{ $data }})"
{{ $attributes->merge(['class' => 'object-contain']) }}
alt="QR Code">
@else
<img src="{{ route('admin.basic-settings.qr-code', ['data' => $data, 'size' => $size]) }}"
{{ $attributes->merge(['class' => 'object-contain']) }}
alt="QR Code">
@endif

View File

@ -53,6 +53,9 @@
'pending' => ['label' => __('Pending'), 'color' => 'amber'], 'pending' => ['label' => __('Pending'), 'color' => 'amber'],
'warning' => ['label' => __('Warning'), 'color' => 'amber'], 'warning' => ['label' => __('Warning'), 'color' => 'amber'],
'error' => ['label' => __('Error'), 'color' => 'rose'], 'error' => ['label' => __('Error'), 'color' => 'rose'],
'used' => ['label' => __('Used'), 'color' => 'indigo'],
'expired' => ['label' => __('Expired'), 'color' => 'rose'],
'cancelled'=> ['label' => __('Cancelled'),'color' => 'slate'],
]; ];
$preset = $presets[$status] ?? ['label' => $status, 'color' => 'slate']; $preset = $presets[$status] ?? ['label' => $status, 'color' => 'slate'];