[FEAT] 優化機台日誌管理、新增庫存變動紀錄與取貨碼/通行碼介面改善

1. 新增機台異常日誌手動解除功能,包含資料庫欄位更新與 UI 操作按鈕。
2. 實作機台庫存變動紀錄系統 (Machine Stock Movements),支援追蹤補貨與銷售產生的庫存異動。
3. 重構取貨碼 (Pickup Code) 與通行碼 (Pass Code) 管理介面,採用極簡奢華風 UI 並拆分 Partial Views 提升可維護性。
4. 優化取貨操作日誌,明確區分「取貨成功」與「取貨失敗」,並加入顏色標籤增強辨識度。
5. 擴充多語系支援 (繁中、英文、日文),確保所有新功能與操作狀態均有對應翻譯。
6. 更新 IoT API 規格文件與相關 Service 邏輯,加強指令確認 (ACK) 處理機制。
This commit is contained in:
sky121113 2026-05-04 08:43:35 +08:00
parent cb5f8f8336
commit 8feca33f13
30 changed files with 3222 additions and 1313 deletions

View File

@ -218,34 +218,55 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
---
### 3.9 B024: 取貨碼/通行碼驗證與消耗回報 (Access Code Verify & Report)
用於處理機台端的代碼取貨流程。包含驗證代碼有效性(驗證階段)與確認出貨完成後的狀態消耗(回報階段)
### 3.9 B660: 取貨碼驗證與消耗 (Pickup Code)
處理機台端的代碼取貨流程。採用兩階段流程以確保庫存準確性
- **URL**: POST|PUT /api/v1/app/sell/access-code/B024
#### [B660] 取貨碼驗證與消耗 (兩階段)
* **Path**: `/api/v1/app/machine/pickup/verify/B660`
* **Method**: `POST` (驗證) / `PUT` (消耗)
* **Description**: 驗證 8 位數取貨碼,並於出貨完成後回報消耗以扣除庫存。
#### [B670] 通行碼驗證 (維護專用)
* **Path**: `/api/v1/app/machine/passcode/verify/B670`
* **Method**: `POST`
* **Description**: 工程人員現場測試專用通行碼,不涉及庫存消耗。
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 使用者輸入的 8 位數取貨碼 | "12345678" |
**Response (Success 200):**
- `pickup_code_id`: 代碼內部 ID供第二階段 PUT 使用。
- `slot_no`: 建議出貨貨道。
- `product_name`: 商品名稱。
#### B. 消耗階段 (PUT)
當機台確認出貨成功後發送,雲端此時才正式執行 `decrement` 庫存與標記代碼已使用。
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| pickup_code_id | Integer | 是 | 驗證階段取得的 ID | 45 |
| status | Integer | 是 | 出貨結果 (1: 成功, 0: 失敗) | 1 |
---
### 3.10 B670: 通行碼驗證 (Pass Code)
專供維修或工程人員現場測試使用的通行碼。
- **URL**: POST /api/v1/app/machine/passcode/verify/B670
- **Authentication**: Bearer Token (Header)
- **Request Body (POST - 驗證階段):**
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| passCode | String | 是 | 使用者輸入的取貨碼/通行碼 | "12345678" |
| code | String | 是 | 8 位數通行碼 | "88888888" |
- **Response Body (POST - 驗證成功 200):**
雲端將回傳該碼對應的權限或待出貨商品資訊。
**Response (Success 200):**
- `name`: 持有者姓名 (如:維護部 A 員)。
- `status`: "active"。
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| res1 | String | 該代碼在雲端的關聯 ID | "99" |
| res2 | String | 操作模式 (1: 出貨, 2: 僅驗證) | "1" |
| res3 | String | 預計出貨商品 ID | "5" |
| res4 | String | 折扣金額或活動標籤 | "0.0" |
- **Request Body (PUT - 回報階段):**
當機台端確認實體出貨成功後,必須發送此請求以耗刷該代碼。
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| accessCodeId | String | 是 | 驗證階段取得的 res1 (ID) | "99" |
| status | String | 是 | 出貨結果 (1: 成功, 0: 失敗) | "1" |
> [!WARNING]
> **鎖定機制**B660 與 B670 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘,以防暴力破解。
---
@ -258,11 +279,28 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **POST**: 提交 `passCode` 驗證該贈品券是否有效。成功後雲端會回傳對應活動 ID 與商品配置。
- **PUT**: 當該贈品0 元商品)出貨完成後,向雲端回報消耗,確保優惠券不會重複核銷。
> [!NOTE]
> B027 與 B024 的邏輯具備高度對稱性,區別在於 B027 通常綁定的是特定的行銷活動 (Campaign) 與 0 元出貨邏輯。
---
### 3.11 B650: 會員身分驗證 (Member Verification)
機台端掃碼後驗證會員身分。
- **URL**: POST /api/v1/app/machine/member/verify/B650
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 會員代碼 | "MEMBER12345" |
---
### 3.12 B710: 機台時間同步 (Timer Sync)
校準機台 RTC 時間。
- **URL**: POST /api/v1/app/machine/timer/B710
- **Authentication**: Bearer Token (Header)
- **Response:** 回傳當前伺服器 Unix Timestamp。
## 4. 已遷移至 MQTT 的端點索引

View File

@ -216,4 +216,20 @@ class MachineController extends AdminController
{
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
}
/**
* AJAX: 清除機台所有未解決的警告與異常 (手動排除)
*/
public function resolveLogs(Machine $machine)
{
$machine->logs()
->where('is_resolved', false)
->whereIn('level', ['error', 'warning'])
->update(['is_resolved' => true]);
return response()->json([
'success' => true,
'message' => __('All issues marked as resolved.')
]);
}
}

View File

@ -68,11 +68,46 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$data['logs'] = SystemOperationLog::with('user:id,name')
->where('module', 'pickup_code')
->latest()
$logQuery = SystemOperationLog::with('user:id,name')
->where('module', 'pickup_code');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('target_id', 'like', "%{$search}%")
->orWhere('new_values', 'like', "%{$search}%")
->orWhere('old_values', 'like', "%{$search}%");
});
}
// 新增:類型篩選
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 新增:日期區間篩選
if ($request->filled('start_date') && $request->filled('end_date')) {
try {
$start = \Carbon\Carbon::parse($request->start_date)->startOfMinute();
$end = \Carbon\Carbon::parse($request->end_date)->endOfMinute();
$logQuery->whereBetween('created_at', [$start, $end]);
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 15), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'create' => __('create'),
'update' => __('update'),
'cancel' => __('cancel'),
'used' => __('used'),
'consume' => __('consume'),
'consume_failed' => __('consume_failed'),
'verify_success' => __('verify_success'),
];
}
if ($isAjax) {
@ -196,6 +231,17 @@ class SalesController extends Controller
// 通行碼設定
public function passCodes(Request $request)
{
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '通行碼設定',
'description' => '特殊通行權限碼管理 (測試/補貨用)',
'tab' => $tab,
];
// 1. 通行碼列表 (list)
if (!$isAjax || $tab === 'list') {
$query = PassCode::with(['machine', 'creator'])->latest();
if ($request->search) {
@ -226,17 +272,64 @@ class SalesController extends Controller
}
}
$passCodes = $query->paginate(15)->withQueryString();
$machines = Machine::all();
$data['passCodes'] = $query->paginate($request->input('per_page', 15), ['*'], 'list_page')->withQueryString();
$data['machines'] = Machine::all();
}
return view('admin.sales.pass-codes.index', [
'title' => '通行碼設定',
'description' => '特殊通行權限碼管理 (測試/補貨用)',
'passCodes' => $passCodes,
'machines' => $machines,
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = SystemOperationLog::with('user:id,name')
->where('module', 'pass_code');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('target_id', 'like', "%{$search}%")
->orWhere('new_values', 'like', "%{$search}%")
->orWhere('old_values', 'like', "%{$search}%");
});
}
// 新增:類型篩選
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 新增:日期區間篩選
if ($request->filled('start_date') && $request->filled('end_date')) {
try {
$start = \Carbon\Carbon::parse($request->start_date)->startOfMinute();
$end = \Carbon\Carbon::parse($request->end_date)->endOfMinute();
$logQuery->whereBetween('created_at', [$start, $end]);
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 15), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'create' => __('create'),
'update' => __('update'),
'cancel' => __('cancel'),
'used' => __('used'),
'consume' => __('consume'),
'verify_success' => __('verify_success'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.pass-codes.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.pass-codes.index', $data);
}
/**
* 產生通行碼
*/
@ -262,6 +355,16 @@ class SalesController extends Controller
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'create',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code created: :code', ['code' => $passCode->code]));
}
@ -281,10 +384,23 @@ class SalesController extends Controller
$validated['expires_at'] = null;
}
$oldValues = $passCode->toArray();
$passCode->update(array_filter($validated, function ($value, $key) use ($request) {
return $request->has($key);
}, ARRAY_FILTER_USE_BOTH));
SystemOperationLog::create([
'company_id' => $passCode->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'update',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'old_values' => $oldValues,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code updated.'));
}
@ -293,7 +409,20 @@ class SalesController extends Controller
*/
public function destroyPassCode(PassCode $passCode)
{
$oldValues = $passCode->toArray();
$passCode->update(['status' => 'disabled']);
SystemOperationLog::create([
'company_id' => $passCode->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'cancel',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'old_values' => $oldValues,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code cancelled.'));
}

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineSlot;
use App\Models\Machine\MachineStockMovement;
use App\Models\Product\Product;
use App\Models\Warehouse\ReplenishmentOrder;
use App\Models\Warehouse\ReplenishmentOrderItem;
@ -605,6 +606,41 @@ class WarehouseController extends Controller
]);
}
/**
* 機台庫存異動紀錄 (AJAX) Slide-out Panel 使用
*/
public function machineStockMovements(Request $request, Machine $machine)
{
$query = $machine->stockMovements()
->with(['product:id,name', 'creator:id,name'])
->latest();
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
if ($request->filled('slot_no')) {
$query->where('slot_no', $request->input('slot_no'));
}
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->input('date_from'));
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->input('date_to'));
}
$movements = $query->paginate($request->input('per_page', 20))->withQueryString();
return response()->json([
'success' => true,
'movements' => $movements->items(),
'meta' => [
'current_page' => $movements->currentPage(),
'last_page' => $movements->lastPage(),
'total' => $movements->total(),
],
]);
}
// ─── 模組 5機台補貨 ───
public function replenishments(Request $request)
@ -900,12 +936,34 @@ class WarehouseController extends Controller
}
}
// delivering → completed增加機台貨道庫存
// delivering → completed增加機台貨道庫存並記錄異動
if ($newStatus === ReplenishmentOrder::STATUS_COMPLETED) {
foreach ($replenishmentOrder->items as $item) {
MachineSlot::where('machine_id', $replenishmentOrder->machine_id)
$slot = MachineSlot::where('machine_id', $replenishmentOrder->machine_id)
->where('slot_no', $item->slot_no)
->increment('stock', $item->quantity);
->lockForUpdate()
->first();
if ($slot) {
$beforeQty = $slot->stock;
$slot->increment('stock', $item->quantity);
$slot->refresh();
MachineStockMovement::create([
'company_id' => $replenishmentOrder->company_id,
'machine_id' => $replenishmentOrder->machine_id,
'product_id' => $item->product_id,
'slot_no' => $item->slot_no,
'type' => MachineStockMovement::TYPE_REPLENISHMENT,
'quantity' => $item->quantity,
'before_qty' => $beforeQty,
'after_qty' => $slot->stock,
'reference_type' => ReplenishmentOrder::class,
'reference_id' => $replenishmentOrder->id,
'note' => '補貨單完成入庫 #' . $replenishmentOrder->order_no,
'created_by' => Auth::id(),
]);
}
}
$replenishmentOrder->completed_at = now();

View File

@ -14,7 +14,11 @@ use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
use App\Models\Machine\MachineStockMovement;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PassCode;
use App\Models\System\SystemOperationLog;
use App\Services\Machine\MachineService;
class MachineController extends Controller
{
@ -491,80 +495,75 @@ class MachineController extends Controller
'success' => true,
'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列
]);
}
/**
* B660: Verify Pickup Code (with Lockout)
* 驗證 4 位數取貨碼,並具備連續錯誤鎖定機制
* B660: Verify/Consume Pickup Code
* POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌)
* PUT: 消耗取貨碼 (扣庫存)
*/
public function verifyPickupCode(Request $request)
{
$machine = $request->get('machine'); // 來自 IotAuth 中間層
$lockoutKey = "pickup_lockout:{$machine->id}";
$machine = $request->get('machine');
$method = $request->method();
// 檢查是否處於鎖定狀態
if (Cache::has($lockoutKey)) {
return response()->json([
'success' => false,
'message' => 'Too many failed attempts. Please try again in 1 minute.',
'code' => 429
], 429);
if ($method === 'POST') {
return $this->handlePickupVerify($request, $machine);
} elseif ($method === 'PUT') {
return $this->handlePickupConsume($request, $machine);
}
$validator = Validator::make($request->all(), [
'code' => 'required|string|size:8'
]);
return response()->json(['success' => false, 'message' => 'Method not allowed'], 405);
}
private function handlePickupVerify(Request $request, $machine)
{
$lockoutKey = "pickup_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many failed attempts.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Invalid code format',
'code' => 400
], 400);
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$pickupCode = PickupCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where('expires_at', '>', now())
->where(function($q) { $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); })
->first();
if (!$pickupCode) {
// 追蹤失敗次數
$failKey = "pickup_fails:{$machine->id}";
$fails = Cache::increment($failKey);
// 第一次失敗,設定計數器的過期時間 (例如 10 分鐘內累計)
if ($fails === 1) {
Cache::put($failKey, 1, 600);
}
$fails = Cache::increment("pickup_fails:{$machine->id}");
if ($fails === 1) Cache::put("pickup_fails:{$machine->id}", 1, 600);
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60); // 鎖定 1 分鐘
Cache::forget($failKey); // 清除錯誤計數
return response()->json([
'success' => false,
'message' => 'Too many failed attempts. Machine locked for 1 minute.',
'code' => 429
], 429);
}
return response()->json([
'success' => false,
'message' => 'Invalid code',
'code' => 404,
'remaining_attempts' => 5 - $fails
], 404);
}
// 驗證成功:清除失敗紀錄
Cache::put($lockoutKey, true, 60);
Cache::forget("pickup_fails:{$machine->id}");
}
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[PickupCode] Verification failed: $code", 'warning');
return response()->json(['success' => false, 'message' => 'Invalid code', 'code' => 404, 'remaining_attempts' => 5 - $fails], 404);
}
Cache::forget("pickup_fails:{$machine->id}");
// 記錄到系統操作日誌供後台顯示
SystemOperationLog::create([
'company_id' => $machine->company_id,
'module' => 'pickup_code',
'action' => 'verify_success',
'target_id' => $pickupCode->id,
'target_type' => PickupCode::class,
'new_values' => ['code' => $pickupCode->code, 'machine_sn' => $machine->serial_no],
'note' => "log.pickup.verify_success_note",
]);
return response()->json([
'success' => true,
'message' => 'Valid code',
'code' => 200,
'data' => [
'slot_no' => $pickupCode->slot_no,
@ -573,4 +572,122 @@ class MachineController extends Controller
]
]);
}
private function handlePickupConsume(Request $request, $machine)
{
$pickupCodeId = $request->input('pickup_code_id');
$status = $request->input('status'); // 1: success, 0: failed
$pickupCode = PickupCode::where('machine_id', $machine->id)
->where('id', $pickupCodeId)
->where('status', 'active')
->first();
if (!$pickupCode) {
return response()->json(['success' => false, 'message' => 'Pickup code not found or already used'], 404);
}
if ($status == '1') {
// 扣除庫存
$slot = $machine->slots()->where('slot_no', $pickupCode->slot_no)->first();
if ($slot && $slot->stock > 0) {
$oldStock = $slot->stock;
$slot->decrement('stock');
$slot->refresh();
// 記錄取貨碼 pickup 庫存異動流水帳
app(MachineService::class)->recordStockMovement(
$slot,
-1,
MachineStockMovement::TYPE_PICKUP,
$pickupCode,
"取貨碼核銷消耗,碼: {$pickupCode->code}"
);
$pickupCode->update(['status' => 'used', 'used_at' => now()]);
ProcessStateLog::dispatch($machine->id, $machine->company_id, "log.pickup.consume_success", 'info', ['code' => $pickupCode->code, 'slot' => $slot->slot_no]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'module' => 'pickup_code',
'action' => 'consume',
'target_id' => $pickupCode->id,
'target_type'=> PickupCode::class,
'new_values' => [
'code' => $pickupCode->code,
'machine_sn' => $machine->serial_no,
'slot' => $slot->slot_no,
'old' => $oldStock,
'new' => ($oldStock - 1)
],
'note' => "log.pickup.consume_success_note",
]);
} else {
return response()->json(['success' => false, 'message' => 'Out of stock'], 400);
}
} else {
ProcessStateLog::dispatch($machine->id, $machine->company_id, "log.pickup.dispense_failed", 'error', ['code' => $pickupCode->code]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'module' => 'pickup_code',
'action' => 'consume_failed',
'target_id' => $pickupCode->id,
'target_type' => PickupCode::class,
'new_values' => ['code' => $pickupCode->code, 'machine_sn' => $machine->serial_no],
'note' => "log.pickup.dispense_failed_note",
]);
}
return response()->json(['success' => true, 'code' => 200]);
}
/**
* B670: Verify Pass Code (For Technicians)
*/
public function verifyPassCode(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "passcode_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Locked', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) return response()->json(['success' => false, 'code' => 400], 400);
$code = $request->input('code');
$passCode = PassCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function($q) { $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); })
->first();
if (!$passCode) {
$fails = Cache::increment("passcode_fails:{$machine->id}");
if ($fails >= 5) Cache::put($lockoutKey, true, 60);
return response()->json(['success' => false, 'code' => 404], 404);
}
SystemOperationLog::create([
'company_id' => $machine->company_id,
'module' => 'pass_code',
'action' => 'verify_success',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'new_values' => ['code' => $passCode->code, 'machine_sn' => $machine->serial_no],
'note' => "log.passcode.verify_success_note",
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $passCode->name,
'status' => 'active'
]
]);
}
}

View File

@ -3,7 +3,9 @@
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineStockMovement;
use App\Models\Machine\RemoteCommand;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -34,7 +36,7 @@ class ProcessCommandAck implements ShouldQueue
* 處理 B055 出貨回報 ACK
* 原始邏輯移植自 MachineController::reportDispenseResult()
*/
public function handle(): void
public function handle(MachineService $machineService): void
{
$commandId = $this->payload['command_id'] ?? null;
$resultCode = $this->payload['result'] ?? null;
@ -115,11 +117,30 @@ class ProcessCommandAck implements ShouldQueue
$payloadUpdates['rolled_back'] = true;
$payloadUpdates['new_stock'] = $oldStock;
// 記錄 rollback 庫存異動流水帳
$slot->refresh();
$machineService->recordStockMovement(
$slot,
+1,
MachineStockMovement::TYPE_ROLLBACK,
$command,
"遠端出貨失敗 Rollback指令 ID: {$command->id},結果碼: {$resultCode}"
);
Log::warning('MQTT Dispense failed, rolled back inventory', [
'serial_no' => $this->serialNo,
'slot_no' => $slotNo,
'rolled_back_to' => $oldStock
]);
// 記錄到機台日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Remote dispense failed for slot :slot",
'error',
['slot' => $slotNo]
);
}
}
}
@ -152,6 +173,20 @@ class ProcessCommandAck implements ShouldQueue
'payload' => array_merge($command->payload, $payloadUpdates),
]);
// 記錄維護類指令到機台日誌 (MachineLog)
if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock'], true)) {
$msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed';
$level = $status === 'success' ? 'info' : 'warning';
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$msgKey,
$level,
['type' => __("command.{$command->command_type}")]
);
}
Log::info("MQTT CommandAck processed", [
'serial_no' => $this->serialNo,
'command_id' => $commandId,

View File

@ -107,9 +107,10 @@ class Machine extends Model
return 'offline';
}
// 判定異常 (檢查過去 15 分鐘內是否有 error 日誌)
// 判定異常 (檢查過去 15 分鐘內是否有 未解決的 error 日誌)
$hasRecentErrors = $this->logs()
->where('level', 'error')
->where('is_resolved', false)
->where('created_at', '>=', now()->subMinutes(15))
->exists();
@ -117,6 +118,17 @@ class Machine extends Model
return 'error';
}
// 判定警告 (檢查過去 15 分鐘內是否有 未解決的 warning 日誌)
$hasRecentWarnings = $this->logs()
->where('level', 'warning')
->where('is_resolved', false)
->where('created_at', '>=', now()->subMinutes(15))
->exists();
if ($hasRecentWarnings) {
return 'warning';
}
return 'online';
}
@ -146,6 +158,7 @@ class Machine extends Model
->from('machine_logs')
->whereColumn('machine_logs.machine_id', 'machines.id')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false)
->where('created_at', '>=', now()->subMinutes(15));
});
}
@ -160,6 +173,7 @@ class Machine extends Model
->from('machine_logs')
->whereColumn('machine_logs.machine_id', 'machines.id')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false)
->where('created_at', '>=', now()->subMinutes(15));
});
}
@ -279,4 +293,9 @@ class Machine extends Model
{
return $this->hasMany(\App\Models\Transaction\Order::class);
}
public function stockMovements()
{
return $this->hasMany(MachineStockMovement::class);
}
}

View File

@ -18,10 +18,12 @@ class MachineLog extends Model
'type',
'message',
'context',
'is_resolved',
];
protected $casts = [
'context' => 'array',
'is_resolved' => 'boolean',
];
protected $appends = [

View File

@ -0,0 +1,109 @@
<?php
namespace App\Models\Machine;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use App\Models\Product\Product;
use App\Models\System\User;
class MachineStockMovement extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'product_id',
'slot_no',
'type',
'quantity',
'before_qty',
'after_qty',
'reference_type',
'reference_id',
'note',
'created_by',
];
protected $appends = [
'translated_note',
];
/**
* 動態翻譯備註
*/
public function getTranslatedNoteAttribute(): ?string
{
if (empty($this->note)) {
return null;
}
return __($this->note, $this->context ?? []);
}
protected $casts = [
'quantity' => 'integer',
'before_qty' => 'integer',
'after_qty' => 'integer',
'context' => 'array',
];
// ─── 異動類型常數 ───
const TYPE_REPLENISHMENT = 'replenishment'; // 補貨單完成
const TYPE_PICKUP = 'pickup'; // 取貨碼核銷
const TYPE_REMOTE_DISPENSE = 'remote_dispense'; // 遠端出貨 (B055 樂觀扣除)
const TYPE_ADJUSTMENT = 'adjustment'; // B009 回報 / 後台手動修改
const TYPE_ROLLBACK = 'rollback'; // B055 出貨失敗回退
// ─── 關聯 ───
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 多型關聯:補貨單、取貨碼操作記錄、遠端指令等
*/
public function reference(): MorphTo
{
return $this->morphTo();
}
// ─── 輔助方法 ───
/**
* 是否為增加庫存的類型
*/
public function isInbound(): bool
{
return in_array($this->type, [
self::TYPE_REPLENISHMENT,
self::TYPE_ROLLBACK,
]);
}
/**
* 是否為減少庫存的類型
*/
public function isOutbound(): bool
{
return in_array($this->type, [
self::TYPE_PICKUP,
self::TYPE_REMOTE_DISPENSE,
]);
}
}

View File

@ -11,6 +11,26 @@ class SystemOperationLog extends Model
{
use HasFactory, TenantScoped;
protected $appends = [
'translated_note',
];
/**
* 動態翻譯備註
*/
public function getTranslatedNoteAttribute(): ?string
{
if (empty($this->note)) {
return null;
}
return __($this->note, array_merge(
$this->old_values ?? [],
$this->new_values ?? []
));
}
protected $fillable = [
'company_id',
'user_id',

View File

@ -4,7 +4,10 @@ namespace App\Services\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use App\Models\Machine\MachineSlot;
use App\Models\Machine\MachineStockMovement;
use App\Models\Machine\RemoteCommand;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
@ -131,6 +134,42 @@ class MachineService
]);
}
/**
* 統一寫入機台庫存異動紀錄
*
* @param MachineSlot $slot 貨道 Model已含 machine_id, product_id, stock
* @param int $delta 異動數量(正=增加,負=減少)
* @param string $type 異動類型MachineStockMovement::TYPE_*
* @param mixed $reference 關聯單據 Modelnullable
* @param string|null $note 備註說明
*/
public function recordStockMovement(
MachineSlot $slot,
int $delta,
string $type,
mixed $reference = null,
?string $note = null,
array $context = []
): MachineStockMovement {
$before = $slot->stock - $delta; // 因為已更新 slot反推異動前的值
return MachineStockMovement::create([
'company_id' => $slot->machine->company_id ?? null,
'machine_id' => $slot->machine_id,
'product_id' => $slot->product_id,
'slot_no' => $slot->slot_no,
'type' => $type,
'quantity' => $delta,
'before_qty' => $before,
'after_qty' => $slot->stock,
'reference_type' => $reference ? get_class($reference) : null,
'reference_id' => $reference?->id,
'note' => $note,
'context' => $context,
'created_by' => Auth::id(),
]);
}
/**
* Sync machine slots based on replenishment report.
*
@ -175,17 +214,33 @@ class MachineService
}
}
$newStock = $slotData['stock'] ?? 0;
$updateData = [
'product_id' => $actualProductId,
'type' => $slotType,
'stock' => $slotData['stock'] ?? 0,
'stock' => $newStock,
'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
'is_active' => true,
];
// 如果這是一次明確的補貨回報,建議更新時間並記錄
if ($existingSlot) {
// B009 回報:若數值有變化,記錄 adjustment
$oldStock = (int) $existingSlot->stock;
$existingSlot->update($updateData);
if ($oldStock !== $newStock) {
$delta = $newStock - $oldStock;
$existingSlot->refresh();
$this->recordStockMovement(
$existingSlot,
$delta,
MachineStockMovement::TYPE_ADJUSTMENT,
null,
"movement.note.replenishment_correction",
['old' => $oldStock, 'new' => $newStock]
);
}
} else {
$machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo]));
}
@ -238,6 +293,20 @@ class MachineService
if ($stock !== null) $updateData['stock'] = (int)$stock;
$slot->update($updateData);
// 3. 若庫存數值有異動,記錄 adjustment 流水帳
if ($stock !== null && (int)$stock !== $oldData['stock']) {
$delta = (int)$stock - $oldData['stock'];
$slot->refresh();
$this->recordStockMovement(
$slot,
$delta,
MachineStockMovement::TYPE_ADJUSTMENT,
null,
"movement.note.manual_adjustment",
['old' => $oldData['stock'], 'new' => $stock]
);
}
// 3. 建立遠端指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
@ -302,7 +371,7 @@ class MachineService
$oldStock = $slot->stock;
$newStock = $oldStock - 1;
// 1. 執行樂觀扣
// 1. 執行樂觀扣
$slot->update(['stock' => $newStock]);
// 2. 建立指令紀錄
@ -318,7 +387,17 @@ class MachineService
]
]);
// 3. 推播 MQTT
// 3. 寫入庫存異動流水帳remote_dispense樂觀扣除
$slot->refresh();
$this->recordStockMovement(
$slot,
-1,
MachineStockMovement::TYPE_REMOTE_DISPENSE,
$command,
"遠端出貨指令 (B055),指令 ID: {$command->id}"
);
// 4. 推播 MQTT
app(\App\Services\Machine\MqttService::class)->pushCommand(
$machine->serial_no,
'dispense',

View File

@ -28,6 +28,7 @@ services:
depends_on:
- mysql
- redis
restart: unless-stopped
laravel.queue:
image: 'sail-8.5/app'
@ -45,7 +46,7 @@ services:
depends_on:
- mysql
- redis
restart: always
restart: unless-stopped
laravel.mqtt:
image: 'sail-8.5/app'
container_name: star-cloud-mqtt-worker
@ -63,7 +64,7 @@ services:
- mysql
- redis
- emqx
restart: always
restart: unless-stopped
mysql:
image: 'mysql/mysql-server:8.0'
@ -92,6 +93,7 @@ services:
- '-p${DB_PASSWORD}'
retries: 3
timeout: 5s
restart: unless-stopped
redis:
image: 'redis:alpine'
container_name: star-cloud-redis
@ -109,6 +111,7 @@ services:
- ping
retries: 3
timeout: 5s
restart: unless-stopped
emqx:
image: 'emqx/emqx:5.10.3'
container_name: star-cloud-emqx
@ -134,6 +137,7 @@ services:
retries: 5
depends_on:
- redis
restart: unless-stopped
mqtt-gateway:
build:
context: ./mqtt-gateway
@ -153,7 +157,7 @@ services:
TZ: 'Asia/Taipei'
networks:
- sail
restart: always
restart: unless-stopped
depends_on:
- emqx
- redis

View File

@ -275,86 +275,120 @@ return [
]
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v03' => 'Cold Drink',
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
't060v30' => 20.0,
't063v03' => 25.0,
't060v40' => 'Buy 1 Get 1',
't060v41' => 'SKU-001',
'spring_limit' => 10,
'track_limit' => 15
]
]
],
'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。'
],
[
'name' => 'B017: 貨道庫存同步 (Slot Synchronization)',
'slug' => 'b017-slot-sync',
'method' => 'GET',
'path' => '/api/v1/app/machine/reload_msg/B017',
'description' => '用於機台端獲獲取所有貨道的最新庫存、效期與狀態。通常由 B010 回傳 status: 49 時觸發。',
'name' => 'B650: 會員身分驗證 (Member Verification)',
'slug' => 'b650-member-verify',
'method' => 'POST',
'path' => '/api/v1/app/machine/member/verify/B650',
'description' => '機台端掃碼後驗證會員身分。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '是否成功',
'example' => true
],
'data' => [
'type' => 'array',
'description' => '貨道數據陣列。',
'example' => [
[
'tid' => '1',
'num' => 10,
'expiry_date' => '2026-12-31',
'batch_no' => 'B2026',
'status' => '1'
]
'parameters' => [
'code' => [
'type' => 'string',
'required' => true,
'description' => '會員代碼',
'example' => 'MEMBER12345'
]
],
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'data' => [
[
'tid' => '1',
'num' => 10,
'expiry_date' => '2026-12-31',
'batch_no' => 'B2026',
'product_id' => 1,
'capacity' => 15,
'status' => '1'
]
'member_id' => 123,
'name' => '王小明',
'points' => 500
]
],
'notes' => 'B017 為全量同步。實作上後端會依據 slot_no 進行排序,並將相關指令狀態更新為已完成。'
],
[
'name' => 'B024: 取貨碼/通行碼驗證與消耗回報',
'slug' => 'b024-access-code',
'name' => 'B660: 取貨碼驗證與消耗 (Pickup Code)',
'slug' => 'b660-pickup-code',
'method' => 'POST / PUT',
'path' => '/api/v1/app/machine/pickup/verify/B660',
'description' => '處理兩階段代碼取貨。POST 驗證有效性PUT 回報出貨成功後正式扣庫存。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'code' => [
'type' => 'string',
'description' => '8 位數取貨碼 (僅限 POST)',
'example' => '12345678'
],
'pickup_code_id' => [
'type' => 'integer',
'description' => '代碼內部 ID (僅限 PUT)',
'example' => 45
],
'status' => [
'type' => 'integer',
'description' => '出貨狀態 (僅限 PUT: 1:成功, 0:失敗)',
'example' => 1
],
],
'response' => [
'success' => true,
'data' => [
'slot_no' => '1',
'pickup_code_id' => 45,
'status' => 'active'
]
],
'notes' => '安全性:連續錯誤 5 次將鎖定該機台驗證功能 1 分鐘。'
],
[
'name' => 'B670: 通行碼驗證 (Pass Code)',
'slug' => 'b670-pass-code',
'method' => 'POST',
'path' => '/api/v1/app/sell/access-code/B024',
'description' => '處理代碼取貨流程。POST 用於驗證碼有效性PUT 用於回報出貨成功並消耗代碼。',
'path' => '/api/v1/app/machine/passcode/verify/B670',
'description' => '專供維修/工程人員使用的通行碼驗證。僅做身份確認,不涉及商品消耗。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'code' => [
'type' => 'string',
'required' => true,
'description' => '8 位數通行碼',
'example' => '88888888'
]
],
'response' => [
'success' => true,
'data' => [
'name' => '工程師 A',
'status' => 'active'
]
],
'notes' => '僅供現場測試使用,錯誤過多同樣會觸發鎖定。'
],
[
'name' => 'B710: 機台時間同步 (Timer Sync)',
'slug' => 'b710-timer-sync',
'method' => 'POST',
'path' => '/api/v1/app/machine/timer/B710',
'description' => '校準機台 RTC 時間。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
],
'response' => [
'success' => true,
'data' => [
'timestamp' => 1714444800
]
]
],
[
'name' => 'B027: 贈品碼/優惠券驗證與消耗 (Free Gift Verify & Consume)',
'slug' => 'b027-freebie-code',
'method' => 'POST / PUT',
'path' => '/api/v1/app/sell/free-gift/B027',
'description' => 'POST 用於驗證碼有效性PUT 用於回報出貨成功並消耗代碼。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
@ -362,7 +396,7 @@ return [
'parameters' => [
'passCode' => [
'type' => 'string',
'description' => '取貨碼 (POST)',
'description' => '贈品碼 (POST)',
],
'accessCodeId' => [
'type' => 'string',
@ -373,37 +407,9 @@ return [
'description' => '出貨狀態 (PUT: 1:成功, 0:失敗)',
],
],
'response_parameters' => [
'res1' => ['type' => 'string', 'description' => '雲端關聯 ID'],
'res3' => ['type' => 'string', 'description' => '預計出貨商品 ID'],
],
'request' => [
'passCode' => '12345678'
],
'response' => [
'success' => true,
'res1' => '99',
'res3' => '5'
],
],
[
'name' => 'B027: 贈品碼/優惠券驗證與消耗回報',
'slug' => 'b027-freebie-code',
'method' => 'POST',
'path' => '/api/v1/app/sell/free-gift/B027',
'description' => '處理贈品券與 0 元購活動。邏輯與 B024 相似但對象為行銷贈品。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'passCode' => [
'type' => 'string',
'description' => '贈品碼 (POST)',
],
],
'response_parameters' => [
'success' => ['type' => 'boolean', 'description' => '驗證結果'],
'res1' => ['type' => 'string', 'description' => '雲端關聯 ID'],
],
'request' => [
'passCode' => 'FREE888'
@ -413,8 +419,8 @@ return [
'message' => 'Free gift verified'
],
],
]
]
],
],
],
'mqtt_topics' => [

View File

@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('machine_stock_movements', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('company_id')->nullable()->index();
$table->unsignedBigInteger('machine_id')->index();
$table->unsignedBigInteger('product_id')->nullable()->index();
$table->string('slot_no', 20);
// 異動類型:補貨入庫、取貨碼消耗、遠端出貨、盤點調整、出貨失敗回退
$table->enum('type', [
'replenishment', // 補貨單完成
'pickup', // 取貨碼核銷
'remote_dispense', // 遠端出貨指令 (B055 樂觀扣除)
'adjustment', // B009 盤點回報 / 後台手動修改
'rollback', // B055 出貨失敗,庫存回退
]);
$table->integer('quantity'); // 異動數量(正數=增加,負數=減少)
$table->integer('before_qty'); // 異動前數量
$table->integer('after_qty'); // 異動後數量
// 多型關聯(可關聯補貨單、取貨碼、遠端指令等)
$table->string('reference_type')->nullable();
$table->unsignedBigInteger('reference_id')->nullable();
$table->index(['reference_type', 'reference_id']);
$table->text('note')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
// 複合索引:查詢特定機台某時段的異動
$table->index(['machine_id', 'slot_no', 'created_at']);
// 多租戶過濾 + 類型篩選
$table->index(['company_id', 'type', 'created_at']);
$table->foreign('machine_id')->references('id')->on('machines')->cascadeOnDelete();
$table->foreign('product_id')->references('id')->on('products')->nullOnDelete();
$table->foreign('created_by')->references('id')->on('users')->nullOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('machine_stock_movements');
}
};

View File

@ -0,0 +1,28 @@
<?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('machine_stock_movements', function (Blueprint $table) {
$table->json('context')->nullable()->after('note');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_stock_movements', function (Blueprint $table) {
$table->dropColumn('context');
});
}
};

View File

@ -0,0 +1,28 @@
<?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('machine_logs', function (Blueprint $table) {
$table->boolean('is_resolved')->default(false)->after('message');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_logs', function (Blueprint $table) {
$table->dropColumn('is_resolved');
});
}
};

View File

@ -28,6 +28,7 @@
"Account:": "Account:",
"Accounts / Machines": "Accounts / Machines",
"Action": "Action",
"Action / Target": "Action / Target",
"Actions": "Actions",
"Active": "Active",
"Active Slots": "Active Slots",
@ -115,6 +116,7 @@
"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 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 deactivate this pass code? It will no longer be usable for authentication.": "Are you sure you want to deactivate this pass code? It will no longer be usable for authentication.",
"Are you sure you want to delete this account?": "Are you sure you want to delete this account?",
"Are you sure you want to delete this account? This action cannot be undone.": "Are you sure you want to delete this account? This action cannot be undone.",
"Are you sure you want to delete this advertisement? This will also remove all assignments to machines.": "Are you sure you want to delete this advertisement? This will also remove all assignments to machines.",
@ -259,7 +261,8 @@
"Confirm Cancel": "Confirm Cancel",
"Confirm Changes": "Confirm Changes",
"Confirm Complete": "Confirm Complete",
"Confirm Deletion": "Confirm Deletion",
"Confirm Deactivation": "Confirm Deactivation",
"Confirm Delete": "Confirm Delete",
"Confirm Password": "Confirm Password",
"Confirm Prepare": "Confirm Prepare",
"Confirm Status Change": "Confirm Status Change",
@ -619,6 +622,7 @@
"Loading Cabinet...": "Loading Cabinet...",
"Loading Data": "Loading Data",
"Loading machines...": "Loading machines...",
"Loading failed": "Loading failed",
"Loading...": "Loading...",
"Location": "Location",
"Location found!": "Location found!",
@ -767,6 +771,17 @@
"Motor not stopped": "Motor not stopped",
"Movement History": "Movement History",
"Movement Logs": "Movement Logs",
"Machine Stock Movements": "Machine Stock Movements",
"Stock Movement Log": "Stock Movement Log",
"movement.type.replenishment": "Replenishment",
"movement.type.pickup": "Pickup Code Consumed",
"movement.type.remote_dispense": "Remote Dispense",
"movement.type.adjustment": "Stock Adjustment",
"movement.type.rollback": "Dispense Rollback",
"Before Qty": "Before Qty",
"After Qty": "After Qty",
"Delta": "Delta",
"No movements found": "No movements found",
"Multilingual Names": "Multilingual Names",
"N/A": "N/A",
"Name": "Name",
@ -1158,7 +1173,8 @@
"Schedule": "Schedule",
"Search Company Title...": "Search Company Title...",
"Search Machine...": "Search Machine...",
"Search Product": "Search Product",
"Search logs...": "Search logs...",
"Searchable Select": "Searchable Select",
"Search accounts...": "Search accounts...",
"Search by code, machine name or serial...": "Search by code, machine name or serial...",
"Search by code, name or machine...": "Search by code, name or machine...",
@ -1444,7 +1460,13 @@
"Unlock Now": "Unlock Now",
"Unlock Page": "Unlock Page",
"Unpublish": "Unpublish",
"cancel": "Cancel",
"create": "Create",
"update": "Update",
"Update": "Update",
"Edit Pass Code": "Edit Pass Code",
"Permanent if empty": "Permanent if empty",
"Update Pass Code": "Update Pass Code",
"Update Authorization": "Update Authorization",
"Update Customer": "Update Customer",
"Update Password": "Update Password",
@ -1528,6 +1550,7 @@
"Welcome Gift Status": "Welcome Gift Status",
"Work Content": "Work Content",
"Yes, Cancel": "Yes, Cancel",
"Yes, Deactivate": "Yes, Deactivate",
"Yes, Delete": "Yes, Delete",
"Yes, Disable": "Yes, Disable",
"Yes, regenerate": "Yes, regenerate",
@ -1647,5 +1670,9 @@
"visit_gift": "visit_gift",
"vs Yesterday": "vs Yesterday",
"warehouses": "warehouses",
"待填寫": "待填寫"
"used": "Used",
"待填寫": "待填寫",
"verify_success": "Verification Success",
"consume": "Pickup Success",
"consume_failed": "Pickup Failed"
}

View File

@ -27,6 +27,8 @@
"Account updated successfully.": "Account updated successfully.",
"Account:": "Account:",
"Accounts / Machines": "Accounts / Machines",
"Action": "操作タイプ",
"Action / Target": "操作 / 対象",
"Actions": "操作",
"Active": "有効",
"Active Slots": "稼働中の貨道",
@ -114,7 +116,8 @@
"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 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?",
"Are you sure you want to deactivate this pass code? It will no longer be usable for authentication.": "この通行コードを無効にしてもよろしいですか?無効にすると認証に使用できなくなります。",
"Are you sure you want to delete this account?": "このアカウントを削除してもよろしいですか?",
"Are you sure you want to delete this account? This action cannot be undone.": "Are you sure you want to delete this account? This action cannot be undone.",
"Are you sure you want to delete this advertisement? This will also remove all assignments to machines.": "Are you sure you want to delete this advertisement? This will also remove all assignments to machines.",
"Are you sure you want to delete this configuration?": "Are you sure you want to delete this configuration?",
@ -256,7 +259,8 @@
"Confirm Account Status Change": "Confirm Account Status Change",
"Confirm Assignment": "Confirm Assignment",
"Confirm Cancel": "キャンセル確認",
"Confirm Changes": "Confirm Changes",
"Confirm Deactivation": "無効化の確認",
"Confirm Delete": "削除の確認",
"Confirm Complete": "完了を確認",
"Confirm Deletion": "削除の確認",
"Confirm Password": "Confirm Password",
@ -352,7 +356,8 @@
"Default Not Donate": "Default Not Donate",
"Define and manage security roles and permissions.": "Define and manage security roles and permissions.",
"Define new third-party payment parameters": "Define new third-party payment parameters",
"Delete": "Delete",
"Yes, Deactivate": "はい、無効にします",
"Yes, Delete": "はい、削除します",
"Delete Account": "Delete Account",
"Delete Advertisement": "Delete Advertisement",
"Delete Advertisement Confirmation": "Delete Advertisement Confirmation",
@ -617,7 +622,8 @@
"Live Fleet Updates": "Live Fleet Updates",
"Loading Cabinet...": "Loading Cabinet...",
"Loading Data": "Loading Data",
"Loading machines...": "Loading machines...",
"Loading machines...": "マシンの読み込み中...",
"Loading failed": "読み込みに失敗しました",
"Loading...": "読み込み中...",
"Location": "Location",
"Location found!": "Location found!",
@ -763,6 +769,17 @@
"Motor not stopped": "Motor not stopped",
"Movement History": "移動履歴",
"Movement Logs": "移動ログ",
"Machine Stock Movements": "機台在庫異動履歴",
"Stock Movement Log": "在庫異動ログ",
"movement.type.replenishment": "補充入庫",
"movement.type.pickup": "受取コード消費",
"movement.type.remote_dispense": "リモート出庫",
"movement.type.adjustment": "棚卸調整",
"movement.type.rollback": "出庫失敗ロールバック",
"Before Qty": "変動前数量",
"After Qty": "変動後数量",
"Delta": "変動量",
"No movements found": "異動記録なし",
"Multilingual Names": "Multilingual Names",
"N/A": "N/A",
"Name": "Name",
@ -1045,7 +1062,8 @@
"Quick Expiry Check": "Quick Expiry Check",
"Quick Maintenance": "Quick Maintenance",
"Quick Replenish": "クイック補充",
"Quick Select": "Quick Select",
"Search logs...": "ログを検索...",
"Searchable Select": "Searchable Select",
"Quick replenishment from this view": "Quick replenishment from this view",
"Quick search...": "Quick search...",
"Real-time OEE analysis awaits": "Real-time OEE analysis awaits",
@ -1439,8 +1457,11 @@
"Unlock Now": "Unlock Now",
"Unlock Page": "Unlock Page",
"Unpublish": "配信終了",
"Update": "Update",
"Update Authorization": "Update Authorization",
"Update": "更新",
"Edit Pass Code": "通行コード編集",
"Permanent if empty": "空欄の場合は永久",
"Update Pass Code": "通行コード更新",
"Update Authorization": "承認の更新",
"Update Customer": "Update Customer",
"Update Password": "Update Password",
"Update Product": "Update Product",
@ -1523,7 +1544,6 @@
"Welcome Gift Status": "Welcome Gift Status",
"Work Content": "作業内容",
"Yes, Cancel": "はい、キャンセルします",
"Yes, Delete": "はい、無効にします",
"Yes, Disable": "はい、無効にします",
"Yesterday": "Yesterday",
"You can assign or change the personnel handling this order": "この注文の担当者を割り当て、または変更できます",
@ -1640,5 +1660,8 @@
"visit_gift": "visit_gift",
"vs Yesterday": "vs Yesterday",
"warehouses": "warehouses",
"待填寫": "待填寫"
"待填寫": "待填寫",
"verify_success": "検証成功",
"consume": "受取成功",
"consume_failed": "受取失敗"
}

View File

@ -27,6 +27,8 @@
"Account updated successfully.": "帳號已成功更新。",
"Account:": "帳號:",
"Accounts / Machines": "帳號 / 機台",
"Action": "操作",
"Action / Target": "操作 / 對象",
"Actions": "操作",
"Active": "有效",
"Active Slots": "使用中貨道",
@ -114,6 +116,7 @@
"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 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 pass code? It will no longer be usable for authentication.": "您確定要停用此通行碼嗎?停用後將無法再用於驗證。",
"Are you sure you want to delete this account?": "您確定要刪除此帳號嗎?",
"Are you sure you want to delete this account? This action cannot be undone.": "確定要刪除此帳號嗎?此操作無法復原。",
"Are you sure you want to delete this advertisement? This will also remove all assignments to machines.": "您確定要刪除此廣告嗎?這也將移除所有對機台的投放。",
@ -257,6 +260,8 @@
"Confirm Cancel": "確認取消",
"Confirm Changes": "確認變更",
"Confirm Complete": "確認完成",
"Confirm Deactivation": "確認停用",
"Confirm Delete": "確認刪除",
"Confirm Deletion": "確認刪除資料",
"Confirm Password": "確認新密碼",
"Confirm Prepare": "確認備貨",
@ -298,7 +303,7 @@
"Create Config": "建立配置",
"Create Machine": "新增機台",
"Create New Role": "建立新角色",
"Create Payment Config": "新增金流配置",
"Create Payment Config": "建立金流配置",
"Create Product": "建立商品",
"Create Replenishment Order": "建立補貨單",
"Create Role": "建立角色",
@ -615,6 +620,7 @@
"Loading Cabinet...": "正在載入機台貨盤...",
"Loading Data": "載入資料中",
"Loading machines...": "正在載入機台...",
"Loading failed": "載入失敗",
"Loading...": "載入中...",
"Location": "位置",
"Location found!": "已成功獲取座標!",
@ -761,6 +767,17 @@
"Motor not stopped": "電機未停止",
"Movement History": "異動紀錄",
"Movement Logs": "異動紀錄",
"Machine Stock Movements": "機台庫存流水帳",
"Stock Movement Log": "庫存流水帳",
"movement.type.replenishment": "補貨入庫",
"movement.type.pickup": "取貨碼消耗",
"movement.type.remote_dispense": "遠端出貨",
"movement.type.adjustment": "盤點調整",
"movement.type.rollback": "出貨失敗退回",
"Before Qty": "異動前數量",
"After Qty": "異動後數量",
"Delta": "變化量",
"No movements found": "尚無異動紀錄",
"Multilingual Names": "多語系名稱",
"N/A": "不適用",
"Name": "名稱",
@ -1073,6 +1090,7 @@
"Remote Reboot": "遠端結帳",
"Remote Settlement": "遠端結帳",
"Remote dispense successful for slot :slot": "貨道 :slot 遠端出貨成功",
"Remote dispense failed for slot :slot": "貨道 :slot 遠端出貨失敗",
"Removal": "撤機",
"Repair": "維修",
"Replenish": "補貨",
@ -1151,6 +1169,7 @@
"Schedule": "排程區間",
"Search Company Title...": "搜尋公司名稱...",
"Search Machine...": "搜尋機台...",
"Search logs...": "搜尋紀錄...",
"Search Product": "搜尋商品",
"Search accounts...": "搜尋帳號...",
"Search by code, machine name or serial...": "搜尋代碼、機台名稱或序號...",
@ -1279,7 +1298,7 @@
"Stock & Expiry Overview": "庫存與效期一覽",
"Stock / Capacity": "庫存/上限",
"Stock In": "庫存入庫",
"Stock Level": "庫存",
"Stock Level": "庫存水準",
"Stock Level > 50%": "庫存高於 50%",
"Stock Levels": "庫存水準",
"Stock Management": "庫存管理單",
@ -1436,7 +1455,14 @@
"Unlock Now": "立即解鎖",
"Unlock Page": "頁面解鎖",
"Unpublish": "下架",
"cancel": "取消",
"create": "建立",
"update": "更新",
"used": "已使用",
"Update": "更新",
"Edit Pass Code": "編輯通行碼",
"Permanent if empty": "留空則為永久有效",
"Update Pass Code": "更新通行碼",
"Update Authorization": "更新授權",
"Update Customer": "更新客戶",
"Update Password": "更改密碼",
@ -1520,13 +1546,13 @@
"Welcome Gift Status": "來店禮",
"Work Content": "作業內容",
"Yes, Cancel": "確認取消",
"Yes, Delete": "是的,停用",
"Yes, Deactivate": "是的,停用",
"Yes, Delete": "是的,刪除",
"Yes, Disable": "是的,停用",
"Yesterday": "昨日",
"You can assign or change the personnel handling this order": "您可以指派或變更處理此訂單的人員",
"You cannot assign permissions you do not possess.": "您無法指派您自身不具備的權限。",
"You cannot delete your own account.": "您無法刪除自己的帳號。",
"Your email address is unverified.": "您的電子郵件地址尚未驗證。",
"Your recent account activity": "最近的帳號活動",
"accounts": "帳號管理",
"admin": "管理員",
@ -1637,5 +1663,32 @@
"visit_gift": "來店禮",
"vs Yesterday": "較昨日",
"warehouses": "倉庫管理",
"待填寫": "待填寫"
"待填寫": "待填寫",
"verify_success": "驗證成功",
"consume": "取貨成功",
"consume_failed": "取貨失敗",
"All Slots": "所有貨道",
"log.pickup.verify_success": "[取貨碼] 驗證成功,碼::code",
"log.pickup.verify_success_note": "機台 [:machine_sn] 已驗證取貨碼 [:code]",
"log.pickup.consume_success": "[取貨碼] 核銷成功::code貨道:slot",
"log.pickup.consume_success_note": "機台 [:machine_sn] 已核銷取貨碼。貨道 :slot:old -> :new",
"log.pickup.dispense_failed": "[取貨碼] 機台回報出貨失敗::code",
"log.pickup.dispense_failed_note": "機台 [:machine_sn] 回報取貨碼 [:code] 出貨失敗",
"log.passcode.verify_success_note": "維修人員於機台 [:machine_sn] 驗證通行碼 [:code]",
"movement.note.manual_adjustment": "後台手動修改貨道庫存(舊: :old → 新: :new",
"movement.note.replenishment_correction": "B009 盤點回報修正(舊: :old → 新: :new",
"log.command.success": "遠端指令 [:type] 執行成功",
"log.command.failed": "遠端指令 [:type] 執行失敗",
"command.reboot": "重啟",
"command.reboot_card": "讀卡機重啟",
"command.lock": "鎖機",
"command.unlock": "解鎖",
"command.checkout": "結帳",
"command.change": "找零",
"command.dispense": "出貨",
"command.reload_stock": "同步庫存",
"Clear Abnormal Status": "清除異常狀態",
"Are you sure you want to resolve all recent issues for this machine?": "您確定要排除此機台目前所有的異常紀錄嗎?這將會恢復機台狀態為正常。",
"All issues marked as resolved.": "所有異常已標記為已排除。",
"Failed to resolve issues.": "排除異常失敗。"
}

View File

@ -324,11 +324,11 @@
/* Small variants for filter bars */
.luxury-input-sm {
@apply py-2.5 text-sm !important;
@apply py-2 text-sm !important;
}
.luxury-select-sm .hs-select-toggle {
@apply py-2.5 text-sm !important;
@apply py-2 text-sm !important;
}
}
/* Flatpickr Luxury Theme Overrides */

View File

@ -8,6 +8,7 @@
showLogPanel: false,
showEditModal: false,
showInventoryPanel: false,
showResolveConfirm: false,
editMachineId: '',
editMachineName: '',
activeTab: 'status',
@ -131,6 +132,35 @@
finally { this.inventoryLoading = false; }
},
confirmResolve() {
this.showResolveConfirm = true;
},
async executeResolve() {
this.showResolveConfirm = false;
try {
const res = await fetch(`/admin/machines/${this.currentMachineId}/resolve-logs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const data = await res.json();
if (data.success) {
window.showToast(data.message || "{{ __('All issues marked as resolved.') }}", 'success');
this.fetchLogs(1);
// Refresh the machine list background to update the status bulb
this.fetchPage(window.location.href);
} else {
window.showToast(data.message || "{{ __('Failed to resolve issues.') }}", 'error');
}
} catch (e) {
console.error('resolveLogs error:', e);
}
},
formatDateTime(dateStr) {
if (!dateStr) return '--';
const d = new Date(dateStr);
@ -269,6 +299,14 @@
class="text-xs font-black text-emerald-600 dark:text-emerald-400 tracking-widest uppercase">{{
__('Online') }}</span>
</div>
@elseif($cStatus === 'warning')
<div class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-500/10 border border-amber-500/20 tooltip mx-auto w-fit"
title="{{ __('Recently reported warnings in logs') }}">
<div class="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
<span
class="text-xs font-black text-amber-600 dark:text-amber-400 tracking-widest uppercase">{{
__('Warning') }}</span>
</div>
@elseif($cStatus === 'error')
<div class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20 tooltip mx-auto w-fit"
title="{{ __('Recently reported errors or warnings in logs') }}">
@ -398,6 +436,8 @@
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</div>
@elseif($cStatus === 'warning')
<div class="h-2.5 w-2.5 rounded-full bg-amber-500 animate-pulse"></div>
@elseif($cStatus === 'error')
<div class="h-2.5 w-2.5 rounded-full bg-rose-500 animate-pulse"></div>
@else
@ -502,7 +542,7 @@
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="w-screen max-w-4xl">
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-200 dark:border-slate-800 rounded-l-[32px] overflow-hidden">
<!-- Header -->
<div
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
@ -593,7 +633,14 @@
</div>
</div>
</div>
<div class="flex justify-start sm:justify-end">
<div class="flex flex-wrap items-center justify-start sm:justify-end gap-4 sm:gap-6">
<button @click="confirmResolve()"
class="text-[12px] font-bold text-rose-500 dark:text-rose-400 uppercase tracking-widest hover:text-rose-600 transition-colors flex items-center gap-1.5 px-3 py-1.5 rounded-xl hover:bg-rose-500/5 transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{{ __('Clear Abnormal Status') }}
</button>
<button @click="startDate = ''; endDate = ''; fetchLogs(1)"
class="text-[12px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
@ -770,45 +817,47 @@
</div>
</template>
<!-- Pagination Footer -->
<div x-show="logs.length > 0"
class="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
</div>
</div>
</div>
<!-- Fixed Pagination Footer -->
<div x-show="logs.length > 0 && activeTab !== 'expiry'"
class="mt-auto border-t border-slate-100 dark:border-slate-800/80 px-6 py-4 flex items-center justify-between bg-slate-50/30 dark:bg-slate-900/30 backdrop-blur-sm rounded-bl-[32px]">
<div class="flex items-center gap-4">
<button @click="fetchLogs(currentPage - 1)" :disabled="currentPage <= 1"
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
<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.5" d="M15 19l-7-7 7-7" />
</svg>
<button @click="fetchLogs(currentPage - 1)"
:disabled="currentPage <= 1"
class="h-9 px-4 rounded-xl text-[11px] font-black uppercase tracking-widest transition-all shadow-sm border border-slate-200 dark:border-slate-700 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white dark:hover:bg-slate-800 flex items-center gap-2 text-slate-600 dark:text-slate-300">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M15 19l-7-7 7-7"/></svg>
{{ __('Previous') }}
</button>
<div class="flex items-center gap-1.5">
<span
class="text-[12px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest"
x-text="currentPage"></span>
<span
class="text-[12px] font-black text-slate-300 dark:text-slate-600 uppercase">/</span>
<span
class="text-[12px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest"
x-text="lastPage"></span>
<div class="relative group">
<select @change="fetchLogs(parseInt($event.target.value))"
class="h-9 pl-4 pr-10 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-[11px] font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 outline-none transition-all cursor-pointer shadow-sm hover:border-slate-300 dark:hover:border-slate-600 !bg-none">
<template x-for="p in Array.from({length: lastPage}, (_, i) => i + 1)">
<option :value="p" :selected="p === currentPage" x-text="p + ' / ' + lastPage"></option>
</template>
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-cyan-500/50 group-hover:text-cyan-500 transition-colors">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
<button @click="fetchLogs(currentPage + 1)"
:disabled="currentPage >= lastPage"
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
<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.5" d="M9 5l7 7-7 7" />
</svg>
class="h-9 px-4 rounded-xl text-[11px] font-black uppercase tracking-widest transition-all shadow-sm border border-slate-200 dark:border-slate-700 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white dark:hover:bg-slate-800 flex items-center gap-2 text-slate-600 dark:text-slate-300">
{{ __('Next') }}
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
</div>
</div>
<div class="text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em]">
<span class="text-slate-600 dark:text-slate-300" x-text="totalLogs"></span> {{ __('items') }}
</div>
</div>
</div><!-- /Body -->
@ -898,7 +947,7 @@
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="w-screen max-w-4xl">
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-200 dark:border-slate-800 rounded-l-[32px] overflow-hidden">
<!-- Header -->
<div
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
@ -1118,4 +1167,15 @@
</div>
</div><!-- /Inventory Offcanvas -->
<x-confirm-modal
alpineVar="showResolveConfirm"
confirmAction="executeResolve()"
iconType="warning"
title="{{ __('Clear Abnormal Status') }}"
message="{{ __('Are you sure you want to resolve all recent issues for this machine?') }}"
confirmText="{{ __('Confirm') }}"
cancelText="{{ __('Cancel') }}"
confirmColor="rose"
/>
@endsection

View File

@ -1,7 +1,9 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-4 pb-20" x-data="{
<div class="space-y-2 pb-20" x-data="{
activeTab: '{{ $tab ?? 'list' }}',
tabLoading: null,
showCreateModal: false,
showEditModal: false,
showDeleteModal: false,
@ -10,7 +12,6 @@
passCodeName: '',
expiresDays: 7,
customCode: '',
isLoadingTable: false,
status: '{{ request('status', '') }}',
showQrModal: false,
activeQrCode: '',
@ -52,23 +53,59 @@
}).replace(/\//g, '-');
},
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
async fetchTabData(tab, customUrl = null) {
if (this.tabLoading === tab) return;
this.tabLoading = tab;
const container = document.getElementById(`tab-${tab}-container`);
let url = customUrl;
if (!url) {
let params = new URLSearchParams();
params.set('tab', tab);
params.set('_ajax', '1');
if (container) {
const form = container.querySelector('form');
if (form) {
const formData = new FormData(form);
formData.forEach((value, key) => {
if (value.trim() !== '') params.append(key, value);
});
}
}
url = `${window.location.pathname}?${params.toString()}`;
} else {
const urlObj = new URL(url, window.location.origin);
urlObj.searchParams.set('tab', tab);
urlObj.searchParams.set('_ajax', '1');
url = urlObj.toString();
}
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();
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
if (container) {
container.innerHTML = data.html;
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
});
}
const historyUrl = new URL(url);
historyUrl.searchParams.delete('_ajax');
window.history.pushState({}, '', historyUrl.toString());
}
} catch (e) {
console.error('AJAX Load Failed:', e);
console.error(e);
window.showToast?.('{{ __("Loading failed") }}', 'error');
} finally {
this.isLoadingTable = false;
this.tabLoading = null;
}
},
@ -143,6 +180,15 @@
},
init() {
this.$watch('activeTab', (newTab) => {
const url = new URL(window.location.origin + window.location.pathname);
url.searchParams.set('tab', newTab);
window.history.pushState({}, '', url);
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();
});
});
this.$watch('showCreateModal', (val) => {
if (val) {
this.selectedMachine = '';
@ -153,7 +199,7 @@
}
});
}
} ">
} " @ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
{{-- Page Header --}}
<x-page-header :title="__('Pass Codes')"
:subtitle="__('Manage multi-use authorization codes for testing and maintenance')">
@ -165,310 +211,39 @@
</button>
</x-page-header>
{{-- Main Content Card --}}
{{-- Tabs --}}
<x-tab-nav model="activeTab">
<x-tab-nav-item value="list" :label="__('Pass Codes')" model="activeTab" />
<x-tab-nav-item value="logs" :label="__('Operation Records')" model="activeTab" />
</x-tab-nav>
{{-- Main Content --}}
<div class="mt-6">
<div class="relative min-h-[400px]">
{{-- List Tab --}}
<div x-show="activeTab === 'list'" class="relative min-h-[400px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="isLoadingTable" />
<div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }">
<form action="{{ route('admin.sales.pass-codes') }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())">
<div class="relative group flex-1 md:flex-none">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search by code, name or machine...') }}">
<x-luxury-spinner show="tabLoading === 'list'" />
<div id="tab-list-container" class="relative"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'list' }">
@include('admin.sales.pass-codes.partials.tab-list')
</div>
<div class="w-full md:w-48">
<x-searchable-select name="status" id="filter-status" :placeholder="__('All Status')"
x-model="status" @change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="active" {{ request('status')==='active' ? 'selected' : '' }}
data-title="{{ __('Active') }}">{{ __('Active') }}</option>
<option value="expired" {{ request('status')==='expired' ? 'selected' : '' }}
data-title="{{ __('Expired') }}">{{ __('Expired') }}</option>
<option value="disabled" {{ request('status')==='disabled' ? 'selected' : '' }}
data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select>
</div>
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0">
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all"
title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; status = ''; syncSelect('filter-status', ''); $el.closest('form').dispatchEvent(new Event('submit'))"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all"
title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</form>
{{-- Table (Desktop) --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Pass Code') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Name / Machine') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Expires At') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Status') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
@forelse ($passCodes as $code)
<tr
class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 whitespace-nowrap">
@if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture()))
<span
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest bg-cyan-50 dark:bg-cyan-500/10 px-4 py-1.5 rounded-xl border border-cyan-100 dark:border-cyan-500/20 shadow-sm cursor-pointer hover:bg-cyan-100 dark:hover:bg-cyan-500/20 transition-all">
{{ $code->code }}
</span>
@else
<span
class="text-lg font-black font-mono text-slate-400 dark:text-slate-600 tracking-widest bg-slate-50 dark:bg-slate-900/50 px-4 py-1.5 rounded-xl border border-slate-100 dark:border-slate-800/50 shadow-sm">
{{ $code->code }}
</span>
@endif
</td>
<td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$code->machine->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($code->expires_at)
<div
class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">
{{ $code->expires_at->format('Y-m-d H:i') }}</div>
<div
class="text-xs font-bold {{ $code->expires_at->isPast() ? 'text-rose-500' : 'text-slate-400' }} mt-0.5">
{{ $code->expires_at->isPast() ? __('Expired') : $code->expires_at->diffForHumans()
}}
</div>
@else
<span
class="text-xs font-black uppercase tracking-widest text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-lg border border-emerald-500/20">{{
__('Permanent') }}</span>
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$displayStatus = $code->status === 'active' && $code->expires_at?->isPast() ? 'expired'
: ($code->status === 'disabled' ? 'cancelled' : $code->status);
@endphp
<x-status-badge :status="$displayStatus" />
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($code->status === 'active' && (!$code->expires_at ||
$code->expires_at->isFuture()))
<button type="button" @click="openEditModal({
id: {{ $code->id }},
name: '{{ addslashes($code->name) }}',
expires_at: '{{ $code->expires_at ? $code->expires_at->format('Y/m/d H:i') : '' }}'
})"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300 border border-slate-100 dark:border-slate-800"
title="{{ __('Edit') }}">
<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.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
<button type="button"
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300 border border-slate-100 dark:border-slate-800"
title="{{ __('View QR') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
</button>
@endif
@if($code->status === 'active')
<form id="delete-form-desktop-{{ $code->id }}"
action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST">
@csrf
@method('DELETE')
<button type="button"
@click="confirmDelete('delete-form-desktop-{{ $code->id }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20"
title="{{ __('Cancel Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="5" :message="__('No pass codes found')" />
@endforelse
</tbody>
</table>
</div>
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($passCodes as $code)
<div
class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div
class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div class="min-w-0">
@if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture()))
<h3 @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $code->code }}
</h3>
@else
<h3
class="text-base font-extrabold text-slate-400 dark:text-slate-600 truncate tracking-tight">
{{ $code->code }}
</h3>
@endif
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->name }}
</p>
</div>
</div>
<div class="min-w-0">
@php
$displayStatusMobile = $code->status === 'active' && $code->expires_at?->isPast() ?
'expired' : ($code->status === 'disabled' ? 'cancelled' : $code->status);
@endphp
<x-status-badge :status="$displayStatusMobile" size="sm" />
</div>
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{
$code->machine->name }}</p>
</div>
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Expires At') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono tracking-tighter">
{{ $code->expires_at ? $code->expires_at->format('Y-m-d H:i') : __('Permanent') }}
</p>
{{-- Logs Tab --}}
<div x-show="activeTab === 'logs'" class="relative min-h-[400px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0" style="display: none;" x-cloak>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="tabLoading === 'logs'" />
<div id="tab-logs-container" class="relative"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'logs' }">
@include('admin.sales.pass-codes.partials.tab-logs')
</div>
</div>
{{-- Action Buttons --}}
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-3">
@if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture()))
<button type="button" @click="openEditModal({
id: {{ $code->id }},
name: '{{ addslashes($code->name) }}',
expires_at: '{{ $code->expires_at ? $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.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
{{ __('Edit') }}
</button>
<button type="button"
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
{{ __('QR Code') }}
</button>
@endif
@if($code->status === 'active')
<form id="delete-form-mobile-{{ $code->id }}"
action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST" class="flex-1">
@csrf
@method('DELETE')
<button type="button" @click="confirmDelete('delete-form-mobile-{{ $code->id }}')"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
{{ __('Cancel') }}
</button>
</form>
@endif
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pass codes found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $passCodes->links('vendor.pagination.luxury') }}
</div>
</div>
</div>
@ -547,9 +322,9 @@
</div>
<div class="space-y-4">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity
Period (Days)') }}</label>
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Validity Period (Days)') }}
</label>
<div
class="bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] p-6 border border-slate-100 dark:border-slate-800/50 shadow-sm">
<div class="flex items-center gap-6 mb-6">
@ -598,16 +373,14 @@
class="text-xl font-mono font-black text-slate-700 dark:text-slate-200 tracking-wider"
x-text="calculateExpiry()"></span>
</div>
<p
class="text-center mt-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
{{ __('Leave empty or 0 for permanent code') }}</p>
</div>
</div>
</div>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">{{
__('Cancel') }}</button>
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }}
</button>
<button type="submit" class="btn-luxury-primary px-12">
{{ __('Create') }}
</button>
@ -617,36 +390,26 @@
</div>
</div>
{{-- Confirm Cancel Modal --}}
<x-confirm-modal alpineVar="showDeleteModal" :title="__('Cancel Pass Code')"
:message="__('Are you sure you want to cancel this pass code? This action cannot be undone.')"
:confirmText="__('Yes, Cancel')" confirmAction="submitDelete()" confirmColor="rose" iconType="danger" />
{{-- QR Code Modal --}}
<div x-show="showQrModal" class="fixed inset-0 z-[60] overflow-y-auto"
{{-- Edit Modal --}}
<div x-show="showEditModal" class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showQrModal = false">
</div>
<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="showEditModal = false"></div>
<div x-show="showQrModal" x-transition:enter="ease-out duration-300"
<div x-show="showEditModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="inline-block w-full max-w-sm transition-all transform luxury-card rounded-[2.5rem] bg-white dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl overflow-hidden">
<div class="p-8 pb-0 flex justify-between items-start">
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{
__('Pass Code QR') }}</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1"
x-text="activeQrCode"></p>
</div>
<button @click="showQrModal = false"
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
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-xl 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">{{
__('Edit Pass Code') }}</h3>
<button @click="showEditModal = 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" />
@ -654,6 +417,69 @@
</button>
</div>
<form :action="editFormAction" method="POST" class="space-y-6">
@csrf
@method('PATCH')
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Description / Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" x-model="editPassCodeName" class="luxury-input w-full"
placeholder="{{ __('e.g. Test Code for Maintenance') }}" required>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Expires At') }}
</label>
<x-luxury-datetime-input name="expires_at" :value="''"
:placeholder="__('Permanent if empty')" />
</div>
<div class="flex gap-4 pt-4">
<button type="button" @click="showEditModal = false"
class="flex-1 px-8 py-4 rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 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] px-8 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-95">
{{ __('Update Pass Code') }}
</button>
</div>
</form>
</div>
</div>
</div>
{{-- QR Modal --}}
<div x-show="showQrModal" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity" @click="showQrModal = false">
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
</div>
<div
class="relative bg-white dark:bg-slate-900 rounded-[2.5rem] shadow-2xl border border-slate-100 dark:border-slate-800 w-full max-w-sm overflow-hidden animate-luxury-in">
<div
class="px-8 py-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('QR Code') }}
</h3>
<p class="text-xs font-bold text-cyan-600 dark:text-cyan-400 mt-1 uppercase tracking-widest">
{{ __('Pass Code') }}: <span class="font-mono" x-text="activeQrCode"></span>
</p>
</div>
<button @click="showQrModal = false" class="text-slate-400 hover:text-slate-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-10 flex flex-col items-center gap-6">
<div class="p-4 bg-white rounded-3xl shadow-xl border border-slate-100">
<x-qr-code data="activeQrCode" size="200" class="w-48 h-48" />
@ -661,7 +487,7 @@
<div class="text-center w-full space-y-4">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 leading-relaxed px-4">
{{ __('Scan this code at the machine to authorize testing or maintenance.') }}
{{ __('Scan this code at the machine or share the link with the maintenance staff.') }}
</p>
<div class="flex items-center justify-center gap-3">
@ -671,7 +497,7 @@
title="{{ __('Copy Link') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 006.364 6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
<span class="text-xs font-black uppercase tracking-widest">{{ __('Link') }}</span>
</button>
@ -710,82 +536,9 @@
</div>
</div>
{{-- Edit Modal --}}
<div x-show="showEditModal" class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="showEditModal = false"></div>
<div x-show="showEditModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-xl 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">{{
__('Edit Pass Code') }}</h3>
<button @click="showEditModal = 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="editFormAction" method="POST" class="space-y-6">
@csrf
@method('PATCH')
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Description / Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" x-model="editPassCodeName" class="luxury-input w-full"
placeholder="{{ __('e.g. Test Code for Maintenance') }}" required>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Expires
At') }}</label>
<x-luxury-datetime-input name="expires_at" :placeholder="__('Permanent')"
x-model="editExpiresAt" />
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest pl-1 mt-1">{{ __('Leave
empty for permanent code') }}</p>
</div>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showEditModal = false" class="btn-luxury-ghost px-8">{{
__('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-12">
{{ __('Update') }}
</button>
</div>
</form>
</div>
</div>
</div>
{{-- Delete Modal --}}
<x-confirm-modal alpineVar="showDeleteModal" confirmAction="submitDelete()" :title="__('Confirm Deactivation')"
:message="__('Are you sure you want to deactivate this pass code? It will no longer be usable for authentication.')"
:confirmText="__('Yes, Deactivate')" :cancelText="__('Cancel')" iconType="danger" confirmColor="rose" />
</div>
@endsection
@push('styles')
<style>
/* 移除 Chrome, Safari, Edge, Opera 的原生加減按鈕 */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
/* 移除 Firefox 的原生加減按鈕 */
input[type=number] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>
@endpush

View File

@ -0,0 +1,300 @@
<form action="{{ route('admin.sales.pass-codes') }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@submit.prevent="fetchTabData('list')">
<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" x2="16.65" y2="16.65" />
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search by code, name or machine...') }}">
</div>
<div class="w-full md:w-48">
<x-searchable-select name="status" id="filter-status" :placeholder="__('All Status')"
:selected="request('status')"
@change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="active" {{ request('status')==='active' ? 'selected' : '' }}
data-title="{{ __('Active') }}">{{ __('Active') }}</option>
<option value="expired" {{ request('status')==='expired' ? 'selected' : '' }}
data-title="{{ __('Expired') }}">{{ __('Expired') }}</option>
<option value="disabled" {{ request('status')==='disabled' ? 'selected' : '' }}
data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select>
</div>
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0">
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all"
title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; syncSelect('filter-status', ''); $el.closest('form').dispatchEvent(new Event('submit'))"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all"
title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</form>
{{-- Table (Desktop) --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Pass Code') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Name / Machine') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Expires At') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Status') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
@forelse ($passCodes as $code)
<tr
class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 whitespace-nowrap">
@if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture()))
<span
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest bg-cyan-50 dark:bg-cyan-500/10 px-4 py-1.5 rounded-xl border border-cyan-100 dark:border-cyan-500/20 shadow-sm cursor-pointer hover:bg-cyan-100 dark:hover:bg-cyan-500/20 transition-all">
{{ $code->code }}
</span>
@else
<span
class="text-lg font-black font-mono text-slate-400 dark:text-slate-600 tracking-widest bg-slate-50 dark:bg-slate-900/50 px-4 py-1.5 rounded-xl border border-slate-100 dark:border-slate-800/50 shadow-sm">
{{ $code->code }}
</span>
@endif
</td>
<td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$code->machine->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($code->expires_at)
<div
class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">
{{ $code->expires_at->format('Y-m-d H:i') }}</div>
<div
class="text-xs font-bold {{ $code->expires_at->isPast() ? 'text-rose-500' : 'text-slate-400' }} mt-0.5">
{{ $code->expires_at->isPast() ? __('Expired') : $code->expires_at->diffForHumans()
}}
</div>
@else
<span
class="text-xs font-black uppercase tracking-widest text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-lg border border-emerald-500/20">{{
__('Permanent') }}</span>
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$displayStatus = $code->status === 'active' && $code->expires_at?->isPast() ? 'expired'
: ($code->status === 'disabled' ? 'cancelled' : $code->status);
@endphp
<x-status-badge :status="$displayStatus" />
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($code->status === 'active' && (!$code->expires_at ||
$code->expires_at->isFuture()))
<button type="button" @click="openEditModal({
id: {{ $code->id }},
name: '{{ addslashes($code->name) }}',
expires_at: '{{ $code->expires_at ? $code->expires_at->format('Y/m/d H:i') : '' }}'
})"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300 border border-slate-100 dark:border-slate-800"
title="{{ __('Edit') }}">
<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.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
<button type="button"
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300 border border-slate-100 dark:border-slate-800"
title="{{ __('View QR') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
</button>
@endif
@if($code->status === 'active')
<form id="delete-form-desktop-{{ $code->id }}"
action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST">
@csrf
@method('DELETE')
<button type="button"
@click="confirmDelete('delete-form-desktop-{{ $code->id }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20"
title="{{ __('Cancel Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="5" :message="__('No pass codes found')" />
@endforelse
</tbody>
</table>
</div>
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($passCodes as $code)
<div
class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div
class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div class="min-w-0">
@if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture()))
<h3 @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $code->code }}
</h3>
@else
<h3
class="text-base font-extrabold text-slate-400 dark:text-slate-600 truncate tracking-tight">
{{ $code->code }}
</h3>
@endif
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->name }}
</p>
</div>
</div>
<div class="min-w-0">
@php
$displayStatusMobile = $code->status === 'active' && $code->expires_at?->isPast() ?
'expired' : ($code->status === 'disabled' ? 'cancelled' : $code->status);
@endphp
<x-status-badge :status="$displayStatusMobile" size="sm" />
</div>
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{
$code->machine->name }}</p>
</div>
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Expires At') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono tracking-tighter">
{{ $code->expires_at ? $code->expires_at->format('Y-m-d H:i') : __('Permanent') }}
</p>
</div>
</div>
{{-- Action Buttons --}}
<div class="mt-6 pt-6 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-3">
@if($code->status === 'active' && (!$code->expires_at || $code->expires_at->isFuture()))
<button type="button" @click="openEditModal({
id: {{ $code->id }},
name: '{{ addslashes($code->name) }}',
expires_at: '{{ $code->expires_at ? $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.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
{{ __('Edit') }}
</button>
<button type="button"
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
{{ __('QR Code') }}
</button>
@endif
@if($code->status === 'active')
<form id="delete-form-mobile-{{ $code->id }}"
action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST" class="flex-1">
@csrf
@method('DELETE')
<button type="button" @click="confirmDelete('delete-form-mobile-{{ $code->id }}')"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
{{ __('Cancel') }}
</button>
</form>
@endif
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pass codes found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $passCodes->links('vendor.pagination.luxury') }}
</div>

View File

@ -0,0 +1,289 @@
{{-- Filter Form --}}
<form action="{{ route('admin.sales.pass-codes') }}" method="GET"
class="flex flex-wrap items-center gap-3 sm:gap-4 mb-8" @submit.prevent="fetchTabData('logs')">
<input type="hidden" name="tab" value="logs">
{{-- 搜尋關鍵字 --}}
<div class="relative group w-full sm:w-64 sm:flex-none">
<span
class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4 stroke-[2.5]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search_log" value="{{ request('search_log') }}"
class="py-2.5 pl-12 pr-6 block w-full luxury-input text-sm font-bold"
placeholder="{{ __('Search logs...') }}">
</div>
{{-- 類型篩選 --}}
<div class="w-full sm:w-64">
<x-searchable-select
name="action"
:selected="request('action')"
:placeholder="__('All Types')"
:hasSearch="false"
>
@foreach($actions as $key => $label)
<option value="{{ $key }}" {{ request('action') === $key ? 'selected' : '' }} data-title="{{ $label }}">
{{ $label }}
</option>
@endforeach
</x-searchable-select>
</div>
{{-- 日期範圍 --}}
<div class="relative group w-full sm:w-72 sm:flex-none"
x-data="{ fp: null }"
x-init="fp = flatpickr($refs.dateRange, {
mode: 'range',
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
defaultHour: 0,
defaultMinute: 0,
locale: '{{ app()->getLocale() == 'zh_TW' ? 'zh_tw' : 'en' }}',
defaultDate: '{{ request('start_date') }}' && '{{ request('end_date') }}' ? ['{{ request('start_date') }}', '{{ request('end_date') }}'] : [],
onChange: function(selectedDates, dateStr, instance) {
if (selectedDates.length === 2) {
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
} else if (selectedDates.length === 0) {
$refs.startDate.value = '';
$refs.endDate.value = '';
}
}
})">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<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="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="hidden" name="start_date" x-ref="startDate" value="{{ request('start_date') }}">
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
<input type="text" x-ref="dateRange"
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full text-sm font-bold cursor-pointer">
</div>
{{-- 按鈕組 --}}
<div class="flex items-center gap-2 ml-auto sm:ml-0">
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95"
title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button" @click="
$el.closest('form').querySelectorAll('input[type=text], input[type=hidden]:not([name=tab])').forEach(i => i.value = '');
$el.closest('form').querySelectorAll('select').forEach(s => {
s.value = ' ';
const instance = window.HSSelect.getInstance(s);
if (instance) instance.setValue(' ');
});
fetchTabData('logs', '{{ route('admin.sales.pass-codes') }}?tab=logs');
"
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 active:scale-95"
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>
{{-- Desktop Table Mode --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Action / Target') }}</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">
{{ __('Action') }}</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">
{{ __('Pass Code') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Time') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Operator') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($logs as $log)
<tr
class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-5">
<div class="flex items-center gap-x-4">
<div
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 group-hover:bg-indigo-500 group-hover:text-white transition-all duration-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div class="flex flex-col">
<span
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{{ __('Pass Code') }}
</span>
<span
class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide mt-0.5">
ID: {{ $log->target_id }}
</span>
</div>
</div>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
@php
$actionColor = match($log->action) {
'create' => 'cyan',
'cancel' => 'rose',
'update' => 'indigo',
'verify_success' => 'emerald',
default => 'slate'
};
@endphp
<span
class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-{{ $actionColor }}-500/10 text-{{ $actionColor }}-500 border border-{{ $actionColor }}-500/20 tracking-widest uppercase shadow-sm shadow-{{ $actionColor }}-500/5">
{{ __($log->action) }}
</span>
</td>
<td
class="px-6 py-5 text-center font-mono font-extrabold text-slate-700 dark:text-slate-200">
@if($log->new_values && isset($log->new_values['code']))
<span
class="px-2 py-1 rounded-lg bg-slate-100 dark:bg-slate-800/50 border border-slate-200/50 dark:border-slate-700/50 tracking-tighter">
{{ $log->new_values['code'] }}
</span>
@else
<span class="text-slate-300 dark:text-slate-700">-</span>
@endif
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<span
class="text-xs font-mono font-black text-slate-700 dark:text-slate-200 tracking-widest">
{{ $log->created_at?->format('Y-m-d H:i:s') }}
</span>
</td>
<td class="px-6 py-5 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<div
class="w-7 h-7 rounded-full bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center text-indigo-600 dark:text-indigo-400 text-[10px] font-black border border-indigo-200/50 dark:border-indigo-800/50">
{{ Str::substr($log->user->name ?? 'S', 0, 1) }}
</div>
<span
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
{{ $log->user->name ?? __('System') }}
</span>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-20 text-center">
<div class="flex flex-col items-center justify-center gap-3">
<div class="p-4 rounded-3xl bg-slate-50 dark:bg-slate-800/50">
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="1.5"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<p class="text-slate-400 font-bold uppercase tracking-widest text-xs">{{
__('No records found') }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Card Mode (Mobile & Tablet) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-4">
@forelse($logs as $log)
<div
class="luxury-card p-5 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
<div class="flex items-start justify-between gap-4 mb-5">
<div class="flex items-center gap-3 min-w-0">
<div
class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-indigo-500 group-hover:text-white transition-all duration-300 shadow-sm shrink-0">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-indigo-600 transition-colors tracking-tight">
{{ __('Pass Code') }}</h3>
<p
class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest truncate mt-0.5">
ID: {{ $log->target_id }}</p>
</div>
</div>
<div class="shrink-0">
@php
$actionColor = match($log->action) {
'create' => 'cyan',
'cancel' => 'rose',
'update' => 'indigo',
'verify_success' => 'emerald',
default => 'slate'
};
@endphp
<span
class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-{{ $actionColor }}-500/10 text-{{ $actionColor }}-500 border border-{{ $actionColor }}-500/20 uppercase tracking-widest shadow-sm">
{{ __($log->action) }}
</span>
</div>
</div>
<div
class="grid grid-cols-2 gap-y-4 mb-4 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Operator') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{
$log->user->name ?? __('System') }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Pass Code') }}</p>
<p class="text-sm font-mono font-black text-slate-800 dark:text-slate-100">
{{ $log->new_values['code'] ?? '-' }}
</p>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Time') }}</p>
<p
class="text-sm font-mono font-bold text-slate-800 dark:text-slate-100 tracking-tighter">
{{ $log->created_at?->format('Y-m-d H:i:s') }}</p>
</div>
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No records found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $logs->links('vendor.pagination.luxury') }}
</div>

View File

@ -6,18 +6,36 @@ $slotSelectConfig = [
"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",
"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>'
"toggleTemplate" => '<button type="button" aria-expanded="false"><span class="me-2" data-icon></span><span
class="text-slate-800 dark:text-slate-200" data-title></span>
<div class="ms-auto"><svg class="size-4 text-slate-400 transition-transform duration-300"
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
</svg></div>
</button>',
"dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10
rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[150] animate-luxury-in",
"optionClasses" => "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer
hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between
transition-all duration-300",
"optionTemplate" => '<div class="flex items-center justify-between w-full"><span data-title></span><span
class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg"
width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg></span></div>'
];
@endphp
@section('content')
<div class="space-y-4 pb-20" x-data="pickupCodeManager(@js($slotSelectConfig))">
<div class="space-y-2 pb-20" x-data="pickupCodeManager(@js($slotSelectConfig))"
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
@ -34,342 +52,43 @@ $slotSelectConfig = [
</x-page-header>
{{-- Tab Navigation --}}
<div class="mb-4">
<x-tab-nav model="activeTab">
<x-tab-nav-item value="list" :label="__('Pickup Codes')" model="activeTab" />
<x-tab-nav-item value="logs" :label="__('Operation Records')" model="activeTab" />
</x-tab-nav>
</div>
{{-- Content Containers --}}
{{-- Tab Contents --}}
<div class="mt-6">
<div class="relative min-h-[400px]">
{{-- List Tab --}}
<div x-show="activeTab === 'list'" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4" x-transition:enter-end="opacity-100 translate-y-0"
style="display: none;">
{{-- Main Content Card --}}
<div x-show="activeTab === 'list'" class="relative min-h-[400px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="isLoadingTable" />
<div id="tab-list-container"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': isLoadingTable }"
@ajax:navigate.prevent.stop="fetchPage($event.detail.url)"
@click="if ($event.target.closest('a') && $event.target.closest('a').href && ($event.target.closest('a').href.includes('page=') || $event.target.closest('a').href.includes('per_page='))) { $event.preventDefault(); fetchPage($event.target.closest('a').href); }">
<form action="{{ route('admin.sales.pickup-codes') }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())">
<div class="relative group flex-1 md:flex-none">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="w-4 h-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors stroke-[2.5]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search by code, machine name or serial...') }}">
</div>
<div class="w-full md:w-48">
<x-searchable-select name="status" id="filter-status" :placeholder="__('All Status')"
:selected="request('status')" :hasSearch="false"
@change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="active" {{ request('status')==='active' ? 'selected' : '' }}
data-title="{{ __('Active') }}">{{ __('Active') }}</option>
<option value="used" {{ request('status')==='used' ? 'selected' : '' }}
data-title="{{ __('Used') }}">{{ __('Used') }}</option>
<option value="expired" {{ request('status')==='expired' ? 'selected' : '' }}
data-title="{{ __('Expired') }}">{{ __('Expired') }}</option>
<option value="cancelled" {{ request('status')==='cancelled' ? 'selected' : '' }}
data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select>
</div>
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0">
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95"
title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; syncSelect('filter-status', ''); $el.closest('form').dispatchEvent(new Event('submit'))"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95 border border-slate-200 dark:border-slate-700"
title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</form>
{{-- Table (Desktop) --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Code') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Machine') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Product / Slot') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Expiry') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Status') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
@forelse ($pickupCodes as $code)
<tr
class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 whitespace-nowrap">
@if($code->status === 'active' && $code->expires_at->isFuture())
<span
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest bg-cyan-50 dark:bg-cyan-500/10 px-4 py-1.5 rounded-xl border border-cyan-100 dark:border-cyan-500/20 shadow-sm cursor-pointer hover:bg-cyan-100 dark:hover:bg-cyan-500/20 transition-all">
{{ $code->code }}
</span>
@else
<span
class="text-lg font-black font-mono text-slate-400 dark:text-slate-600 tracking-widest bg-slate-50 dark:bg-slate-900/50 px-4 py-1.5 rounded-xl border border-slate-100 dark:border-slate-800/50 shadow-sm">
{{ $code->code }}
</span>
@endif
</td>
<td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->machine->name }}</div>
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">{{
$code->machine->serial_no }}</div>
</td>
<td class="px-6 py-6">
@php
$slot = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
@endphp
<div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{ $productName
}}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Slot')
}}: {{ $code->slot_no }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300">
{{ $code->expires_at->format('Y-m-d H:i') }}
</div>
<div class="text-[10px] font-bold text-slate-400 mt-0.5">
{{ $code->expires_at->diffForHumans() }}
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? 'expired' :
$code->status;
@endphp
<x-status-badge :status="$displayStatus" />
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($code->status === 'active' && $code->expires_at->isFuture())
{{-- View QR Code Button --}}
<button
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-transparent hover:border-emerald-500/20 transition-all inline-flex group/btn"
title="{{ __('View QR Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
</button>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
<form id="cancel-form-desktop-{{ $code->id }}"
action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST">
@csrf
@method('DELETE')
<button type="button"
@click="confirmCancel('cancel-form-desktop-{{ $code->id }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20"
title="{{ __('Cancel Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="5" :message="__('No pickup codes found')" />
@endforelse
</tbody>
</table>
</div>
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($pickupCodes as $code)
<div
class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div
class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<div class="min-w-0">
@if($code->status === 'active' && $code->expires_at->isFuture())
<h3 @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $code->code }}
</h3>
@else
<h3
class="text-base font-extrabold text-slate-400 dark:text-slate-600 truncate tracking-tight">
{{ $code->code }}
</h3>
@endif
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->machine->name }}
</p>
<x-luxury-spinner show="tabLoading === 'list'" />
<div id="tab-list-container" class="relative"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'list' }">
@include('admin.sales.pickup-codes.partials.tab-list')
</div>
</div>
@php
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? 'expired' :
$code->status;
@endphp
<x-status-badge :status="$displayStatus" size="sm" />
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Slot') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $code->slot_no }}
@php
$slotMobile = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) : __('Empty');
@endphp
<span class="ml-1 text-cyan-600 dark:text-cyan-400">({{ $prodMobile }})</span>
</p>
</div>
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Expiry') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $code->expires_at->format('m-d H:i') }}
<span class="text-[10px] text-slate-400 ml-1">{{ $code->expires_at->diffForHumans()
}}</span>
</p>
</div>
</div>
{{-- Action Buttons --}}
<div class="flex items-center gap-3">
@if($code->status === 'active' && $code->expires_at->isFuture())
{{-- QR Button (Mobile) --}}
<button
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
{{ __('QR Code') }}
</button>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
<form id="cancel-form-mobile-{{ $code->id }}"
action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST"
class="flex-1">
@csrf
@method('DELETE')
<button type="button" @click="confirmCancel('cancel-form-mobile-{{ $code->id }}')"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
{{ __('Cancel') }}
</button>
</form>
@endif
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pickup codes found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $pickupCodes->links('vendor.pagination.luxury') }}
</div>
</div> {{-- End tab-list-container --}}
</div> {{-- End luxury-card --}}
</div>
{{-- End List Tab --}}
{{-- Logs Tab --}}
<div x-show="activeTab === 'logs'" class="relative"
<div x-show="activeTab === 'logs'" class="relative min-h-[400px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0" style="display: none;">
{{-- Main Content Card --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="tabLoading === 'logs'" />
<div id="tab-logs-container" class="relative">
<div id="tab-logs-container" class="relative"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'logs' }">
@include('admin.sales.pickup-codes.partials.tab-logs')
</div>
</div>
</div>
{{-- End Logs Tab --}}
</div>
</div> {{-- End relative min-h --}}
</div> {{-- End mt-6 --}}
{{-- Create Modal --}}
<div x-show="showCreateModal" class="fixed inset-0 z-50 overflow-y-auto"
@ -451,8 +170,7 @@ $slotSelectConfig = [
{{-- Validity Period Section (Compact) --}}
<div class="space-y-4">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity
Period') }}</label>
<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">
@ -474,10 +192,8 @@ $slotSelectConfig = [
<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-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>
</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">
@ -610,8 +326,8 @@ $slotSelectConfig = [
activeQrCode: '',
activeTicketUrl: '',
slots: [],
tabLoading: null,
loadingSlots: false,
isLoadingTable: false,
showCancelModal: false,
cancelTargetForm: null,
slotSelectConfig: config,
@ -619,17 +335,32 @@ $slotSelectConfig = [
async fetchTabData(tab, containerSelector) {
async fetchTabData(tab, url = null) {
if (this.tabLoading === tab) return;
this.tabLoading = tab;
const container = document.getElementById('tab-' + tab + '-container');
const container = document.querySelector(containerSelector);
let url = '{{ route("admin.sales.pickup-codes") }}';
if (!url) {
let params = new URLSearchParams();
params.set('tab', tab);
params.set('_ajax', '1');
if (container) {
const form = container.querySelector('form');
if (form) {
const formData = new FormData(form);
formData.forEach((value, key) => {
if (value.trim() !== '') params.append(key, value);
});
}
}
url = `${window.location.pathname}?${params.toString()}`;
} else {
const urlObj = new URL(url, window.location.origin);
urlObj.searchParams.set('tab', tab);
urlObj.searchParams.set('_ajax', '1');
url = urlObj.toString();
}
try {
const response = await fetch(url, {
@ -706,25 +437,7 @@ $slotSelectConfig = [
this.$el.submit();
},
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
window.history.pushState({}, '', url);
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
}
} catch (e) {
console.error('AJAX Load Failed:', e);
} finally {
this.isLoadingTable = false;
}
},
copyQrLink() {
if (!this.activeTicketUrl) return;
@ -841,9 +554,8 @@ $slotSelectConfig = [
url.searchParams.set('tab', newTab);
window.history.pushState({}, '', url);
if (newTab === 'logs') {
this.fetchTabData('logs', '#tab-logs-container');
}
// Tab data is pre-rendered; refresh only via filters/pagination or specific actions
this.$nextTick(() => {
if (window.HSStaticMethods) window.HSStaticMethods.autoInit();

View File

@ -0,0 +1,310 @@
<form action="{{ route('admin.sales.pickup-codes') }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@submit.prevent="fetchTabData('list')">
<div class="relative group flex-1 md:flex-none">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="w-4 h-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors stroke-[2.5]"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search by code, machine name or serial...') }}">
</div>
<div class="w-full md:w-48">
<x-searchable-select name="status" id="filter-status" :placeholder="__('All Status')"
:selected="request('status')" :hasSearch="false"
@change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="active" {{ request('status')==='active' ? 'selected' : '' }}
data-title="{{ __('Active') }}">{{ __('Active') }}</option>
<option value="used" {{ request('status')==='used' ? 'selected' : '' }}
data-title="{{ __('Used') }}">{{ __('Used') }}</option>
<option value="expired" {{ request('status')==='expired' ? 'selected' : '' }}
data-title="{{ __('Expired') }}">{{ __('Expired') }}</option>
<option value="cancelled" {{ request('status')==='cancelled' ? 'selected' : '' }}
data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
</x-searchable-select>
</div>
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0">
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95"
title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; syncSelect('filter-status', ''); $el.closest('form').dispatchEvent(new Event('submit'))"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95 border border-slate-200 dark:border-slate-700"
title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</form>
{{-- Table (Desktop) --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Code') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Machine') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Product / Slot') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Expiry') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Status') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
@forelse ($pickupCodes as $code)
<tr
class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 whitespace-nowrap">
@if($code->status === 'active' && $code->expires_at->isFuture())
<span
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest bg-cyan-50 dark:bg-cyan-500/10 px-4 py-1.5 rounded-xl border border-cyan-100 dark:border-cyan-500/20 shadow-sm cursor-pointer hover:bg-cyan-100 dark:hover:bg-cyan-500/20 transition-all">
{{ $code->code }}
</span>
@else
<span
class="text-lg font-black font-mono text-slate-400 dark:text-slate-600 tracking-widest bg-slate-50 dark:bg-slate-900/50 px-4 py-1.5 rounded-xl border border-slate-100 dark:border-slate-800/50 shadow-sm">
{{ $code->code }}
</span>
@endif
</td>
<td class="px-6 py-6">
<div
class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->machine->name }}</div>
<div
class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">
{{
$code->machine->serial_no }}</div>
</td>
<td class="px-6 py-6">
@php
$slot = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
@endphp
<div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{
$productName
}}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
__('Slot')
}}: {{ $code->slot_no }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300">
{{ $code->expires_at->format('Y-m-d H:i') }}
</div>
<div class="text-[10px] font-bold text-slate-400 mt-0.5">
{{ $code->expires_at->diffForHumans() }}
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ?
'expired' :
$code->status;
@endphp
<x-status-badge :status="$displayStatus" />
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($code->status === 'active' && $code->expires_at->isFuture())
{{-- View QR Code Button --}}
<button
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-transparent hover:border-emerald-500/20 transition-all inline-flex group/btn"
title="{{ __('View QR Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
</button>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
<form id="cancel-form-desktop-{{ $code->id }}"
action="{{ route('admin.sales.pickup-codes.destroy', $code) }}"
method="POST">
@csrf
@method('DELETE')
<button type="button"
@click="confirmCancel('cancel-form-desktop-{{ $code->id }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20"
title="{{ __('Cancel Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="5" :message="__('No pickup codes found')" />
@endforelse
</tbody>
</table>
</div>
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($pickupCodes as $code)
<div
class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div
class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<div class="min-w-0">
@if($code->status === 'active' && $code->expires_at->isFuture())
<h3 @click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $code->code }}
</h3>
@else
<h3
class="text-base font-extrabold text-slate-400 dark:text-slate-600 truncate tracking-tight">
{{ $code->code }}
</h3>
@endif
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->machine->name }}
</p>
</div>
</div>
@php
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ?
'expired' :
$code->status;
@endphp
<x-status-badge :status="$displayStatus" size="sm" />
</div>
{{-- Info Grid --}}
<div
class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Slot') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $code->slot_no }}
@php
$slotMobile = $code->machine->slots->where('slot_no',
$code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) :
__('Empty');
@endphp
<span class="ml-1 text-cyan-600 dark:text-cyan-400">({{ $prodMobile
}})</span>
</p>
</div>
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Expiry') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $code->expires_at->format('m-d H:i') }}
<span class="text-[10px] text-slate-400 ml-1">{{
$code->expires_at->diffForHumans()
}}</span>
</p>
</div>
</div>
{{-- Action Buttons --}}
<div class="flex items-center gap-3">
@if($code->status === 'active' && $code->expires_at->isFuture())
{{-- QR Button (Mobile) --}}
<button
@click="activeQrCode = '{{ $code->code }}'; activeTicketUrl = '{{ $code->ticket_url }}'; showQrModal = true"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
</svg>
{{ __('QR Code') }}
</button>
@endif
@if($code->status === 'active' && $code->expires_at->isFuture())
<form id="cancel-form-mobile-{{ $code->id }}"
action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST"
class="flex-1">
@csrf
@method('DELETE')
<button type="button"
@click="confirmCancel('cancel-form-mobile-{{ $code->id }}')"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38" />
</svg>
{{ __('Cancel') }}
</button>
</form>
@endif
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pickup codes found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $pickupCodes->links('vendor.pagination.luxury') }}
</div>

View File

@ -1,98 +1,288 @@
<div class="overflow-hidden bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200 dark:border-slate-800 shadow-sm rounded-xl">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-800">
<thead class="bg-slate-50/50 dark:bg-slate-800/50">
<tr>
<th scope="col" class="px-6 py-4 text-left text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ __('Time') }}
</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ __('Operator') }}
</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ __('Actions') }}
</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ __('Target') }}
</th>
<th scope="col" class="px-6 py-4 text-left text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
{{ __('Notes') }}
</th>
{{-- Filter Form (Simplified to match Inventory style) --}}
<form action="{{ route('admin.sales.pickup-codes') }}" method="GET"
class="flex flex-wrap items-center gap-3 sm:gap-4 mb-8" @submit.prevent="fetchTabData('logs')">
<input type="hidden" name="tab" value="logs">
{{-- 搜尋關鍵字 --}}
<div class="relative group w-full sm:w-64 sm:flex-none">
<span
class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-4 h-4 stroke-[2.5]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search_log" value="{{ request('search_log') }}"
class="py-2.5 pl-12 pr-6 block w-full luxury-input text-sm font-bold"
placeholder="{{ __('Search logs...') }}">
</div>
{{-- 類型篩選 --}}
<div class="w-full sm:w-64">
<x-searchable-select
name="action"
:selected="request('action')"
:placeholder="__('All Types')"
:hasSearch="false"
>
@foreach($actions as $key => $label)
<option value="{{ $key }}" {{ request('action') === $key ? 'selected' : '' }} data-title="{{ $label }}">
{{ $label }}
</option>
@endforeach
</x-searchable-select>
</div>
{{-- 日期範圍 --}}
<div class="relative group w-full sm:w-72 sm:flex-none"
x-data="{ fp: null }"
x-init="fp = flatpickr($refs.dateRange, {
mode: 'range',
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
defaultHour: 0,
defaultMinute: 0,
locale: '{{ app()->getLocale() == 'zh_TW' ? 'zh_tw' : 'en' }}',
defaultDate: '{{ request('start_date') }}' && '{{ request('end_date') }}' ? ['{{ request('start_date') }}', '{{ request('end_date') }}'] : [],
onChange: function(selectedDates, dateStr, instance) {
if (selectedDates.length === 2) {
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
} else if (selectedDates.length === 0) {
$refs.startDate.value = '';
$refs.endDate.value = '';
}
}
})">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<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="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="hidden" name="start_date" x-ref="startDate" value="{{ request('start_date') }}">
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
<input type="text" x-ref="dateRange"
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full text-sm font-bold cursor-pointer">
</div>
{{-- 按鈕組 --}}
<div class="flex items-center gap-2 ml-auto sm:ml-0">
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95"
title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button type="button" @click="
$el.closest('form').querySelectorAll('input[type=text], input[type=hidden]:not([name=tab])').forEach(i => i.value = '');
$el.closest('form').querySelectorAll('select').forEach(s => {
s.value = ' ';
const instance = window.HSSelect.getInstance(s);
if (instance) instance.setValue(' ');
});
fetchTabData('logs', '{{ route('admin.sales.pickup-codes') }}?tab=logs');
"
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 active:scale-95"
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>
{{-- Desktop Table Mode --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Action / Target') }}</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">
{{ __('Action') }}</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">
{{ __('Pickup Code') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Time') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Operator') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-800">
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($logs as $log)
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/50 transition-colors duration-200">
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-600 dark:text-slate-300">
{{ $log->created_at->format('Y-m-d H:i:s') }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center text-indigo-600 dark:text-indigo-400 text-xs font-medium">
{{ Str::substr($log->user->name ?? 'Sys', 0, 1) }}
<tr
class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-5">
<div class="flex items-center gap-x-4">
<div
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">
<div class="flex flex-col">
<span
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
{{ __('Pickup Code') }}
</span>
<span
class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide mt-0.5">
ID: {{ $log->target_id }}
</span>
</div>
</div>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
@php
$actionColor = match($log->action) {
'create' => 'cyan',
'cancel', 'consume_failed' => 'rose',
'used', 'consume' => 'emerald',
'verify_success' => 'indigo',
default => 'slate'
};
@endphp
<span
class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-{{ $actionColor }}-500/10 text-{{ $actionColor }}-500 border border-{{ $actionColor }}-500/20 tracking-widest uppercase shadow-sm shadow-{{ $actionColor }}-500/5">
{{ __($log->action) }}
</span>
</td>
<td
class="px-6 py-5 text-center font-mono font-extrabold text-slate-700 dark:text-slate-200">
@if($log->new_values && isset($log->new_values['code']))
<span
class="px-2 py-1 rounded-lg bg-slate-100 dark:bg-slate-800/50 border border-slate-200/50 dark:border-slate-700/50 tracking-tighter">
{{ $log->new_values['code'] }}
</span>
@else
<span class="text-slate-300 dark:text-slate-700">-</span>
@endif
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<span
class="text-xs font-mono font-black text-slate-700 dark:text-slate-200 tracking-widest">
{{ $log->created_at?->format('Y-m-d H:i:s') }}
</span>
</td>
<td class="px-6 py-5 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<div
class="w-7 h-7 rounded-full bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center text-indigo-600 dark:text-indigo-400 text-[10px] font-black border border-indigo-200/50 dark:border-indigo-800/50">
{{ Str::substr($log->user->name ?? 'S', 0, 1) }}
</div>
<span
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
{{ $log->user->name ?? __('System') }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
@if($log->action === 'create')
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/50">
{{ __('Generate') }}
</span>
@elseif($log->action === 'update')
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400 border border-blue-200 dark:border-blue-800/50">
{{ __('Update') }}
</span>
@elseif($log->action === 'cancel')
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400 border border-rose-200 dark:border-rose-800/50">
{{ __('Cancel') }}
</span>
@else
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-slate-50 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400 border border-slate-200 dark:border-slate-800/50">
{{ $log->action }}
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-600 dark:text-slate-300">
ID: {{ $log->target_id }}
@if($log->new_values && isset($log->new_values['code']))
<span class="ml-2 px-2 py-0.5 rounded text-xs bg-slate-100 dark:bg-slate-800 font-mono">
{{ $log->new_values['code'] }}
</span>
@endif
</td>
<td class="px-6 py-4 text-sm text-slate-500 dark:text-slate-400">
@if($log->action === 'update' && isset($log->old_values['expires_at']) && isset($log->new_values['expires_at']))
<div>{{ __('Expires At') }}:
<span class="line-through opacity-70">{{ \Carbon\Carbon::parse($log->old_values['expires_at'])->format('Y-m-d H:i') }}</span>
<svg class="inline w-3 h-3 mx-1 text-slate-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>
<span class="text-amber-600 dark:text-amber-400 font-medium">{{ \Carbon\Carbon::parse($log->new_values['expires_at'])->format('Y-m-d H:i') }}</span>
</div>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-12 text-center">
<div class="flex flex-col items-center justify-center text-slate-400 dark:text-slate-500">
<svg class="w-12 h-12 mb-4 opacity-50" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
<td colspan="5" class="px-6 py-20 text-center">
<div class="flex flex-col items-center justify-center gap-3">
<div class="p-4 rounded-3xl bg-slate-50 dark:bg-slate-800/50">
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="1.5"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p class="text-sm font-medium">{{ __('No records found') }}</p>
</div>
<p class="text-slate-400 font-bold uppercase tracking-widest text-xs">{{ __('No records found') }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Card Mode (Mobile & Tablet) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-4">
@forelse($logs as $log)
<div
class="luxury-card p-5 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
<div class="flex items-start justify-between gap-4 mb-5">
<div class="flex items-center gap-3 min-w-0">
<div
class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm shrink-0">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 transition-colors tracking-tight">
{{ __('Pickup Code') }}</h3>
<p
class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest truncate mt-0.5">
ID: {{ $log->target_id }}</p>
</div>
</div>
<div class="shrink-0">
@php
$actionColor = match($log->action) {
'create' => 'cyan',
'cancel', 'consume_failed' => 'rose',
'used', 'consume' => 'emerald',
'verify_success' => 'indigo',
default => 'slate'
};
@endphp
<span
class="inline-flex items-center px-2.5 py-1 rounded-lg text-[10px] font-black bg-{{ $actionColor }}-500/10 text-{{ $actionColor }}-500 border border-{{ $actionColor }}-500/20 uppercase tracking-widest shadow-sm">
{{ __($log->action) }}
</span>
</div>
</div>
<!-- Pagination -->
@if(isset($logs) && $logs instanceof \Illuminate\Pagination\LengthAwarePaginator && $logs->hasPages())
<div class="px-6 py-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/50">
{{ $logs->links('vendor.pagination.luxury') }}
<div
class="grid grid-cols-2 gap-y-4 mb-4 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Operator') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{
$log->user->name ?? __('System') }}</p>
</div>
@endif
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Pickup Code') }}</p>
<p class="text-sm font-mono font-black text-slate-800 dark:text-slate-100">
{{ $log->new_values['code'] ?? '-' }}
</p>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Time') }}</p>
<p
class="text-sm font-mono font-bold text-slate-800 dark:text-slate-100 tracking-tighter">
{{ $log->created_at?->format('Y-m-d H:i:s') }}</p>
</div>
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No records found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $logs->links('vendor.pagination.luxury') }}
</div>

View File

@ -1,135 +1,7 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-4 pb-20" x-data="{
viewMode: '{{ request('machine_id') ? 'detail' : 'list' }}',
displayMode: 'table', {{-- grid or table --}}
selectedMachine: null,
slots: [],
loading: false,
async init() {
const urlParams = new URLSearchParams(window.location.search);
const machineId = urlParams.get('machine_id');
if (machineId) {
await this.viewSlots({id: machineId});
}
},
async viewSlots(machine) {
this.selectedMachine = machine;
this.viewMode = 'detail';
this.loading = true;
this.slots = [];
const url = new URL(window.location);
url.searchParams.set('machine_id', machine.id);
window.history.pushState({}, '', url);
try {
const res = await fetch(`/admin/warehouses/machine-inventory/${machine.id}/slots`);
const data = await res.json();
if (data.success) {
this.slots = data.slots || [];
this.selectedMachine = data.machine;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch(e) {
console.error('Fetch error:', e);
this.slots = [];
} finally {
this.loading = false;
}
},
backToList() {
this.viewMode = 'list';
this.selectedMachine = null;
this.slots = [];
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
get activeSlotsCount() { return this.slots.filter(s => s.product_id != null).length; },
get emptySlotsCount() { return this.slots.filter(s => s.product_id == null).length; },
get highStockSlotsCount() {
return this.slots.filter(s => s.product_id != null && (s.stock / (s.max_stock || 1)) >= 0.5).length;
},
get totalStock() { return this.slots.reduce((acc, s) => acc + (parseInt(s.stock) || 0), 0); },
get totalCapacity() { return this.slots.reduce((acc, s) => acc + (parseInt(s.max_stock) || 10), 0); },
get overallFillRate() {
if (this.totalCapacity === 0) return 0;
return Math.round((this.totalStock / this.totalCapacity) * 100);
},
getSlotColorClass(slot) {
const today = new Date().toISOString().split('T')[0];
// 1. Expired
if (slot.expiry_date && slot.expiry_date < today) {
return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30';
}
// 2. Empty
if (!slot.product_id || parseInt(slot.stock) === 0) {
return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
}
// 3. Low Stock (<= 2)
if (parseInt(slot.stock) <= 2) {
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30';
}
// 4. High Stock (> 50%)
const maxStock = parseInt(slot.max_stock) || 10;
if (parseInt(slot.stock) > maxStock / 2) {
return 'bg-cyan-50/60 dark:bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/30';
}
// 5. Normal / Active
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30';
},
getSlotCardClass(slot) {
const base = 'luxury-card p-6 rounded-[2rem] backdrop-blur-xl group hover:shadow-2xl transition-all duration-500 relative ';
const today = new Date().toISOString().split('T')[0];
// 1. Expired
if (slot.expiry_date && slot.expiry_date < today) {
return base + 'bg-rose-500/[0.06] border-rose-500/20 dark:bg-rose-500/10 dark:border-rose-500/30 hover:shadow-rose-500/10';
}
// 2. Empty
if (!slot.product_id || parseInt(slot.stock) === 0) {
return base + 'bg-slate-500/[0.06] border-slate-500/20 dark:bg-slate-500/10 dark:border-slate-500/30 hover:shadow-slate-500/10';
}
// 3. Low Stock (<= 2)
if (parseInt(slot.stock) <= 2) {
return base + 'bg-amber-500/[0.06] border-amber-500/20 dark:bg-amber-500/10 dark:border-amber-500/30 hover:shadow-amber-500/10';
}
// 4. High Stock (> 50%)
const maxStock = parseInt(slot.max_stock) || 10;
if (parseInt(slot.stock) > maxStock / 2) {
return base + 'bg-cyan-500/[0.06] border-cyan-500/20 dark:bg-cyan-500/10 dark:border-cyan-500/30 hover:shadow-cyan-500/10';
}
// 5. Normal / Active
return base + 'bg-emerald-500/[0.04] border-emerald-500/10 dark:bg-emerald-500/[0.08] dark:border-emerald-500/25 hover:shadow-emerald-500/10';
},
getSlotProgressClass(slot) {
const today = new Date().toISOString().split('T')[0];
if (slot.expiry_date && slot.expiry_date < today) return 'bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]';
if (!slot.product_id || parseInt(slot.stock) === 0) return 'bg-slate-400';
if (parseInt(slot.stock) <= 2) return 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.4)]';
const maxStock = parseInt(slot.max_stock) || 10;
if (parseInt(slot.stock) > maxStock / 2) return 'bg-cyan-500 shadow-[0_0_8px_rgba(6,182,212,0.4)]';
return 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]';
}
}">
<div class="space-y-4 pb-20" x-data="machineInventory">
{{-- Header --}}
<div class="flex items-center gap-4">
<template x-if="viewMode === 'detail'">
@ -275,21 +147,24 @@
@click.stop
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('Auto Replenishment') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
</a>
{{-- 庫存流水帳按鈕 --}}
<button @click.stop="openMovements({{ json_encode($machine->only(['id', 'name', 'serial_no'])) }})"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-indigo-500 hover:bg-indigo-500/5 transition-all border border-transparent hover:border-indigo-500/20"
title="{{ __('Stock Movement Log') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />
</svg>
</button>
<button
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('View Slots') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
@ -728,5 +603,573 @@
</div>
</div>
</div>
{{-- Movements Slide-out Panel --}}
<div x-show="movementsOpen" style="display: none;" class="relative z-[80]" aria-labelledby="slide-over-title" role="dialog" aria-modal="true">
{{-- Backdrop --}}
<div x-show="movementsOpen" x-transition.opacity.duration.300ms
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity"></div>
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16">
{{-- Panel --}}
<div x-show="movementsOpen"
@click.outside="if (!$event.target.closest('.flatpickr-calendar')) movementsOpen = false"
x-transition:enter="transform transition ease-in-out duration-400 sm:duration-500"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-400 sm:duration-500"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="pointer-events-auto w-screen max-w-4xl">
<div class="flex h-full flex-col bg-white dark:bg-slate-900 shadow-2xl overflow-hidden border-l border-slate-200 dark:border-white/10">
{{-- Header --}}
<div class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-white/10 bg-slate-50/50 dark:bg-slate-900/20">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
<div class="p-2 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0" 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="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="M12 12v9" />
<path d="m8 17 4 4 4-4" />
</svg>
</div>
<span class="truncate">{{ __('Machine Stock Movements') }}</span>
</h2>
<div class="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-[12px] sm:text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
<span x-text="movementsMachine?.serial_no" class="font-mono text-cyan-600 dark:text-cyan-400 truncate"></span>
<span class="hidden sm:inline opacity-50"></span>
<span x-text="movementsMachine?.name" class="truncate"></span>
</div>
</div>
<div class="flex-shrink-0 h-7 flex items-center">
<button type="button" @click="movementsOpen = false"
class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700 active:scale-90">
<span class="sr-only">{{ __('Close Panel') }}</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{{-- Compact Filters --}}
<div class="mt-6 flex flex-col sm:flex-row sm:items-end gap-4 sm:gap-6">
<div class="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{{-- Type --}}
<div class="space-y-1.5 group">
<label class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Type') }}</label>
<x-searchable-select
id="movements-filter-type"
name="type"
x-model="movementsFilter.type"
@change="loadMovements(1)"
:placeholder="__('All Types')"
:hasSearch="false"
class="luxury-select-sm"
>
<option value="replenishment">{{ __('movement.type.replenishment') }}</option>
<option value="pickup">{{ __('movement.type.pickup') }}</option>
<option value="remote_dispense">{{ __('movement.type.remote_dispense') }}</option>
<option value="adjustment">{{ __('movement.type.adjustment') }}</option>
<option value="rollback">{{ __('movement.type.rollback') }}</option>
</x-searchable-select>
</div>
{{-- Slot --}}
<div class="space-y-1.5 group">
<label class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Slot') }}</label>
<div id="movements-slot-select-wrapper" class="luxury-select-sm">
{{-- Dynamic Slot Select --}}
</div>
</div>
{{-- Start Time --}}
<div class="space-y-1.5 group">
<label class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Start Time') }}</label>
<div class="relative">
<input type="text" x-ref="movementsFromPicker" :value="movementsFilter.date_from" x-init="flatpickr($refs.movementsFromPicker, {
enableTime: true,
time_24hr: true,
dateFormat: 'Y/m/d H:i',
disableMobile: true,
allowInput: true,
defaultDate: movementsFilter.date_from,
onReady: (selectedDates, dateStr, instance) => {
if (movementsFilter.date_from) instance.setDate(movementsFilter.date_from, false);
},
onOpen: (selectedDates, dateStr, instance) => {
if (movementsFilter.date_from) instance.setDate(movementsFilter.date_from, false);
},
onClose: (selectedDates, dateStr) => { movementsFilter.date_from = dateStr; loadMovements(1); }
})"
class="luxury-input py-2 pl-9 w-full text-sm font-bold cursor-pointer bg-white dark:bg-slate-900/50">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="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>
</div>
</div>
{{-- End Time --}}
<div class="space-y-1.5 group">
<label class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('End Time') }}</label>
<div class="relative">
<input type="text" x-ref="movementsToPicker" :value="movementsFilter.date_to" x-init="flatpickr($refs.movementsToPicker, {
enableTime: true,
time_24hr: true,
dateFormat: 'Y/m/d H:i',
disableMobile: true,
allowInput: true,
defaultDate: movementsFilter.date_to,
onReady: (selectedDates, dateStr, instance) => {
if (movementsFilter.date_to) instance.setDate(movementsFilter.date_to, false);
},
onOpen: (selectedDates, dateStr, instance) => {
if (movementsFilter.date_to) instance.setDate(movementsFilter.date_to, false);
},
onClose: (selectedDates, dateStr) => { movementsFilter.date_to = dateStr; loadMovements(1); }
})"
class="luxury-input py-2 pl-9 w-full text-sm font-bold cursor-pointer bg-white dark:bg-slate-900/50">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="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>
</div>
</div>
</div>
<div class="flex-shrink-0 flex items-center">
<button @click="movementsFilter.date_from = ''; movementsFilter.date_to = ''; movementsFilter.type = ''; movementsFilter.slot_no = ''; rebuildSlotSelect(); loadMovements(1)"
class="text-[12px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5">
<svg class="w-3.5 h-3.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>
{{ __('Clear Filter') }}
</button>
</div>
</div>
</div>
{{-- Content --}}
<div class="relative flex-1 overflow-y-auto px-6 py-6 bg-white dark:bg-slate-900/50">
{{-- Loading State --}}
<div x-show="movementsLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm">
<div class="animate-spin inline-block w-8 h-8 border-[3px] border-current border-t-transparent text-cyan-600 rounded-full" role="status" aria-label="loading">
<span class="sr-only">Loading...</span>
</div>
</div>
{{-- Movements List --}}
<template x-if="!movementsLoading && movements.length === 0">
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="w-16 h-16 mb-4 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
<h3 class="text-sm font-medium text-slate-900 dark:text-slate-100">{{ __('No movements found') }}</h3>
</div>
</template>
<template x-if="movements.length > 0">
<div class="luxury-card border border-slate-200/50 dark:border-slate-800/50 rounded-2xl overflow-hidden shadow-xl bg-transparent">
{{-- Desktop Table --}}
<div class="hidden lg:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0 text-sm whitespace-nowrap">
<thead class="bg-slate-50 dark:bg-slate-800/50">
<tr>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-200 dark:border-slate-700 w-40">{{ __('Timestamp') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-200 dark:border-slate-700 w-24 text-center">{{ __('Type') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-200 dark:border-slate-700">{{ __('Message') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<template x-for="movement in movements" :key="movement.id">
<tr class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all border-b border-slate-100 dark:border-slate-800/50 last:border-0">
<td class="px-6 py-4">
<div class="flex flex-col">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest"
x-text="new Date(movement.created_at).toLocaleString('zh-TW', {year:'numeric', month:'2-digit', day:'2-digit'}).replace(/\//g, '-')"></span>
<span class="text-[14px] font-bold text-slate-600 dark:text-slate-200 mt-0.5"
x-text="new Date(movement.created_at).toLocaleString('zh-TW', {hour:'2-digit', minute:'2-digit', second:'2-digit', hour12: false})"></span>
</div>
</td>
<td class="px-6 py-4 text-center">
<span :class="movementTypeBadge(movement.type)"
class="inline-flex items-center px-2 py-0.5 rounded-md border border-current/10 text-[11px] font-black uppercase tracking-wider">
<span x-text="movementTypeName(movement.type)"></span>
</span>
</td>
<td class="px-6 py-4">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2 text-[13px] font-medium text-slate-700 dark:text-slate-200">
<span class="font-black text-slate-400 dark:text-slate-500 tracking-tighter" x-text="'[' + '{{ __('Slot') }}' + ' ' + movement.slot_no + ']'"></span>
<span x-text="movement.product?.name || '{{ __('Unknown Product') }}'"></span>
<span class="mx-0.5 opacity-50">:</span>
<span class="font-bold" x-text="movement.before_qty"></span>
<svg class="w-3 h-3 text-slate-300 dark:text-slate-600 mx-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
<span class="font-bold" x-text="movement.after_qty"></span>
<span class="ml-2 font-black"
:class="movement.quantity > 0 ? 'text-emerald-500' : 'text-rose-500'"
x-text="(movement.quantity > 0 ? '+' : '') + movement.quantity"></span>
<template x-if="movement.creator">
<span class="ml-3 pl-3 border-l border-slate-100 dark:border-slate-800 text-[11px] font-bold text-slate-400 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
<span x-text="movement.creator.name"></span>
</span>
</template>
</div>
<template x-if="movement.note">
<p class="text-[11px] text-slate-400 flex items-center gap-1 font-medium italic">
<svg class="w-3 h-3 text-slate-300" 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"></path></svg>
<span x-text="movement.note"></span>
</p>
</template>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{-- Mobile List view --}}
<div class="lg:hidden divide-y divide-slate-100 dark:divide-slate-800">
<template x-for="movement in movements" :key="movement.id">
<div class="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="flex flex-col">
<span class="text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest"
x-text="new Date(movement.created_at).toLocaleString('zh-TW', {year:'numeric', month:'2-digit', day:'2-digit'}).replace(/\//g, '-')"></span>
<span class="text-[11px] font-bold text-slate-500 dark:text-slate-400 mt-0.5"
x-text="new Date(movement.created_at).toLocaleString('zh-TW', {hour:'2-digit', minute:'2-digit', hour12: false})"></span>
</div>
<span :class="movementTypeBadge(movement.type)"
class="inline-flex items-center px-1.5 py-0.5 rounded-md border border-current/10 text-[9px] font-black uppercase tracking-tight">
<span x-text="movementTypeName(movement.type)"></span>
</span>
</div>
<div class="mb-2">
<div class="flex items-center gap-2 text-[13px] font-medium text-slate-700 dark:text-slate-200">
<span class="font-black text-slate-400 dark:text-slate-500 tracking-tighter" x-text="'[' + '{{ __('Slot') }}' + ' ' + movement.slot_no + ']'"></span>
<span x-text="movement.product?.name || '{{ __('Unknown Product') }}'" class="truncate"></span>
</div>
<div class="flex items-center gap-2 mt-1 text-xs text-slate-500">
<span class="font-bold" x-text="movement.before_qty"></span>
<svg class="w-3 h-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
<span class="font-bold" x-text="movement.after_qty"></span>
<span class="ml-1 font-black"
:class="movement.quantity > 0 ? 'text-emerald-500' : 'text-rose-500'"
x-text="(movement.quantity > 0 ? '+' : '') + movement.quantity"></span>
</div>
</div>
<template x-if="movement.creator || movement.note">
<div class="mt-3 flex items-center justify-between text-[11px] text-slate-400 font-medium">
<span x-text="movement.translated_note || movement.note" class="truncate w-full pr-2 text-slate-500"></span>
<span x-text="movement.creator?.name" class="flex items-center gap-1 whitespace-nowrap">
<svg class="w-3 h-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
</span>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
</div>
{{-- Pagination Footer --}}
<div x-show="movementsMeta.total > 0"
class="mt-auto border-t border-slate-100 dark:border-slate-800/80 px-6 py-4 flex items-center justify-between bg-slate-50/30 dark:bg-slate-900/30 backdrop-blur-sm rounded-b-3xl">
<div class="flex items-center gap-4">
<button @click="loadMovements(movementsMeta.current_page - 1)"
:disabled="movementsMeta.current_page === 1"
class="h-9 px-4 rounded-xl text-[11px] font-black uppercase tracking-widest transition-all shadow-sm border border-slate-200 dark:border-slate-700 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white dark:hover:bg-slate-800 flex items-center gap-2 text-slate-600 dark:text-slate-300">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M15 19l-7-7 7-7"/></svg>
{{ __('Previous') }}
</button>
<div class="relative group">
<select @change="loadMovements($event.target.value)"
class="h-9 pl-4 pr-10 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-[11px] font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 outline-none transition-all cursor-pointer shadow-sm hover:border-slate-300 dark:hover:border-slate-600 !bg-none">
<template x-for="p in Array.from({length: movementsMeta.last_page}, (_, i) => i + 1)">
<option :value="p" :selected="p === movementsMeta.current_page" x-text="p + ' / ' + movementsMeta.last_page"></option>
</template>
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-cyan-500/50 group-hover:text-cyan-500 transition-colors">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
<button @click="loadMovements(movementsMeta.current_page + 1)"
:disabled="movementsMeta.current_page === movementsMeta.last_page"
class="h-9 px-4 rounded-xl text-[11px] font-black uppercase tracking-widest transition-all shadow-sm border border-slate-200 dark:border-slate-700 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white dark:hover:bg-slate-800 flex items-center gap-2 text-slate-600 dark:text-slate-300">
{{ __('Next') }}
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
<div class="text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em]">
<span class="text-slate-600 dark:text-slate-300" x-text="movementsMeta.total"></span> {{ __('items') }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('machineInventory', () => ({
viewMode: '{{ request('machine_id') ? 'detail' : 'list' }}',
displayMode: 'table',
selectedMachine: null,
slots: [],
loading: false,
async init() {
const urlParams = new URLSearchParams(window.location.search);
const machineId = urlParams.get('machine_id');
if (machineId) {
await this.viewSlots({id: machineId});
}
},
async viewSlots(machine) {
this.selectedMachine = machine;
this.viewMode = 'detail';
this.loading = true;
this.slots = [];
const url = new URL(window.location);
url.searchParams.set('machine_id', machine.id);
window.history.pushState({}, '', url);
try {
const res = await fetch(`/admin/warehouses/machine-inventory/${machine.id}/slots`);
const data = await res.json();
if (data.success) {
this.slots = data.slots || [];
this.selectedMachine = data.machine;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch(e) {
console.error('Fetch error:', e);
this.slots = [];
} finally {
this.loading = false;
}
},
backToList() {
this.viewMode = 'list';
this.selectedMachine = null;
this.slots = [];
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
get activeSlotsCount() { return this.slots.filter(s => s.product_id != null).length; },
get emptySlotsCount() { return this.slots.filter(s => s.product_id == null).length; },
get highStockSlotsCount() {
return this.slots.filter(s => s.product_id != null && (s.stock / (s.max_stock || 1)) >= 0.5).length;
},
get totalStock() { return this.slots.reduce((acc, s) => acc + (parseInt(s.stock) || 0), 0); },
get totalCapacity() { return this.slots.reduce((acc, s) => acc + (parseInt(s.max_stock) || 10), 0); },
get overallFillRate() {
if (this.totalCapacity === 0) return 0;
return Math.round((this.totalStock / this.totalCapacity) * 100);
},
getSlotColorClass(slot) {
const today = new Date().toISOString().split('T')[0];
if (slot.expiry_date && slot.expiry_date < today) return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30';
if (!slot.product_id || parseInt(slot.stock) === 0) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
if (parseInt(slot.stock) <= 2) return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30';
const maxStock = parseInt(slot.max_stock) || 10;
if (parseInt(slot.stock) > maxStock / 2) return 'bg-cyan-50/60 dark:bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/30';
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30';
},
getSlotCardClass(slot) {
const base = 'luxury-card p-6 rounded-[2rem] backdrop-blur-xl group hover:shadow-2xl transition-all duration-500 relative ';
const today = new Date().toISOString().split('T')[0];
if (slot.expiry_date && slot.expiry_date < today) return base + 'bg-rose-500/[0.06] border-rose-500/20 dark:bg-rose-500/10 dark:border-rose-500/30 hover:shadow-rose-500/10';
if (!slot.product_id || parseInt(slot.stock) === 0) return base + 'bg-slate-500/[0.06] border-slate-500/20 dark:bg-slate-500/10 dark:border-slate-500/30 hover:shadow-slate-500/10';
if (parseInt(slot.stock) <= 2) return base + 'bg-amber-500/[0.06] border-amber-500/20 dark:bg-amber-500/10 dark:border-amber-500/30 hover:shadow-amber-500/10';
const maxStock = parseInt(slot.max_stock) || 10;
if (parseInt(slot.stock) > maxStock / 2) return base + 'bg-cyan-500/[0.06] border-cyan-500/20 dark:bg-cyan-500/10 dark:border-cyan-500/30 hover:shadow-cyan-500/10';
return base + 'bg-emerald-500/[0.04] border-emerald-500/10 dark:bg-emerald-500/[0.08] dark:border-emerald-500/25 hover:shadow-emerald-500/10';
},
getSlotProgressClass(slot) {
const today = new Date().toISOString().split('T')[0];
if (slot.expiry_date && slot.expiry_date < today) return 'bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]';
if (!slot.product_id || parseInt(slot.stock) === 0) return 'bg-slate-400';
if (parseInt(slot.stock) <= 2) return 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.4)]';
const maxStock = parseInt(slot.max_stock) || 10;
if (parseInt(slot.stock) > maxStock / 2) return 'bg-cyan-500 shadow-[0_0_8px_rgba(6,182,212,0.4)]';
return 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]';
},
// ── Movements Panel ──
movementsOpen: false,
movementsMachine: null,
movements: [],
movementsLoading: false,
movementsMeta: { current_page: 1, last_page: 1, total: 0 },
movementsFilter: { type: '', slot_no: '', date_from: '', date_to: '' },
movementsPage: 1,
movementsPerPage: 20,
movementsSlots: [],
movementTypeTranslations: {
replenishment: '{{ __("movement.type.replenishment") }}',
pickup: '{{ __("movement.type.pickup") }}',
remote_dispense: '{{ __("movement.type.remote_dispense") }}',
adjustment: '{{ __("movement.type.adjustment") }}',
rollback: '{{ __("movement.type.rollback") }}'
},
movementTypeName(type) {
return this.movementTypeTranslations[type] || type.replace('_', ' ').toUpperCase();
},
openMovements(machine) {
this.movementsMachine = machine;
this.movementsOpen = true;
this.movements = [];
this.movementsPage = 1;
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const todayStr = `${y}/${m}/${d}`;
this.movementsFilter = {
type: '',
slot_no: '',
date_from: todayStr + ' 00:00',
date_to: todayStr + ' 23:59'
};
this.updateSlotSelect();
this.loadMovements();
},
updateSlotSelect() {
if (!this.movementsMachine) return;
const machineId = this.movementsMachine.id;
if (this.selectedMachine && this.selectedMachine.id == machineId && this.slots.length > 0) {
this.movementsSlots = this.slots.map(s => s.slot_no);
this.rebuildSlotSelect();
} else {
fetch(`/admin/warehouses/machine-inventory/${machineId}/slots`)
.then(res => res.json())
.then(data => {
if (data.success) {
this.movementsSlots = (data.slots || []).map(s => s.slot_no);
this.rebuildSlotSelect();
}
});
}
},
rebuildSlotSelect() {
const wrapper = document.getElementById('movements-slot-select-wrapper');
if (!wrapper) return;
wrapper.innerHTML = '';
const select = document.createElement('select');
const uniqueId = 'movements-slot-select-' + Date.now();
select.id = uniqueId;
select.className = 'hidden';
const config = {
placeholder: '{{ __("All Slots") }}',
hasSearch: true,
searchPlaceholder: '{{ __("Search...") }}',
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>'
};
select.setAttribute('data-hs-select', JSON.stringify(config));
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = '{{ __("All Slots") }}';
allOpt.setAttribute('data-title', '{{ __("All Slots") }}');
select.appendChild(allOpt);
this.movementsSlots.forEach(slotNo => {
const opt = document.createElement('option');
opt.value = slotNo;
opt.textContent = slotNo;
opt.setAttribute('data-title', slotNo);
if (this.movementsFilter.slot_no == slotNo) opt.selected = true;
select.appendChild(opt);
});
wrapper.appendChild(select);
select.addEventListener('change', (e) => {
this.movementsFilter.slot_no = e.target.value;
this.loadMovements(1);
});
this.$nextTick(() => {
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit(['select']);
}
});
},
async loadMovements(page = 1) {
if (!this.movementsMachine) return;
this.movementsLoading = true;
this.movementsPage = page;
const params = new URLSearchParams({ page, per_page: this.movementsPerPage, ...this.movementsFilter });
Object.keys(this.movementsFilter).forEach(k => {
if (!this.movementsFilter[k]) params.delete(k);
});
try {
const res = await fetch(`/admin/warehouses/machine-inventory/${this.movementsMachine.id}/movements?${params}`);
const data = await res.json();
if (data.success) {
this.movements = data.movements || [];
this.movementsMeta = data.meta || { current_page: 1, last_page: 1, total: 0 };
}
} catch (e) {
console.error('Movements fetch error:', e);
} finally {
this.movementsLoading = false;
}
},
movementTypeBadge(type) {
const colors = {
replenishment: 'emerald',
pickup: 'indigo',
remote_dispense:'cyan',
adjustment: 'amber',
rollback: 'rose',
};
const color = colors[type] || 'slate';
return `bg-${color}-500/10 text-${color}-600 dark:text-${color}-400 border-${color}-500/20`;
}
}));
});
</script>
@endsection

View File

@ -63,7 +63,10 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
Route::post('machine/timer/B710', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncTimer']);
Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']);
Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']);
Route::post('machine/pickup/verify/B660', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyPickupCode']);
// 取貨與通行驗證
Route::match(['post', 'put'], 'machine/pickup/verify/B660', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyPickupCode']);
Route::post('machine/passcode/verify/B670', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyPassCode']);
// 廣告與貨道清單 (B005, B009, B012)
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);

View File

@ -54,6 +54,7 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
Route::get('/{machine}/slots-ajax', [App\Http\Controllers\Admin\MachineController::class, 'slotsAjax'])->name('slots-ajax');
Route::post('/{machine}/slots/expiry', [App\Http\Controllers\Admin\MachineController::class, 'updateSlotExpiry'])->name('slots.expiry.update');
Route::get('/{machine}/logs-ajax', [App\Http\Controllers\Admin\MachineController::class, 'logsAjax'])->name('logs-ajax');
Route::post('/{machine}/resolve-logs', [App\Http\Controllers\Admin\MachineController::class, 'resolveLogs'])->name('resolve-logs');
});
Route::resource('machines', App\Http\Controllers\Admin\MachineController::class);
@ -101,6 +102,7 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
// 模組 4機台庫存總覽 (Force Route Refresh)
Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory');
Route::get('/machine-inventory/{machine}/slots', [App\Http\Controllers\Admin\WarehouseController::class, 'machineSlots'])->name('machine-inventory.slots');
Route::get('/machine-inventory/{machine}/movements', [App\Http\Controllers\Admin\WarehouseController::class, 'machineStockMovements'])->name('machine-inventory.movements');
// 模組 5機台補貨
Route::get('/replenishments', [App\Http\Controllers\Admin\WarehouseController::class, 'replenishments'])->name('replenishments');