[RELEASE] 晉升 demo 變更至 main 生產環境

1. 實作調撥單與機台補貨單的 A4 列印功能與多租戶防護。
2. 新增與優化來店禮、通行碼、取貨碼行銷促銷模組之多語系翻譯與 UI 排版。
3. 實作 OTA APK 版本管理與更新部署功能(含 API 與後台介面)。
4. 微調側邊欄選單順序,將 APK版本 移至基本設定選單最下方。
5. 從角色權限設定中徹底移除已廢棄的特殊權限 (menu.special-permission) 模組。
This commit is contained in:
sky121113 2026-05-27 10:08:54 +08:00
commit 51d007d84e
47 changed files with 5574 additions and 674 deletions

View File

@ -38,7 +38,7 @@ trigger: always_on
* **樣式與組件**:全面使用 Tailwind CSS utility classes遵循 Preline UI 的 HTML 結構進行開發。**避免撰寫自訂 CSS**。
* **互動邏輯**:優先使用輕量級 **Alpine.js** (`x-data`, `x-show`, `@click`) 於 Blade 內完成,**禁用 React / Vue / Inertia.js**。
* **【警告Preline 冗餘】**Preline 官方範例常有多餘控制項。必須確保介面佈局極簡,嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
* **多語系 (I18n)**:所有 UI 可見文字必須使用 `@lang('key')``__('key')` 處理。Key 統一採用**英文原始詞彙**,並同步更新 `zh_TW.json`, `en.json`, `ja.json`
* **多語系 (I18n)**:所有 UI 可見文字必須使用 `@lang('key')``__('key')` 處理。Key 統一採用**英文原始詞彙**,並同步更新 `zh_TW.json`, `en.json`, `ja.json`必須嚴格遵守「多語系管理與自動對齊規範 (SKILL.md)」,以 `zh_TW.json` 為標準對齊三語系,所有 JSON 檔案須按字母排序 (ksort),且斜線一律使用 `JSON_UNESCAPED_SLASHES` 還原為 `/`,嚴禁逸出為 `\/`
## 5. 運行與測試機制
* **啟動環境**`./vendor/bin/sail up -d`

View File

@ -19,6 +19,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |
| 多語系, 翻譯, 語系檔, lang, json, zh_TW, en, ja, I18n | **多語系管理與自動對齊規範** | `.agents/skills/multilingual-specs/SKILL.md` |
---
@ -42,4 +43,8 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
必須讀取:
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
### 🔴 新增或修改語系 JSON 檔案lang/*.json或執行多語系任務時
必須讀取:
1. **multilingual-specs** — 確保遵守三語系對齊、字母排序、斜線不逸出規範

View File

@ -234,11 +234,49 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- `status`: "active"。
> [!WARNING]
> **鎖定機制**B660, B670 與 B680 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
> **鎖定機制**B660, B670, B680 與 B690 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
---
### 3.12 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
### 3.12 B690: 來店禮驗證 (Welcome Gift Verify)
處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊供機台端套用折扣。
- **URL**: POST /api/v1/app/machine/welcome-gift/verify/B690
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 8 位數來店禮代碼 | "12345678" |
**Response (Success 200):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true |
| code | Integer | 業務狀態碼 | 200 |
| data.name | String | 來店禮名稱 | 新客八五折 |
| data.code_id | Integer | 來店禮 ID (用於 MQTT 交易關聯) | 12 |
| data.discount_type | String | 折扣類型 (percentage / amount) | percentage |
| data.discount_value | Integer | 折扣趴數 (如 15 代表 15% off) 或折扣金額 | 15 |
| data.discount_label | String | 台灣中文折數標籤 (如「八五折」、「折 50 元」) | 八五折 |
| data.usage_type | String | 使用類型 (once / unlimited) | once |
| data.status | String | 狀態 | active |
**Response (Failed 404):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | false |
| message | String | 失敗原因 | Invalid or expired code |
| code | Integer | 業務狀態碼 | 404 |
| remaining_attempts | Integer | 剩餘嘗試次數 | 3 |
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息,並在 `payment_type` 帶入 `7` (來店禮) 完成扣減。
---
### 3.14 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
- **URL**: POST /api/v1/app/sell/free-gift/B027
@ -249,7 +287,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
---
### 3.13 B650: 會員身分驗證 (Member Verification)
### 3.15 B650: 會員身分驗證 (Member Verification)
機台端掃碼後驗證會員身分。
- **URL**: POST /api/v1/app/machine/member/verify/B650

View File

@ -0,0 +1,161 @@
# 多語系管理與自動對齊規範 (Multilingual Specs)
本技能規範定義了 Star Cloud 系統中所有多語系 JSON 檔案(位於 `lang/`)的維護標準。當專案需要新增、修改或校對多語系時,必須嚴格遵守此規範。
---
## 核心三大原則 (CRITICAL RULES)
### 1. 三語系完美對齊 (Master-Align)
- **以 `zh_TW.json` 為唯一標準**:不論任何時候新增或修改翻譯 Key都必須以 `lang/zh_TW.json` 的鍵值Keys集合為絕對標準。
- **三檔對齊**`lang/en.json` 與 `lang/ja.json` 的鍵值集合必須與 `zh_TW.json` 百分之百相同(行數、數量與 Key 的拼寫必須完全一致)。
### 2. 字母順序排序 (ksort)
- 為避免 Git 版本控制產生大範圍的亂序 Diff三個 JSON 檔案中的所有鍵值對,必須一律按 **Key 的英文字母升冪順序** 進行排序(類似 PHP 的 `ksort()`)。
### 3. 斜線不逸出與美化 (No Escaped Slashes)
- **禁止使用預設 `json_encode` 逸出斜線**JSON 檔案中的網址或路徑斜線一律使用 `/`,嚴禁產生 `\/`
- **儲存格式化要求**
在寫入語系 JSON 檔案時,必須使用以下 JSON 編碼參數:
```php
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
```
---
## 自動化對齊工具 (I18n Alignment Tool)
當處理語系變更時AI 助手應在專案的 `scratch/` 目錄中建立一個臨時 PHP 腳本(例如 `scratch/align_lang.php`),執行對齊並排序後,**立即將其刪除**。
### 🔧 臨時對齊腳本範本 (`scratch/align_lang.php`)
```php
<?php
/**
* Star Cloud - 多語系自動對齊與排序工具
* 執行指令php scratch/align_lang.php
*/
$langDir = __DIR__ . '/../lang/';
$zhPath = $langDir . 'zh_TW.json';
$enPath = $langDir . 'en.json';
$jaPath = $langDir . 'ja.json';
if (!file_exists($zhPath)) {
die("❌ 找不到 zh_TW.json 主語系檔!\n");
}
$zhData = json_decode(file_get_contents($zhPath), true);
$enData = file_exists($enPath) ? json_decode(file_get_contents($enPath), true) : [];
$jaData = file_exists($jaPath) ? json_decode(file_get_contents($jaPath), true) : [];
// 1. 精準翻譯對照字典 (用於自動補齊常見系統/機台特有詞彙)
$translationDict = [
// 通行碼/機台/權限相關
"Channel Limits Config" => [
"en" => "Channel Limits Config",
"ja" => "チャンネル制限設定"
],
"Connection error" => [
"en" => "Connection error",
"ja" => "接続エラー"
],
"Passcode settings" => [
"en" => "Passcode settings",
"ja" => "パスコード設定"
],
"Reset Passcode" => [
"en" => "Reset Passcode",
"ja" => "パスコードをリセット"
],
"Update Passcode" => [
"en" => "Update Passcode",
"ja" => "パスコードを更新"
],
"Machine Authorization" => [
"en" => "Machine Authorization",
"ja" => "デバイス授権"
],
"Status" => [
"en" => "Status",
"ja" => "ステータス"
],
"Actions" => [
"en" => "Actions",
"ja" => "操作"
]
];
$missingEnCount = 0;
$missingJaCount = 0;
$newEnData = [];
$newJaData = [];
// 2. 以 zh_TW 的 keys 為基準對齊 en 與 ja
foreach ($zhData as $key => $zhVal) {
// 處理英文
if (isset($enData[$key]) && trim($enData[$key]) !== '') {
$newEnData[$key] = $enData[$key];
} else {
// 嘗試從精準字典查找
if (isset($translationDict[$key]["en"])) {
$newEnData[$key] = $translationDict[$key]["en"];
} else {
// fallback: 使用 key 原始名稱作為英文
$newEnData[$key] = $key;
}
$missingEnCount++;
}
// 處理日文
if (isset($jaData[$key]) && trim($jaData[$key]) !== '') {
$newJaData[$key] = $jaData[$key];
} else {
// 嘗試從精準字典查找
if (isset($translationDict[$key]["ja"])) {
$newJaData[$key] = $translationDict[$key]["ja"];
} else {
// fallback: 如果 key 與英文相同,且非中文,可先 fallback 英文,後續再由 LLM 補譯
$newJaData[$key] = $key;
}
$missingJaCount++;
}
}
// 3. 字母排序 (ksort)
ksort($zhData);
ksort($newEnData);
ksort($newJaData);
// 4. 寫回檔案 (反逸出斜線 + Unicode)
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
file_put_contents($zhPath, json_encode($zhData, $jsonFlags) . "\n");
file_put_contents($enPath, json_encode($newEnData, $jsonFlags) . "\n");
file_put_contents($jaPath, json_encode($newJaData, $jsonFlags) . "\n");
echo "✅ 多語系處理完畢!\n";
echo "📊 統計結果:\n";
echo " - 總共鍵數: " . count($zhData) . " 個\n";
echo " - 補齊英文空缺: {$missingEnCount} 個\n";
echo " - 補齊日文空缺: {$missingJaCount} 個\n";
```
---
## 💡 AI 助手處理語系之 SOP 流程
當使用者要求修改或補齊語系時AI 必須:
1. **同步更新/新增 `zh_TW.json`**:將新的詞彙加入 `zh_TW.json` 中。
2. **利用 LLM 翻譯補足空缺**
- 針對新加入的詞彙,由 AI 運用其 LLM 能力將高品質的英文(`en`)與日文(`ja`)翻譯出來。
3. **擴充對齊腳本的暫存字典**
- 將這些新翻譯的詞彙暫時寫入臨時對齊腳本 `scratch/align_lang.php` 中的 `$translationDict` 中。
4. **執行一鍵對齊與字母排序**
- 在終端機執行 `php scratch/align_lang.php`,確保三個語系 JSON 檔案行數百分之百對齊、全部排序、斜線清空。
5. **清理暫存**
- 刪除 `scratch/align_lang.php`
6. **Git 檢查與提交**
- 執行 `git diff` 確認修改範疇僅限於新增的語系,無亂序與無關的語系被刪除。

View File

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Admin\BasicSettings;
use App\Http\Controllers\Admin\AdminController;
use App\Models\Machine\ApkVersion;
use App\Models\Machine\Machine;
use App\Services\Machine\MqttService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class ApkVersionController extends AdminController
{
public function index(): View
{
$versions = ApkVersion::orderBy('version_code', 'desc')->paginate(10);
// 用於 OTA 下發時讓使用者選擇的機台列表
$machines = Machine::all();
return view('admin.basic-settings.apk-versions.index', compact('versions', 'machines'));
}
public function create(): View
{
return view('admin.basic-settings.apk-versions.create');
}
public function store(Request $request)
{
$request->validate([
'version_name' => 'required|string|max:100',
'version_code' => 'required|integer|unique:apk_versions,version_code',
'flavor' => 'required|string|max:100',
'apk_file' => 'required_without:download_url|nullable|file|max:102400', // 上傳限制 100MB
'download_url' => 'required_without:apk_file|nullable|url|max:500',
'release_notes' => 'nullable|string',
'is_forced' => 'nullable|boolean',
]);
$filePath = null;
if ($request->hasFile('apk_file')) {
$filePath = $request->file('apk_file')->store('apks', 'public');
}
ApkVersion::create([
'version_name' => $request->version_name,
'version_code' => $request->version_code,
'flavor' => $request->flavor,
'file_path' => $filePath,
'download_url' => $request->download_url,
'release_notes' => $request->release_notes,
'is_forced' => $request->has('is_forced') || (bool) $request->input('is_forced', false),
]);
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', __('APK version uploaded successfully.'));
}
public function destroy(ApkVersion $apkVersion)
{
if ($apkVersion->file_path) {
Storage::disk('public')->delete($apkVersion->file_path);
}
$apkVersion->delete();
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', __('APK version deleted successfully.'));
}
/**
* 推送 OTA 更新指令給指定機台
*/
public function push(Request $request, ApkVersion $apkVersion, MqttService $mqttService)
{
$request->validate([
'machine_ids' => 'required|array',
'machine_ids.*' => 'exists:machines,id',
]);
$machines = Machine::whereIn('id', $request->machine_ids)->get();
$successCount = 0;
foreach ($machines as $machine) {
// 1. 先建立 RemoteCommand 紀錄,以取得自增 ID 作為 command_id與系統慣例一致
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id(),
'command_type' => 'update_app',
'status' => 'pending',
'payload' => [
'url' => $apkVersion->url,
'version' => $apkVersion->version_name,
'version_code' => $apkVersion->version_code,
'is_forced' => $apkVersion->is_forced,
],
]);
// 2. 推播 MQTTcommand_id 使用資料庫自增流水號
$pushed = $mqttService->pushCommand(
$machine->serial_no,
'update_app',
$command->payload,
(string) $command->id
);
if ($pushed) {
$successCount++;
} else {
// 推播失敗,將紀錄標記為失敗
$command->update(['status' => 'failed']);
}
}
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', sprintf(__('OTA command sent to %d devices successfully.'), $successCount));
}
}

View File

@ -580,7 +580,6 @@ class PermissionController extends Controller
'menu.analysis',
'menu.data-config',
'menu.remote',
'menu.special-permission',
'menu.basic',
'menu.permissions',
];

View File

@ -13,6 +13,8 @@ use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCode;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\Machine\Machine;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
@ -596,12 +598,262 @@ class SalesController extends Controller
}
// 來店禮設定
public function storeGifts()
public function storeGifts(Request $request)
{
return view('admin.placeholder', [
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '來店禮設定',
'description' => '來店優惠活動設定',
'description' => '來店禮優惠碼管理 (單次與無限制來店優惠折抵)',
'tab' => $tab,
];
// 1. 來店禮列表 (list)
if (!$isAjax || $tab === 'list') {
$query = WelcomeGift::with(['machine', 'creator'])->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhere('name', 'like', "%{$request->search}%")
->orWhereHas('machine', function ($mq) use ($request) {
$mq->where('name', 'like', "%{$request->search}%")
->orWhere('serial_no', 'like', "%{$request->search}%");
});
});
}
if ($request->status && trim($request->status) !== '') {
$status = trim($request->status);
if ($status === 'active') {
$query->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
} elseif ($status === 'expired') {
$query->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
} else {
$query->where('status', $status);
}
}
$data['welcomeGifts'] = $query->paginate($request->input('per_page', 10), ['*'], 'list_page')->withQueryString();
$data['machines'] = Machine::all();
}
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no']);
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('remark', 'like', "%{$search}%")
->orWhereHas('welcomeGift', function($pq) use ($search) {
$pq->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
});
}
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 日期區間篩選 (6 個月限制)
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
if (!$startDate && !$endDate) {
$logQuery->where('created_at', '>=', now()->subMonths(6));
} else {
try {
if ($startDate) {
$logQuery->where('created_at', '>=', \Carbon\Carbon::parse($startDate)->startOfMinute());
}
if ($endDate) {
$logQuery->where('created_at', '<=', \Carbon\Carbon::parse($endDate)->endOfMinute());
}
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 10), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'verify_success' => __('verify_success'),
'consume' => __('consume'),
'cancel' => __('cancel'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.welcome-gifts.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.welcome-gifts.index', $data);
}
/**
* 新增來店禮
*/
public function storeWelcomeGift(Request $request)
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:100',
'discount_type' => 'required|in:percentage,amount',
'discount_val_input' => 'required|numeric|min:0.1', // 接受折數 (如 8.5) 或 金額 (如 50)
'usage_type' => 'required|in:once,unlimited',
'usage_limit' => 'nullable|integer|min:1',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:10',
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 關鍵自動換算邏輯
$discountValue = 0;
if ($validated['discount_type'] === 'percentage') {
// 使用者輸入折數 (如 8 代表打八折8.5 代表打八五折)
$fold = (float) $validated['discount_val_input'];
// 換算為趴數,公式為 (10 - 折數) * 10 ➜ 例如 8.5折 ➜ (10 - 8.5) * 10 = 15% off
$discountValue = (int) round((10 - $fold) * 10);
} else {
// 金額折抵 (如 50 代表折 50 元)
$discountValue = (int) $validated['discount_val_input'];
}
$welcomeGift = WelcomeGift::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'],
'code' => $validated['custom_code'] ?? WelcomeGift::generateUniqueCode($validated['machine_id']),
'discount_type' => $validated['discount_type'],
'discount_value' => $discountValue,
'usage_type' => $validated['usage_type'],
'usage_limit' => $validated['usage_type'] === 'once' ? ($validated['usage_limit'] ?? 1) : null,
'expires_at' => $validated['expires_at'],
'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'create',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift created successfully.'));
}
/**
* 更新來店禮
*/
public function updateWelcomeGift(Request $request, WelcomeGift $welcomeGift)
{
$validated = $request->validate([
'name' => 'nullable|string|max:100',
'discount_type' => 'nullable|in:percentage,amount',
'discount_val_input' => 'nullable|numeric|min:0.1',
'expires_at' => 'nullable|date',
'status' => 'nullable|in:active,disabled',
'usage_type' => 'nullable|in:once,unlimited',
'usage_limit' => 'nullable|integer|min:1',
]);
if (isset($validated['expires_at']) && empty($validated['expires_at'])) {
$validated['expires_at'] = null;
}
$oldValues = $welcomeGift->toArray();
// 整理要更新的資料
$updateData = [];
if ($request->has('name')) {
$updateData['name'] = $validated['name'];
}
if ($request->has('status')) {
$updateData['status'] = $validated['status'];
}
if ($request->has('expires_at')) {
$updateData['expires_at'] = $validated['expires_at'];
}
if ($request->has('usage_type')) {
$updateData['usage_type'] = $validated['usage_type'];
if ($validated['usage_type'] === 'unlimited') {
$updateData['usage_limit'] = null;
} elseif ($request->has('usage_limit')) {
$updateData['usage_limit'] = $validated['usage_limit'];
}
} elseif ($request->has('usage_limit') && $welcomeGift->usage_type === 'once') {
$updateData['usage_limit'] = $validated['usage_limit'];
}
// 折扣折數/金額更新
if ($request->has('discount_type') || $request->has('discount_val_input')) {
$discountType = $validated['discount_type'] ?? $welcomeGift->discount_type;
$discountValInput = $validated['discount_val_input'] ?? ($discountType === 'percentage' ? $welcomeGift->input_fold : $welcomeGift->discount_value);
$updateData['discount_type'] = $discountType;
if ($discountType === 'percentage') {
$fold = (float) $discountValInput;
$updateData['discount_value'] = (int) round((10 - $fold) * 10);
} else {
$updateData['discount_value'] = (int) $discountValInput;
}
}
$welcomeGift->update($updateData);
SystemOperationLog::create([
'company_id' => $welcomeGift->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'update',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'old_values' => $oldValues,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift updated.'));
}
/**
* 刪除來店禮 (改為停用)
*/
public function destroyWelcomeGift(WelcomeGift $welcomeGift)
{
$oldValues = $welcomeGift->toArray();
$welcomeGift->update(['status' => 'disabled']);
SystemOperationLog::create([
'company_id' => $welcomeGift->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'cancel',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'old_values' => $oldValues,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift cancelled.'));
}
/**

View File

@ -15,13 +15,4 @@ class SpecialPermissionController extends Controller
'description' => '特殊權限庫存清空功能',
]);
}
// APK版本管理
public function apkVersions()
{
return view('admin.placeholder', [
'title' => 'APK版本管理',
'description' => 'APP版本控制與更新',
]);
}
}

View File

@ -1436,4 +1436,47 @@ class WarehouseController extends Controller
'items' => $items
]);
}
/**
* 列印調撥單
*/
public function printTransfer($id)
{
$order = TransferOrder::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator'])
->findOrFail($id);
// 雙重防護:驗證租戶資料隔離安全
$user = auth()->user();
if ($user && $user->company_id && $order->company_id !== $user->company_id) {
abort(404);
}
$items = \App\Models\Warehouse\TransferOrderItem::with(['product' => fn($q) => $q->with('translations')])
->where('transfer_order_id', $id)
->get();
return view('admin.warehouses.print.transfer', compact('order', 'items'));
}
/**
* 列印機台補貨單
*/
public function printReplenishment($id)
{
$order = ReplenishmentOrder::with(['machine', 'warehouse', 'creator', 'assignee'])
->findOrFail($id);
// 雙重防護:驗證租戶資料隔離安全
$user = auth()->user();
if ($user && $user->company_id && $order->company_id !== $user->company_id) {
abort(404);
}
$items = ReplenishmentOrderItem::with(['product' => fn($q) => $q->with('translations')])
->where('replenishment_order_id', $id)
->get();
return view('admin.warehouses.print.replenishment', compact('order', 'items'));
}
}

View File

@ -20,6 +20,8 @@ use App\Models\Transaction\PassCode;
use App\Models\StaffCard;
use App\Models\StaffCardLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\System\SystemOperationLog;
use App\Services\Machine\MachineService;
use App\Services\Product\ProductCatalogService;
@ -641,4 +643,73 @@ class MachineController extends Controller
]
]);
}
/**
* B690: Verify Welcome Gift (New)
*/
public function verifyWelcomeGift(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "welcome_gift_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many attempts. Locked.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$gift = WelcomeGift::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$gift || !$gift->isValid()) {
$fails = Cache::increment("welcome_gift_fails:{$machine->id}");
if ($fails === 1) {
Cache::put("welcome_gift_fails:{$machine->id}", 1, 600);
}
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60);
Cache::forget("welcome_gift_fails:{$machine->id}");
}
return response()->json(['success' => false, 'message' => 'Invalid or expired code', 'code' => 404, 'remaining_attempts' => max(0, 5 - $fails)], 404);
}
Cache::forget("welcome_gift_fails:{$machine->id}");
// 建立來店禮驗證日誌 (驗證成功)
WelcomeGiftLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'welcome_gift_id' => $gift->id,
'action' => 'verify_success',
'remark' => 'log.welcome_gift.verify_success',
'raw_data' => [
'code' => $gift->code,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value
]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $gift->name,
'code_id' => $gift->id,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value,
'discount_label' => $gift->discount_label, // 回傳自動算好的中文折數 (如「八折」、「折50元」)
'usage_type' => $gift->usage_type,
'status' => 'active'
]
]);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Guest;
use App\Http\Controllers\Controller;
use App\Models\Transaction\WelcomeGift;
class WelcomeGiftController extends Controller
{
/**
* 顯示公開來店禮憑證頁面
*/
public function show($slug)
{
$welcomeGift = WelcomeGift::with(['machine'])
->where('slug', $slug)
->where('status', 'active')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->firstOrFail();
return view('guest.welcome-gift.show', compact('welcomeGift'));
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ApkVersion extends Model
{
protected $fillable = [
'version_name',
'version_code',
'flavor',
'file_path',
'download_url',
'release_notes',
'is_forced',
];
protected $casts = [
'is_forced' => 'boolean',
];
/**
* 取得最終下載 URL
*/
public function getUrlAttribute(): string
{
return $this->download_url ?: Storage::disk('public')->url($this->file_path);
}
}

View File

@ -36,6 +36,7 @@ class Order extends Model
'payment_at',
'member_barcode',
'code_id',
'welcome_gift_id',
'invoice_info',
'machine_time',
'status',
@ -141,4 +142,9 @@ class Order extends Model
{
return $this->hasOne(\App\Models\StaffCardLog::class);
}
public function welcomeGift()
{
return $this->belongsTo(WelcomeGift::class);
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WelcomeGift extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'name',
'code',
'slug',
'discount_type',
'discount_value',
'usage_type',
'usage_limit',
'usage_count',
'expires_at',
'status',
'created_by',
];
protected $casts = [
'expires_at' => 'datetime',
'discount_value' => 'integer',
];
protected $appends = ['discount_label', 'input_fold'];
/**
* 獲取公開來店禮連結
*/
public function getTicketUrlAttribute()
{
return $this->slug ? route('welcome-gift.ticket', $this->slug) : null;
}
protected static function booted()
{
static::creating(function ($gift) {
if (!$gift->slug) {
$gift->slug = \Illuminate\Support\Str::random(16);
}
});
}
/**
* 後台編輯時回填使用的折數 (如趴數 20 換算回 8 15 換算回 8.5 )
*/
public function getInputFoldAttribute()
{
if ($this->discount_type !== 'percentage') {
return null;
}
return (100 - $this->discount_value) / 10;
}
/**
* 自動轉換為台灣消費者習慣的折數標籤 (例如:八折、八五折、折 50 )
*/
public function getDiscountLabelAttribute(): string
{
if ($this->discount_type === 'amount') {
return "{$this->discount_value}";
}
if ($this->discount_type === 'percentage') {
$fold = $this->input_fold;
return $this->convertToChineseDiscount($fold);
}
return '';
}
private function convertToChineseDiscount($fold): string
{
$map = [
'1' => '一', '2' => '二', '3' => '三', '4' => '四', '5' => '五',
'6' => '六', '7' => '七', '8' => '八', '9' => '九', '0' => '零'
];
$foldStr = (string)$fold;
if (strpos($foldStr, '.') !== false) {
list($integer, $decimal) = explode('.', $foldStr);
$intChar = $map[$integer] ?? $integer;
$decChar = $map[substr($decimal, 0, 1)] ?? substr($decimal, 0, 1);
return "{$intChar}{$decChar}";
}
$char = $map[$foldStr] ?? $foldStr;
return "{$char}";
}
/**
* 關聯機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 關聯建立者
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 判斷是否可用
*/
public function isValid(): bool
{
if ($this->status !== 'active') {
return false;
}
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
if ($this->usage_type === 'once' && $this->usage_count >= ($this->usage_limit ?? 1)) {
return false;
}
return true;
}
/**
* 產生唯一的來店禮代碼 (8)
*/
public static function generateUniqueCode(int $machineId): string
{
do {
$code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
$exists = self::where('machine_id', $machineId)
->where('code', $code)
->where('status', 'active')
->exists();
} while ($exists);
return $code;
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WelcomeGiftLog extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'welcome_gift_id',
'order_id',
'action',
'remark',
'raw_data',
];
protected $casts = [
'raw_data' => 'array',
];
/**
* 關聯來店禮
*/
public function welcomeGift(): BelongsTo
{
return $this->belongsTo(WelcomeGift::class);
}
/**
* 關聯機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 關聯訂單
*/
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
/**
* 關聯操作人員(一般是系統透過訂單綁定或背景核銷,如有操作人可由關聯取得,通常從 Order 或由 SystemOperationLog 獲取)
*/
}

View File

@ -12,6 +12,8 @@ use App\Models\StaffCardLog;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use Illuminate\Support\Facades\Log;
use App\Services\Machine\MachineService;
@ -63,6 +65,7 @@ class TransactionService
'payment_response' => $data['payment_response'] ?? null,
'member_barcode' => $data['member_barcode'] ?? null,
'code_id' => $data['code_id'] ?? null,
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
'invoice_info' => $data['invoice_info'] ?? null,
'machine_time' => $data['machine_time'] ?? now(),
'payment_at' => now(),
@ -346,6 +349,38 @@ class TransactionService
}
}
// 處理來店禮 (Welcome Gift) 自動核銷
$welcomeGiftId = $data['order']['welcome_gift_id'] ?? ($data['welcome_gift_id'] ?? null);
if ($welcomeGiftId) {
$welcomeGift = WelcomeGift::where('machine_id', $order->machine_id)
->where('id', $welcomeGiftId)
->where('status', 'active')
->first();
if ($welcomeGift) {
$newCount = $welcomeGift->usage_count + 1;
$isUsedUp = ($welcomeGift->usage_type === 'once' && $newCount >= ($welcomeGift->usage_limit ?? 1));
$welcomeGift->update([
'usage_count' => $newCount,
'status' => $isUsedUp ? 'used' : 'active',
]);
WelcomeGiftLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'welcome_gift_id' => $welcomeGift->id,
'order_id' => $order->id,
'action' => 'consume',
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}",
'raw_data' => [
'code' => $welcomeGift->code,
'count' => $newCount,
'type' => $welcomeGift->usage_type
]
]);
}
}
return $order;
});

View File

@ -409,30 +409,87 @@ return [
'notes' => '驗證成功後會建立刷卡日誌。連續錯誤同樣會觸發鎖定。'
],
[
'name' => 'B027: 贈品碼/優惠券驗證 (Free Gift Verify)',
'slug' => 'b027-freebie-code',
'name' => 'B690: 來店禮驗證 (Welcome Gift Verify)',
'slug' => 'b690-welcome-gift-verify',
'method' => 'POST',
'path' => '/api/v1/app/sell/free-gift/B027',
'description' => '驗證贈品券有效性。成功後回傳對應資訊,正式消耗改由 MQTT finalize 處理。',
'path' => '/api/v1/app/machine/welcome-gift/verify/B690',
'description' => '處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊(含台灣中文折數標籤),供機台端套用折扣。正式消耗改由 MQTT finalize 處理 (payment_type=7)。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'passCode' => [
'code' => [
'type' => 'string',
'description' => '贈品碼/優惠券代碼',
'example' => 'FREE888'
'required' => true,
'description' => '8 位數來店禮代碼',
'example' => '12345678'
]
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '業務狀態碼',
'example' => 200
],
'data.name' => [
'type' => 'string',
'description' => '來店禮名稱',
'example' => '新客八五折'
],
'data.code_id' => [
'type' => 'integer',
'description' => '來店禮 ID (用於 MQTT 交易關聯payment_type=7 時帶入)',
'example' => 12
],
'data.discount_type' => [
'type' => 'string',
'description' => '折扣類型percentage (趴數折扣) 或 amount (金額折扣)',
'example' => 'percentage'
],
'data.discount_value' => [
'type' => 'integer',
'description' => '折扣數值。percentage 時為趴數 (如 15 代表 15% off)amount 時為折扣金額 (如 50)',
'example' => 15
],
'data.discount_label' => [
'type' => 'string',
'description' => '台灣中文折數標籤 (如「八五折」、「折 50 元」),供 UI 直接顯示',
'example' => '八五折'
],
'data.usage_type' => [
'type' => 'string',
'description' => '使用類型once (單次) 或 unlimited (無限制)',
'example' => 'once'
],
'data.status' => [
'type' => 'string',
'description' => '來店禮狀態',
'example' => 'active'
],
],
'request' => [
'code' => '12345678'
],
'response' => [
'success' => true,
'code' => 200,
'data' => [
'res1' => 'cloud_id_123',
'message' => 'Free gift verified'
'name' => '新客八五折',
'code_id' => 12,
'discount_type' => 'percentage',
'discount_value' => 15,
'discount_label' => '八五折',
'usage_type' => 'once',
'status' => 'active'
]
],
'notes' => '消耗流程已遷移至 MQTT。'
'notes' => '安全性:連續錯誤 5 次將鎖定該機台驗證功能 1 分鐘。消耗流程已遷移至 MQTT (payment_type=7)。'
],
],
],

View File

@ -0,0 +1,55 @@
<?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::create('welcome_gifts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('name', 100)->comment('來店禮名稱');
$table->string('code', 10)->comment('來店禮代碼 (8位)');
$table->string('slug', 16)->unique()->comment('票券 Slug');
$table->string('discount_type')->comment('折扣類型: percentage (趴數), amount (金額)');
$table->integer('discount_value')->comment('折扣值 (趴數或折抵金額)');
$table->string('usage_type')->default('once')->comment('使用次數: once (單次), unlimited (無限制)');
$table->integer('usage_limit')->nullable()->default(1)->comment('使用次數上限');
$table->integer('usage_count')->default(0)->comment('已使用次數');
$table->timestamp('expires_at')->nullable()->comment('到期時間');
$table->string('status')->default('active')->comment('狀態: active, disabled, used, expired');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->index(['company_id', 'machine_id', 'code', 'status']);
});
Schema::create('welcome_gift_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->foreignId('welcome_gift_id')->constrained('welcome_gifts')->onDelete('cascade');
$table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null');
$table->string('action')->comment('動作: verify_success, consume, cancel');
$table->string('remark')->nullable();
$table->json('raw_data')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('welcome_gift_logs');
Schema::dropIfExists('welcome_gifts');
}
};

View File

@ -0,0 +1,33 @@
<?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('orders', function (Blueprint $table) {
$table->foreignId('welcome_gift_id')
->nullable()
->after('code_id')
->constrained('welcome_gifts')
->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropForeign(['welcome_gift_id']);
$table->dropColumn('welcome_gift_id');
});
}
};

View File

@ -0,0 +1,27 @@
<?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('apk_versions', function (Blueprint $table) {
$table->id();
$table->string('version_name'); // 例如 '10_08_6_R'
$table->integer('version_code')->unique(); // 例如 100806用於比對版本新舊必須遞增
$table->string('flavor'); // 機台風味,例如 'S_12_XY_U_Standard_'
$table->string('file_path')->nullable(); // 本地上傳的 APK 儲存路徑
$table->string('download_url')->nullable(); // 外連下載 URL (如果未使用本地上傳)
$table->text('release_notes')->nullable(); // 版本更新說明
$table->boolean('is_forced')->default(false); // 是否強制更新
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('apk_versions');
}
};

View File

@ -64,6 +64,7 @@ class RoleSeeder extends Seeder
'menu.basic.machines',
'menu.basic.payment-configs',
'menu.basic.discord-notifications',
'menu.basic.apk-versions',
'menu.permissions',
'menu.permissions.companies',
'menu.permissions.accounts',
@ -125,6 +126,7 @@ class RoleSeeder extends Seeder
'menu.special-permission.apk-versions',
'menu.special-permission.discord-notifications',
'menu.basic.discord-notifications',
'menu.basic.apk-versions',
]);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -98,7 +98,7 @@
<td class="px-6 py-6" width="30%">
<div class="flex flex-wrap gap-1 max-w-xs">
@php
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions'];
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.basic', 'menu.permissions'];
$displayPermissions = $role->permissions->filter(function($p) use ($activeModules) {
if ($p->name === 'menu.data-config.sub-account-roles') return false;
if (str_starts_with($p->name, 'menu.')) {

View File

@ -0,0 +1,285 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-4 pb-20">
<!-- Header -->
<x-page-header
:title="__('Upload New APK')"
:subtitle="__('Register a new firmware version for OTA deployment')"
>
<x-slot name="back">
<a href="{{ route('admin.basic-settings.apk-versions.index') }}" class="p-2.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 text-slate-500 hover:text-cyan-500 transition-all shadow-sm flex items-center justify-center">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/></svg>
</a>
</x-slot>
<button type="submit" form="create-apk-form" class="btn-luxury-primary px-8 flex items-center gap-2">
<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="m4.5 12.75 6 6 9-13.5"/></svg>
<span>{{ __('Save Version') }}</span>
</button>
</x-page-header>
<form id="create-apk-form"
x-data="{
uploadMode: 'file',
fileName: '',
isDragging: false,
isForced: false,
submitForm() {
const form = this.$el;
const versionName = form.querySelector('[name=version_name]').value.trim();
const versionCode = form.querySelector('[name=version_code]').value.trim();
if (!versionName) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Please enter version name') }}', type: 'error' }
}));
return;
}
if (!versionCode || isNaN(versionCode) || parseInt(versionCode) <= 0) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Version code must be a positive integer') }}', type: 'error' }
}));
return;
}
if (this.uploadMode === 'file') {
const fileInput = form.querySelector('[name=apk_file]');
if (!fileInput.files.length) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Please select an APK file to upload') }}', type: 'error' }
}));
return;
}
} else {
const urlInput = form.querySelector('[name=download_url]').value.trim();
if (!urlInput) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Please provide a download URL') }}', type: 'error' }
}));
return;
}
}
form.submit();
},
handleFileDrop(event) {
this.isDragging = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
const fileInput = this.$refs.apkFileInput;
const dt = new DataTransfer();
dt.items.add(files[0]);
fileInput.files = dt.files;
this.fileName = files[0].name;
}
},
handleFileSelect(event) {
if (event.target.files.length > 0) {
this.fileName = event.target.files[0].name;
}
}
}"
@submit.prevent="submitForm"
action="{{ route('admin.basic-settings.apk-versions.store') }}"
method="POST"
enctype="multipart/form-data"
class="space-y-6"
novalidate>
@csrf
{{-- 錯誤提示 --}}
@if ($errors->any())
<div class="p-5 bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 rounded-2xl flex items-start gap-4 animate-luxury-in font-bold">
<div class="w-8 h-8 bg-rose-500/20 rounded-xl flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</div>
<div>
<p class="text-sm tracking-wide">{{ __('Please check the following errors:') }}</p>
<ul class="mt-1 text-xs list-disc list-inside opacity-80">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
{{-- 左欄:版本基本資訊 --}}
<div class="lg:col-span-7 space-y-6">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in group/card">
<div class="flex items-center gap-5 mb-8">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500/10 to-teal-500/10 flex items-center justify-center text-cyan-500 border border-cyan-500/20 shadow-lg shadow-cyan-500/5 group-hover/card:scale-110 transition-transform duration-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/></svg>
</div>
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight font-display uppercase">{{ __('Version Information') }}</h3>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ __('Firmware identification details') }}</p>
</div>
</div>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{{-- 版本名稱 --}}
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Version Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="version_name" value="{{ old('version_name') }}" required class="luxury-input w-full" placeholder="{{ __('e.g., 10_08_6_R') }}">
<p class="mt-1.5 text-[10px] font-bold text-slate-400/70 dark:text-slate-500/70 tracking-wide">{{ __('Format: Major_Minor_Patch_R') }}</p>
</div>
{{-- 版本代碼 --}}
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Version Code') }} <span class="text-rose-500">*</span></label>
<input type="number" name="version_code" value="{{ old('version_code') }}" required min="1" class="luxury-input w-full" placeholder="{{ __('e.g., 100806') }}">
<p class="mt-1.5 text-[10px] font-bold text-slate-400/70 dark:text-slate-500/70 tracking-wide">{{ __('Must be strictly greater than previous version') }}</p>
</div>
</div>
{{-- 機台風味 --}}
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Machine Flavor') }} <span class="text-rose-500">*</span></label>
<select name="flavor" required class="luxury-input w-full">
<option value="" disabled {{ old('flavor') ? '' : 'selected' }}>{{ __('Select flavor...') }}</option>
<option value="S_12_XY_U_Standard_" {{ old('flavor') == 'S_12_XY_U_Standard_' ? 'selected' : '' }}>S_12_XY_U_Standard_ (S號 / Android 12 / XY主板)</option>
</select>
<p class="mt-1.5 text-[10px] font-bold text-slate-400/70 dark:text-slate-500/70 tracking-wide">{{ __('Target hardware flavor for this APK') }}</p>
</div>
{{-- 更新說明 --}}
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Release Notes') }}</label>
<textarea name="release_notes" rows="4" class="luxury-input w-full resize-none" placeholder="{{ __('Describe the changes in this version...') }}">{{ old('release_notes') }}</textarea>
</div>
</div>
</div>
</div>
{{-- 右欄:檔案上傳與更新模式 --}}
<div class="lg:col-span-5 space-y-6">
{{-- 上傳模式卡片 --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in group/card" style="animation-delay: 50ms">
<div class="flex items-center gap-5 mb-8">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500/10 to-indigo-500/10 flex items-center justify-center text-violet-500 border border-violet-500/20 shadow-lg shadow-violet-500/5 group-hover/card:scale-110 transition-transform duration-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>
</div>
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight font-display uppercase">{{ __('APK Source') }}</h3>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ __('Upload file or provide URL') }}</p>
</div>
</div>
{{-- 上傳模式切換 --}}
<div class="flex rounded-xl bg-slate-100 dark:bg-slate-800/80 p-1 mb-6">
<button type="button"
@click="uploadMode = 'file'"
:class="uploadMode === 'file' ? 'bg-white dark:bg-slate-700 text-cyan-600 dark:text-cyan-400 shadow-sm' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'"
class="flex-1 py-2.5 text-xs font-black uppercase tracking-widest rounded-lg transition-all duration-300 flex items-center justify-center gap-2">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>
{{ __('File Upload') }}
</button>
<button type="button"
@click="uploadMode = 'url'"
:class="uploadMode === 'url' ? 'bg-white dark:bg-slate-700 text-cyan-600 dark:text-cyan-400 shadow-sm' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'"
class="flex-1 py-2.5 text-xs font-black uppercase tracking-widest rounded-lg transition-all duration-300 flex items-center justify-center gap-2">
<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.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.757m9.86-1.637a4.5 4.5 0 00-1.242-7.244l-4.5-4.5a4.5 4.5 0 00-6.364 6.364L4.343 8.25"/></svg>
{{ __('External URL') }}
</button>
</div>
{{-- 檔案上傳區域 --}}
<div x-show="uploadMode === 'file'" x-transition>
<div class="relative border-2 border-dashed rounded-2xl p-8 text-center transition-all duration-300 cursor-pointer"
:class="isDragging ? 'border-cyan-500 bg-cyan-500/5 dark:bg-cyan-500/10 scale-[1.02]' : 'border-slate-200 dark:border-slate-700 hover:border-cyan-400 dark:hover:border-cyan-600 hover:bg-slate-50/50 dark:hover:bg-slate-800/50'"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop($event)"
@click="$refs.apkFileInput.click()">
<input type="file"
name="apk_file"
x-ref="apkFileInput"
accept=".apk"
class="hidden"
@change="handleFileSelect($event)">
<div class="flex flex-col items-center gap-4">
<div class="w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-300"
:class="isDragging ? 'bg-cyan-500 text-white scale-110' : fileName ? 'bg-emerald-500 text-white' : 'bg-slate-100 dark:bg-slate-800 text-slate-400'">
<svg x-show="!fileName" class="w-8 h-8 stroke-[1.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5h10.5a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0017.25 4.5H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z"/></svg>
<svg x-show="fileName" class="w-8 h-8 stroke-[1.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div>
<p x-show="!fileName" class="text-sm font-extrabold text-slate-600 dark:text-slate-300">
{{ __('Drag & drop your APK here') }}
</p>
<p x-show="fileName" class="text-sm font-extrabold text-emerald-600 dark:text-emerald-400" x-text="fileName"></p>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-2">
{{ __('Maximum file size: 100MB') }} · .apk
</p>
</div>
</div>
</div>
</div>
{{-- 外連 URL 輸入區域 --}}
<div x-show="uploadMode === 'url'" x-transition>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Download URL') }}</label>
<input type="url" name="download_url" value="{{ old('download_url') }}" class="luxury-input w-full" placeholder="https://example.com/release/app-v10.8.6.apk">
<p class="mt-1.5 text-[10px] font-bold text-slate-400/70 dark:text-slate-500/70 tracking-wide">{{ __('Provide a publicly accessible direct download link') }}</p>
</div>
</div>
{{-- 更新模式卡片 --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in group/card" style="animation-delay: 100ms">
<div class="flex items-center gap-5 mb-8">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500/10 to-orange-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20 shadow-lg shadow-amber-500/5 group-hover/card:scale-110 transition-transform duration-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
</div>
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight font-display uppercase">{{ __('Update Mode') }}</h3>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">{{ __('Control deployment behavior') }}</p>
</div>
</div>
{{-- 強制更新開關 --}}
<div class="flex items-center justify-between p-5 rounded-2xl border transition-all duration-300"
:class="isForced
? 'bg-rose-500/10 dark:bg-rose-500/10 border-rose-500/20 dark:border-rose-500/30 text-rose-600 dark:text-rose-400 shadow-sm shadow-rose-500/5'
: 'bg-slate-50/80 dark:bg-slate-800/40 border-slate-100 dark:border-slate-800/80 text-slate-700 dark:text-slate-200'">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300"
:class="isForced ? 'bg-rose-500/10 text-rose-500' : 'bg-slate-100 dark:bg-slate-800 text-slate-400'">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
</div>
<div>
<p class="text-sm font-extrabold transition-colors"
:class="isForced ? 'text-rose-600 dark:text-rose-400' : 'text-slate-700 dark:text-slate-200'">{{ __('Forced Update') }}</p>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 tracking-wide mt-0.5">{{ __('During OTA deployment, the device will install directly in the background without user confirmation.') }}</p>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer shrink-0">
<input type="hidden" name="is_forced" value="0">
<input type="checkbox" name="is_forced" value="1" x-model="isForced" class="sr-only peer" {{ old('is_forced') ? 'checked' : '' }}>
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-slate-600 peer-checked:bg-rose-500"></div>
</label>
</div>
{{-- 注意事項 --}}
<div class="mt-6 p-5 rounded-2xl bg-amber-500/10 border border-amber-500/20 shadow-sm shadow-amber-500/5">
<div class="flex items-start gap-4">
<svg class="w-5 h-5 stroke-[2.5] text-amber-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
<div class="text-xs font-bold text-amber-800 dark:text-amber-300 tracking-wide leading-relaxed">
<p class="text-sm font-black uppercase tracking-widest mb-1.5 flex items-center gap-1">{{ __('Important Notice') }}</p>
<p class="font-bold opacity-90 leading-relaxed">{{ __('Version Code must be strictly greater than the previous release, otherwise the OTA silent installation on the device will fail with INSTALL_FAILED_ALREADY_EXISTS.') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,398 @@
@extends('layouts.admin')
@php
$machinesJson = $machines->map(function($m) {
return [
'id' => $m->id,
'name' => $m->name,
'serial_no' => $m->serial_no,
'status' => $m->status, // online / offline
'firmware_version' => $m->firmware_version ?? '', // 設備目前的版本或風味
'location' => $m->location ?? '',
];
});
@endphp
@section('content')
<script>
window.apkVersionApp = function() {
return {
isDeleteConfirmOpen: false,
deleteFormAction: '',
confirmDelete(action) {
this.deleteFormAction = action;
this.isDeleteConfirmOpen = true;
},
// OTA 派發 Modal 相關狀態
isPushModalOpen: false,
pushActionUrl: '',
selectedVersionName: '',
selectedFlavor: '',
searchMachineQuery: '',
onlyOnlineMachines: false,
selectedMachineIds: [],
machines: @json($machinesJson),
openPushModal(versionName, flavor, actionUrl) {
this.selectedVersionName = versionName;
this.selectedFlavor = flavor;
this.pushActionUrl = actionUrl;
this.selectedMachineIds = [];
this.searchMachineQuery = '';
this.onlyOnlineMachines = false;
this.isPushModalOpen = true;
},
get filteredMachines() {
return this.machines.filter(m => {
const matchesSearch = m.name.toLowerCase().includes(this.searchMachineQuery.toLowerCase()) ||
m.serial_no.toLowerCase().includes(this.searchMachineQuery.toLowerCase()) ||
m.location.toLowerCase().includes(this.searchMachineQuery.toLowerCase());
const matchesOnline = !this.onlyOnlineMachines || m.status === 'online';
// 可以根據 flavor 進行模糊匹配或若機台 firmware_version 包含特定字串
const matchesFlavor = !this.selectedFlavor ||
m.firmware_version.toLowerCase().includes(this.selectedFlavor.toLowerCase()) ||
m.name.toLowerCase().includes(this.selectedFlavor.toLowerCase());
return matchesSearch && matchesOnline;
});
},
toggleAllMachines(checked) {
if (checked) {
this.selectedMachineIds = this.filteredMachines.map(m => m.id);
} else {
this.selectedMachineIds = [];
}
},
isLoadingTable: false,
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) throw new Error('Network response was not ok');
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
history.pushState(null, '', url);
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit();
}
}
} catch (error) {
console.error('Fetch error:', error);
window.location.href = url;
} finally {
this.isLoadingTable = false;
}
}
};
};
</script>
<div class="space-y-4 pb-20" x-data="apkVersionApp()">
<!-- Header -->
<x-page-header
:title="__('APK Versions')"
:subtitle="__('OTA firmware update and version control')"
>
<a href="{{ route('admin.basic-settings.apk-versions.create') }}" class="btn-luxury-primary flex items-center gap-2">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<span>{{ __('Upload New APK') }}</span>
</a>
</x-page-header>
<!-- Content Container with Loading State -->
<div id="ajax-content-container"
class="relative transition-all duration-500 min-h-[400px]"
:class="isLoadingTable ? 'opacity-40 blur-[2px] pointer-events-none' : 'opacity-100 blur-0'"
@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); }">
<!-- Loading Spinner Overlay -->
<div x-show="isLoadingTable"
class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] rounded-3xl" x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
</div>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">{{ __('Loading Data') }}...</p>
</div>
@if(session('success'))
<div class="p-4 mb-6 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 font-extrabold flex items-center gap-3">
<svg class="w-5 h-5 shrink-0 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>{{ session('success') }}</span>
</div>
@endif
<!-- Main Card -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<div class="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-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Version Info') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Flavor') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Update Mode') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Release Notes') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($versions as $version)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 font-extrabold text-slate-800 dark:text-slate-100">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl 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">
<svg class="w-5 h-5 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/></svg>
</div>
<div>
<div 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 whitespace-nowrap">
{{ $version->version_name }}
</div>
<div class="text-xs font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest mt-0.5">
Code: {{ $version->version_code }}
</div>
</div>
</div>
</td>
<td class="px-6 py-6">
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
{{ $version->flavor }}
</span>
</td>
<td class="px-6 py-6">
@if($version->is_forced)
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-rose-100 dark:border-rose-900/30 bg-rose-50 dark:bg-rose-900/20 text-rose-600 dark:text-rose-400 tracking-widest uppercase">
{{ __('Forced Update') }}
</span>
@else
<span class="px-2.5 py-1 rounded-lg text-xs font-bold border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 tracking-widest uppercase">
{{ __('Optional Update') }}
</span>
@endif
</td>
<td class="px-6 py-6 max-w-xs truncate">
<span class="text-sm font-medium text-slate-500 dark:text-slate-400 leading-relaxed">
{{ $version->release_notes ?: '-' }}
</span>
</td>
<td class="px-6 py-6 text-right space-x-2 whitespace-nowrap">
<!-- OTA 派發按鈕 -->
<button type="button"
@click="openPushModal('{{ $version->version_name }}', '{{ $version->flavor }}', '{{ route('admin.basic-settings.apk-versions.push', $version) }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 border border-transparent hover:border-cyan-500/20 transition-all inline-flex"
title="{{ __('OTA Deploy') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5h10.5a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0017.25 4.5H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z"/></svg>
</button>
<!-- 下載連結 -->
<a href="{{ $version->url }}"
target="_blank"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 border border-transparent hover:border-emerald-500/20 transition-all inline-flex"
title="{{ __('Download APK') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
</a>
<!-- 刪除按鈕 -->
<form action="{{ route('admin.basic-settings.apk-versions.destroy', $version) }}" method="POST" class="inline-block" @submit.prevent="confirmDelete($el.getAttribute('action'))">
@csrf
@method('DELETE')
<button type="submit"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 border border-transparent hover:border-rose-500/20 transition-all"
title="{{ __('Delete') }}">
<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 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-20 text-center">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-slate-50 dark:bg-slate-800/50 mb-6 border border-slate-100 dark:border-slate-800 shadow-sm">
<svg class="w-10 h-10 text-slate-300 dark:text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5h10.5a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0017.25 4.5H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z"/></svg>
</div>
<p class="text-base font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('No versions found') }}</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $versions->links('vendor.pagination.luxury') }}
</div>
</div>
</div>
<!-- Global Delete Confirm Modal -->
<x-delete-confirm-modal :message="__('Are you sure you want to delete this APK version? This action will permanently remove the record and file.')" />
<!-- OTA 派發模態彈窗 (Custom Luxury Modal using Alpine.js) -->
<div x-show="isPushModalOpen"
class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" x-cloak>
<!-- 背景遮罩 -->
<div class="fixed inset-0 bg-slate-900/60 dark:bg-slate-950/80 backdrop-blur-sm" @click="isPushModalOpen = false"></div>
<!-- 彈窗主體 -->
<div class="relative w-full max-w-2xl mx-auto my-6 px-4 z-50">
<div class="relative flex flex-col w-full bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 rounded-3xl shadow-2xl outline-none focus:outline-none overflow-hidden animate-luxury-in">
<!-- 彈窗 Header -->
<div class="flex items-center justify-between p-6 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white flex items-center gap-2 font-display">
<span class="p-1.5 rounded-lg bg-cyan-500 text-white leading-none">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5h10.5a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0017.25 4.5H6.75A2.25 2.25 0 004.5 6.75v10.5a2.25 2.25 0 002.25 2.25z"/></svg>
</span>
<span>{{ __('OTA Update Deployment') }}</span>
</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 tracking-wider uppercase mt-1">
{{ __('Deploy version') }}: <span class="text-cyan-500" x-text="selectedVersionName"></span> (<span x-text="selectedFlavor"></span>)
</p>
</div>
<button type="button"
@click="isPushModalOpen = false"
class="p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-white transition-colors">
<svg class="w-6 h-6 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 彈窗內容 -->
<div class="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
<!-- 搜尋與篩選列 -->
<div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
<div class="relative flex-1 group">
<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 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"
x-model="searchMachineQuery"
placeholder="{{ __('Search machine name, S/N, or location...') }}"
class="luxury-input py-2 pl-12 pr-6 block w-full text-sm">
</div>
<div class="flex items-center gap-2">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" x-model="onlyOnlineMachines" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-cyan-500"></div>
<span class="ml-2 text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{ __('Online Only') }}</span>
</label>
</div>
</div>
<!-- 表單與機台選擇清單 -->
<form :action="pushActionUrl" method="POST" id="ota-push-form">
@csrf
<div class="border border-slate-100 dark:border-slate-800/80 rounded-2xl overflow-hidden">
<table class="w-full text-left">
<thead class="bg-slate-50/50 dark:bg-slate-950/40">
<tr>
<th class="px-6 py-3 w-12 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">
<input type="checkbox"
@change="toggleAllMachines($el.checked)"
:checked="selectedMachineIds.length === filteredMachines.length && filteredMachines.length > 0"
class="rounded border-slate-300 dark:border-slate-700 text-cyan-500 focus:ring-cyan-500 dark:bg-slate-900">
</th>
<th class="px-6 py-3 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Machine Detail') }}</th>
<th class="px-6 py-3 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Status') }}</th>
<th class="px-6 py-3 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('App Version') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800">
<template x-for="machine in filteredMachines" :key="machine.id">
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-800/20 transition-all duration-200">
<td class="px-6 py-3">
<input type="checkbox"
name="machine_ids[]"
:value="machine.id"
x-model="selectedMachineIds"
class="rounded border-slate-300 dark:border-slate-700 text-cyan-500 focus:ring-cyan-500 dark:bg-slate-900">
</td>
<td class="px-6 py-3">
<div class="text-sm font-extrabold text-slate-800 dark:text-slate-100" x-text="machine.name"></div>
<div class="text-[10px] font-mono text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="'S/N: ' + machine.serial_no"></div>
</td>
<td class="px-6 py-3">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full"
:class="machine.status === 'online' ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600'"></span>
<span class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400"
x-text="machine.status === 'online' ? '{{ __('Online') }}' : '{{ __('Offline') }}'"></span>
</div>
</td>
<td class="px-6 py-3">
<span class="px-2 py-0.5 rounded text-[10px] font-mono font-bold"
:class="machine.firmware_version.toLowerCase().includes(selectedFlavor.toLowerCase()) && selectedFlavor !== '' ? 'bg-cyan-500/10 text-cyan-500 dark:bg-cyan-500/20 border border-cyan-500/20' : 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400 border border-transparent'"
x-text="machine.firmware_version || '-'"></span>
</td>
</tr>
</template>
<tr x-show="filteredMachines.length === 0">
<td colspan="4" class="px-6 py-12 text-center text-slate-400 dark:text-slate-500 font-bold">
{{ __('No machines match your criteria') }}
</td>
</tr>
</tbody>
</table>
</div>
</form>
</div>
<!-- 彈窗 Footer -->
<div class="flex items-center justify-between p-6 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<div class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
<span x-text="selectedMachineIds.length"></span> {{ __('Devices Selected') }}
</div>
<div class="flex items-center gap-3">
<button type="button"
@click="isPushModalOpen = false"
class="px-5 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 text-sm font-extrabold text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all">
{{ __('Cancel') }}
</button>
<button type="submit"
form="ota-push-form"
:disabled="selectedMachineIds.length === 0"
class="px-5 py-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 text-sm font-extrabold shadow-lg shadow-cyan-500/20 transition-all disabled:opacity-55 disabled:pointer-events-none">
{{ __('Deploy Update') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -30,7 +30,7 @@
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Permissions') }}</p>
@php
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions'];
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.basic', 'menu.permissions'];
$displayPermissions = $role->permissions->filter(function($p) use ($activeModules) {
if ($p->name === 'menu.data-config.sub-account-roles') return false;
if (str_starts_with($p->name, 'menu.')) {

View File

@ -5,9 +5,9 @@
<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">
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" x2="16.65" y2="16.65" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
@ -38,7 +38,18 @@
</svg>
</button>
<button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; syncSelect('filter-status', ''); $el.closest('form').dispatchEvent(new Event('submit'))"
@click="
const selectEl = document.getElementById('filter-status');
if (selectEl) {
selectEl.value = ' ';
if (window.HSSelect) {
const inst = window.HSSelect.getInstance(selectEl);
if (inst) inst.setValue(' ');
}
}
$el.closest('form').querySelector('input[name=search]').value = '';
$el.closest('form').dispatchEvent(new Event('submit'));
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all"
title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">

View File

@ -7,9 +7,11 @@
<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">
<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" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" x2="16.65" y2="16.65"></line>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search_log" value="{{ request('search_log') }}"
@ -24,6 +26,7 @@
:selected="request('action')"
:placeholder="__('All Types')"
:hasSearch="false"
@change="$el.closest('form').dispatchEvent(new Event('submit'))"
>
@foreach($actions as $key => $label)
<option value="{{ $key }}" {{ request('action') === $key ? 'selected' : '' }} data-title="{{ $label }}">
@ -139,12 +142,9 @@
<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">
{{ __('Sales Record') }}</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>
{{ __('Time') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@ -194,35 +194,16 @@
<span class="text-slate-300 dark:text-slate-700">-</span>
@endif
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<td class="px-6 py-5 text-right 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">
@if($log->user)
<div
class="w-7 h-7 rounded-full bg-cyan-100 dark:bg-cyan-900/50 flex items-center justify-center text-cyan-600 dark:text-cyan-400 text-[10px] font-black border border-cyan-200/50 dark:border-cyan-800/50">
{{ Str::substr($log->user->name, 0, 1) }}
</div>
<span
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
{{ $log->user->name }}
</span>
@else
<span
class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
{{ __('Machine') }}
</span>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-20 text-center">
<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"
@ -273,12 +254,6 @@
<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 ?? __('Machine') }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Pass Code') }}</p>

View File

@ -42,7 +42,18 @@
</svg>
</button>
<button type="button"
@click="$el.closest('form').querySelector('input[name=search]').value=''; syncSelect('filter-status', ''); $el.closest('form').dispatchEvent(new Event('submit'))"
@click="
const selectEl = document.getElementById('filter-status');
if (selectEl) {
selectEl.value = ' ';
if (window.HSSelect) {
const inst = window.HSSelect.getInstance(selectEl);
if (inst) inst.setValue(' ');
}
}
$el.closest('form').querySelector('input[name=search]').value = '';
$el.closest('form').dispatchEvent(new Event('submit'));
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 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"

View File

@ -7,9 +7,11 @@
<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">
<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" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" x2="16.65" y2="16.65"></line>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" name="search_log" value="{{ request('search_log') }}"
@ -17,6 +19,23 @@
placeholder="{{ __('Search logs...') }}">
</div>
{{-- 狀態篩選 --}}
<div class="w-full sm:w-64">
<x-searchable-select
name="action"
:selected="request('action')"
:placeholder="__('All Status')"
:hasSearch="false"
@change="$el.closest('form').dispatchEvent(new Event('submit'))"
>
@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-56 sm:flex-none"
x-data="{ fp: null }"
@ -88,6 +107,11 @@
</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"
@ -118,12 +142,9 @@
<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">
{{ __('Sales Record') }}</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>
{{ __('Time') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@ -173,35 +194,16 @@
<span class="text-slate-300 dark:text-slate-700">-</span>
@endif
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<td class="px-6 py-5 text-right 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">
@if($log->user)
<div
class="w-7 h-7 rounded-full bg-cyan-100 dark:bg-cyan-900/50 flex items-center justify-center text-cyan-600 dark:text-cyan-400 text-[10px] font-black border border-cyan-200/50 dark:border-cyan-800/50">
{{ Str::substr($log->user->name, 0, 1) }}
</div>
<span
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
{{ $log->user->name }}
</span>
@else
<span
class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
{{ __('Machine') }}
</span>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-20 text-center">
<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"
@ -252,12 +254,6 @@
<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 ?? __('Machine') }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Pickup Code') }}</p>

View File

@ -0,0 +1,913 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-2 pb-20" x-data="{
activeTab: '{{ $tab ?? "list" }}',
tabLoading: null,
showCreateModal: false,
showEditModal: false,
showDeleteModal: false,
showQrModal: false,
deleteTargetForm: '',
activeQrCode: '',
activeTicketUrl: '',
selectedMachine: '',
giftName: '',
discountType: 'percentage',
discountValInput: '',
usageType: 'once',
usageLimit: 1,
customCode: '',
// Create validity/expiry states
expiryMode: 'permanent',
expiresDays: 7,
customExpiry: '',
editFormAction: '',
editCustomCode: '',
editGiftName: '',
editDiscountType: 'percentage',
editDiscountValInput: '',
editUsageType: 'once',
editUsageLimit: 1,
// Edit validity/expiry states
editExpiryMode: 'permanent',
editExpiresDays: 7,
editCustomExpiry: '',
openEditModal(item) {
this.editFormAction = '{{ route('admin.sales.store-gifts.update', ':id') }}'.replace(':id', item.id);
this.editCustomCode = item.code;
this.editGiftName = item.name;
this.editDiscountType = item.discount_type;
this.editDiscountValInput = item.discount_type === 'percentage' ? item.input_fold : item.discount_value;
this.editUsageType = item.usage_type;
this.editUsageLimit = item.usage_limit || 1;
if (item.expires_at) {
this.editExpiryMode = 'date';
const expDate = new Date(item.expires_at);
this.editCustomExpiry = this.formatDate(expDate);
const today = new Date();
const diffTime = expDate - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
this.editExpiresDays = diffDays > 0 ? diffDays : 7;
} else {
this.editExpiryMode = 'permanent';
this.editCustomExpiry = '';
this.editExpiresDays = 7;
}
this.syncSelect('edit-discount-type', this.editDiscountType);
this.syncSelect('edit-usage-type', this.editUsageType);
this.showEditModal = true;
},
generateRandomCode() {
this.customCode = Math.floor(Math.random() * 90000000 + 10000000).toString();
},
calculateDaysExpiry() {
const date = new Date();
const days = parseInt(this.expiresDays) || 0;
date.setDate(date.getDate() + days);
return this.formatDate(date);
},
getDisplayExpiry() {
if (this.expiryMode === 'permanent') {
return '{{ __("Permanent") }}';
}
if (this.customExpiry) {
return this.customExpiry.replace('T', ' ');
}
return this.calculateDaysExpiry();
},
getSubmittedExpiry() {
if (this.expiryMode === 'permanent') {
return '';
}
if (this.customExpiry) {
return this.customExpiry;
}
return this.calculateDaysExpiry();
},
calculateEditDaysExpiry() {
const date = new Date();
const days = parseInt(this.editExpiresDays) || 0;
date.setDate(date.getDate() + days);
return this.formatDate(date);
},
getDisplayEditExpiry() {
if (this.editExpiryMode === 'permanent') {
return '{{ __("Permanent") }}';
}
if (this.editCustomExpiry) {
return this.editCustomExpiry.replace('T', ' ');
}
return this.calculateEditDaysExpiry();
},
getSubmittedEditExpiry() {
if (this.editExpiryMode === 'permanent') {
return '';
}
if (this.editCustomExpiry) {
return this.editCustomExpiry;
}
return this.calculateEditDaysExpiry();
},
formatDate(date) {
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0');
},
async 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 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, window.location.origin);
historyUrl.searchParams.delete('_ajax');
window.history.pushState({}, '', historyUrl.toString());
}
} catch (e) {
console.error(e);
window.showToast?.('{{ __("Loading failed") }}', 'error');
} finally {
this.tabLoading = null;
}
},
confirmDelete(formId) {
this.deleteTargetForm = formId;
this.showDeleteModal = true;
},
submitDelete() {
if (this.deleteTargetForm) {
const form = document.getElementById(this.deleteTargetForm);
if (form) form.submit();
}
this.showDeleteModal = false;
},
copyQrCode() {
if (!this.activeQrCode) return;
navigator.clipboard.writeText(this.activeQrCode).then(() => {
window.showToast('{{ __("Code Copied") }}', 'success');
});
},
copyQrLink() {
if (!this.activeTicketUrl) return;
navigator.clipboard.writeText(this.activeTicketUrl).then(() => {
window.showToast('{{ __("Link Copied") }}', 'success');
});
},
async downloadQrCode() {
const url = '{{ route('admin.basic-settings.qr-code') }}?size=500&data=' + encodeURIComponent(this.activeQrCode);
fetch(url)
.then(response => response.blob())
.then(blob => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `welcome-gift-${this.activeQrCode}.png`;
link.click();
URL.revokeObjectURL(link.href);
});
},
syncSelect(id, value) {
this.$nextTick(() => {
const el = document.getElementById(id);
if (el) {
const valStr = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
el.value = valStr;
if (window.HSSelect) {
const inst = window.HSSelect.getInstance(el);
if (inst) inst.setValue(valStr);
}
}
});
},
validateAndSubmit(e) {
if (!this.selectedMachine) {
window.showToast('{{ __("Please select a machine") }}', 'error');
return;
}
if (!this.giftName.trim()) {
window.showToast('{{ __("Please enter a name") }}', 'error');
return;
}
if (!this.discountValInput) {
window.showToast('{{ __("Please enter discount value") }}', 'error');
return;
}
if (this.discountType === 'percentage') {
const val = parseFloat(this.discountValInput);
if (isNaN(val) || val <= 0 || val >= 10) {
window.showToast('{{ __("Discount fold must be between 0.1 and 9.9") }}', 'error');
return;
}
} else {
const val = parseInt(this.discountValInput);
if (isNaN(val) || val <= 0) {
window.showToast('{{ __("Discount amount must be a positive integer") }}', 'error');
return;
}
}
e.target.submit();
},
getDiscountHelpText(input) {
const val = parseFloat(input);
if (isNaN(val) || val <= 0 || val >= 10) {
return '';
}
const percentOff = Math.round((10 - val) * 10);
const map = {
'1': '一', '2': '二', '3': '三', '4': '四', '5': '五',
'6': '六', '7': '七', '8': '八', '9': '九', '0': '零'
};
let foldStr = val.toString();
let chineseFold = '';
if (foldStr.includes('.')) {
const parts = foldStr.split('.');
const integer = parts[0];
const decimal = parts[1];
const intChar = map[integer] || integer;
const decChar = map[decimal.charAt(0)] || decimal.charAt(0);
chineseFold = intChar + decChar + '折';
} else {
const char = map[foldStr] || foldStr;
chineseFold = char + '折';
}
return '{{ __("Discount") }}: ' + percentOff + '% off (' + chineseFold + ')';
},
adjustDiscountValue(target, delta) {
const isEdit = target === 'edit';
const type = isEdit ? this.editDiscountType : this.discountType;
const key = isEdit ? 'editDiscountValInput' : 'discountValInput';
const current = parseFloat(this[key]) || 0;
const step = type === 'percentage' ? 0.1 : 1;
let next = current + (delta * step);
if (type === 'percentage') {
next = Math.max(0.1, Math.min(9.9, next));
this[key] = (Math.round(next * 10) / 10).toString();
} else {
next = Math.max(1, Math.round(next));
this[key] = next.toString();
}
},
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 = '';
this.giftName = '';
this.discountType = 'percentage';
this.discountValInput = '';
this.usageType = 'once';
this.usageLimit = 1;
this.expiryMode = 'permanent';
this.expiresDays = 7;
this.customExpiry = '';
this.generateRandomCode();
this.syncSelect('modal-gift-machine', '');
this.syncSelect('create-discount-type', this.discountType);
this.syncSelect('create-usage-type', this.usageType);
}
});
}
} " @ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
{{-- Page Header --}}
<x-page-header :title="__('Welcome Gifts')"
:subtitle="__('Manage welcoming promotion codes for new guests')">
<button @click="showCreateModal = true; generateRandomCode()" class="btn-luxury-primary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('Add Welcome Gift') }}</span>
</button>
</x-page-header>
{{-- Tabs --}}
<x-tab-nav model="activeTab">
<x-tab-nav-item value="list" :label="__('Welcome Gifts')" model="activeTab" />
<x-tab-nav-item value="logs" :label="__('Usage Logs')" 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="tabLoading === 'list'" />
<div id="tab-list-container" class="relative"
:class="{ 'opacity-30 pointer-events-none transition-opacity duration-300': tabLoading === 'list' }">
@include('admin.sales.welcome-gifts.partials.tab-list')
</div>
</div>
</div>
{{-- 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.welcome-gifts.partials.tab-logs')
</div>
</div>
</div>
</div>
</div>
{{-- Create Modal --}}
<div x-show="showCreateModal" class="fixed inset-0 z-[110] overflow-y-auto"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="showCreateModal = false"></div>
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full overflow-visible">
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{
__('Add Welcome Gift') }}</h3>
<button @click="showCreateModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form action="{{ route('admin.sales.store-gifts.store') }}" method="POST" class="space-y-6"
@submit.prevent="validateAndSubmit">
@csrf
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2 relative focus-within:z-[60]">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Target Machine') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="machine_id" id="modal-gift-machine"
:placeholder="__('Please select a machine')" x-model="selectedMachine"
@change="selectedMachine = $event.target.value">
@foreach($machines as $machine)
<option value="{{ $machine->id }}"
data-title="{{ $machine->name }} ({{ $machine->serial_no }})">{{ $machine->name }}
({{ $machine->serial_no }})</option>
@endforeach
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Welcome Gift Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" x-model="giftName" class="luxury-input w-full"
placeholder="{{ __('e.g. New Guest 15% Off Discount') }}" required>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Discount Type') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="discount_type" id="create-discount-type" :hasSearch="false"
x-model="discountType" @change="discountType = $event.target.value">
<option value="percentage" data-title="{{ __('Percentage Discount') }}">{{ __('Percentage Discount') }}</option>
<option value="amount" data-title="{{ __('Amount Discount') }}">{{ __('Amount Discount') }}</option>
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
<span x-show="discountType === 'percentage'">{{ __('Discount Fold') }} ({{ __('e.g. 8.5 for 85折, 8 for 8折') }}) <span class="text-rose-500">*</span></span>
<span x-show="discountType === 'amount'">{{ __('Discount Amount (NTD)') }} <span class="text-rose-500">*</span></span>
</label>
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="adjustDiscountValue('create', -1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" inputmode="decimal" name="discount_val_input" x-model="discountValInput"
class="flex-1 min-w-0 h-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 px-3"
:placeholder="discountType === 'percentage' ? '8.5' : '50'" required>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="adjustDiscountValue('create', 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="mt-1.5 text-xs text-cyan-600 dark:text-cyan-400 font-bold" x-show="discountType === 'percentage' && discountValInput"><span x-text="getDiscountHelpText(discountValInput)"></span></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Usage Type') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="usage_type" id="create-usage-type" :hasSearch="false"
x-model="usageType" @change="usageType = $event.target.value">
<option value="once" data-title="{{ __('Once') }}">{{ __('Once') }}</option>
<option value="unlimited" data-title="{{ __('Unlimited') }}">{{ __('Unlimited') }}</option>
</x-searchable-select>
</div>
<input type="hidden" name="usage_limit" value="1">
</div>
{{-- Welcome Gift Code Section (Flexible) --}}
<div class="space-y-2">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Welcome Gift Code (8 Digits)') }} <span class="text-rose-500">*</span>
</label>
<button type="button" @click="generateRandomCode()"
class="text-[10px] font-black text-cyan-500 hover:text-cyan-600 uppercase tracking-widest flex items-center gap-1 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ __('Regenerate') }}
</button>
</div>
<input type="text" name="custom_code" x-model="customCode"
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5 uppercase"
maxlength="12" required>
</div>
{{-- Validity Period Section --}}
<div class="space-y-4">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period') }}</label>
<div class="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-xl">
<button type="button" @click="expiryMode = 'permanent'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="expiryMode === 'permanent' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Permanent') }}</button>
<button type="button" @click="expiryMode = 'date'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="expiryMode === 'date' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Custom Expiry') }}</button>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] p-6 border border-slate-100 dark:border-slate-800/50 shadow-sm">
<input type="hidden" name="expires_at" :value="getSubmittedExpiry()">
<div x-show="expiryMode === 'date'" x-transition class="space-y-6">
{{-- Days Stepper --}}
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 pl-1">{{ __('Validity Period (Days)') }}</label>
<div class="flex flex-col sm:flex-row items-center gap-4">
{{-- Unified Luxury Counter --}}
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 min-w-[150px] group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="expiresDays = Math.max(1, parseInt(expiresDays || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-[50px]">
<input type="text" x-model="expiresDays" readonly
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="expiresDays = parseInt(expiresDays || 1) + 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
{{-- Quick Select Buttons --}}
<div class="flex items-center gap-1">
<template x-for="val in [1, 3, 7, 30]">
<button type="button" @click="expiresDays = val; customExpiry = ''"
class="w-10 h-8 rounded-lg border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[10px] font-black transition-all shrink-0"
:class="expiresDays == val && !customExpiry ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val + ' ' + '{{ __('d') }}'"></span>
</button>
</template>
</div>
</div>
</div>
{{-- Custom Expiry Input --}}
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 pl-1">{{ __('Or Choose Custom Date') }}</label>
<div class="relative group">
<input type="text" x-model="customExpiry"
x-init="const fp = flatpickr($el, {
enableTime: true,
dateFormat: 'Y-m-d H:i',
time_24hr: true,
minuteIncrement: 1,
disableMobile: true,
locale: window.flatpickrLocale
}); $watch('customExpiry', v => fp.setDate(v, false))"
class="luxury-input w-full py-3 text-center font-mono text-base bg-white dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 pl-4 pr-12 focus:border-cyan-500 transition-all"
placeholder="{{ __('Choose Date') }}">
<div class="absolute right-5 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-cyan-500 transition-colors pointer-events-none">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
</div>
</div>
</div>
</div>
{{-- Expected Expiry Card --}}
<div class="pt-6" :class="{ 'border-t border-slate-100 dark:border-slate-800/50 mt-6': expiryMode === 'date' }">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Estimated Expiry') }}</span>
<span class="text-[10px] font-bold text-slate-400/70 tracking-wide" x-text="expiryMode === 'permanent' ? '{{ __('Permanent') }}' : '{{ __('Expiry Date Set') }}'"></span>
</div>
<div class="bg-white dark:bg-slate-900/50 rounded-2xl p-4 border border-slate-100 dark:border-slate-700/50 flex items-center justify-center gap-3">
<svg class="w-5 h-5 text-cyan-500/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xl font-mono font-black text-slate-700 dark:text-slate-200 tracking-wider" x-text="getDisplayExpiry()"></span>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }}
</button>
<button type="submit" class="btn-luxury-primary px-12">
{{ __('Create') }}
</button>
</div>
</form>
</div>
</div>
</div>
{{-- Edit Modal --}}
<div x-show="showEditModal" class="fixed inset-0 z-[110] 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 Welcome Gift') }}</h3>
<button @click="showEditModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form :action="editFormAction" method="POST" class="space-y-6">
@csrf
@method('PATCH')
{{-- Readonly Welcome Gift Code Section --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Welcome Gift Code') }}
</label>
<input type="text" x-model="editCustomCode" readonly
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/10 dark:bg-cyan-500/5 cursor-not-allowed opacity-80 uppercase"
maxlength="12">
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Welcome Gift Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" x-model="editGiftName" class="luxury-input w-full"
placeholder="{{ __('e.g. New Guest 15% Off Discount') }}" required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Discount Type') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="discount_type" id="edit-discount-type" :hasSearch="false"
x-model="editDiscountType" @change="editDiscountType = $event.target.value">
<option value="percentage" data-title="{{ __('Percentage Discount') }}">{{ __('Percentage Discount') }}</option>
<option value="amount" data-title="{{ __('Amount Discount') }}">{{ __('Amount Discount') }}</option>
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
<span x-show="editDiscountType === 'percentage'">{{ __('Discount Fold') }} <span class="text-rose-500">*</span></span>
<span x-show="editDiscountType === 'amount'">{{ __('Discount Amount (NTD)') }} <span class="text-rose-500">*</span></span>
</label>
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="adjustDiscountValue('edit', -1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" inputmode="decimal" name="discount_val_input" x-model="editDiscountValInput"
class="flex-1 min-w-0 h-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 px-3"
required>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="adjustDiscountValue('edit', 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="mt-1.5 text-xs text-cyan-600 dark:text-cyan-400 font-bold" x-show="editDiscountType === 'percentage' && editDiscountValInput"><span x-text="getDiscountHelpText(editDiscountValInput)"></span></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Usage Type') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="usage_type" id="edit-usage-type" :hasSearch="false"
x-model="editUsageType" @change="editUsageType = $event.target.value">
<option value="once" data-title="{{ __('Once') }}">{{ __('Once') }}</option>
<option value="unlimited" data-title="{{ __('Unlimited') }}">{{ __('Unlimited') }}</option>
</x-searchable-select>
</div>
<input type="hidden" name="usage_limit" value="1">
</div>
{{-- Validity Period Section --}}
<div class="space-y-4">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Validity Period') }}</label>
<div class="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-xl">
<button type="button" @click="editExpiryMode = 'permanent'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="editExpiryMode === 'permanent' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Permanent') }}</button>
<button type="button" @click="editExpiryMode = 'date'"
class="px-3 py-1 text-[10px] font-black uppercase tracking-tighter rounded-lg transition-all"
:class="editExpiryMode === 'date' ? 'bg-white dark:bg-slate-700 text-cyan-600 shadow-sm' : 'text-slate-400'">{{ __('Custom Expiry') }}</button>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-800/40 rounded-[2rem] p-6 border border-slate-100 dark:border-slate-800/50 shadow-sm">
<input type="hidden" name="expires_at" :value="getSubmittedEditExpiry()">
<div x-show="editExpiryMode === 'date'" x-transition class="space-y-6">
{{-- Days Stepper --}}
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 pl-1">{{ __('Validity Period (Days)') }}</label>
<div class="flex flex-col sm:flex-row items-center gap-4">
{{-- Unified Luxury Counter --}}
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 min-w-[150px] group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="editExpiresDays = Math.max(1, parseInt(editExpiresDays || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-[50px]">
<input type="text" x-model="editExpiresDays" readonly
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="editExpiresDays = parseInt(editExpiresDays || 1) + 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
{{-- Quick Select Buttons --}}
<div class="flex items-center gap-1">
<template x-for="val in [1, 3, 7, 30]">
<button type="button" @click="editExpiresDays = val; editCustomExpiry = ''"
class="w-10 h-8 rounded-lg border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[10px] font-black transition-all shrink-0"
:class="editExpiresDays == val && !editCustomExpiry ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val + ' ' + '{{ __('d') }}'"></span>
</button>
</template>
</div>
</div>
</div>
{{-- Custom Expiry Input --}}
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3 pl-1">{{ __('Or Choose Custom Date') }}</label>
<div class="relative group">
<input type="text" x-model="editCustomExpiry"
x-init="const fp = flatpickr($el, {
enableTime: true,
dateFormat: 'Y-m-d H:i',
time_24hr: true,
minuteIncrement: 1,
disableMobile: true,
locale: window.flatpickrLocale
}); $watch('editCustomExpiry', v => fp.setDate(v, false))"
class="luxury-input w-full py-3 text-center font-mono text-base bg-white dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 pl-4 pr-12 focus:border-cyan-500 transition-all"
placeholder="{{ __('Choose Date') }}">
<div class="absolute right-5 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-cyan-500 transition-colors pointer-events-none">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
</div>
</div>
</div>
</div>
{{-- Expected Expiry Card --}}
<div class="pt-6" :class="{ 'border-t border-slate-100 dark:border-slate-800/50 mt-6': editExpiryMode === 'date' }">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Estimated Expiry') }}</span>
<span class="text-[10px] font-bold text-slate-400/70 tracking-wide" x-text="editExpiryMode === 'permanent' ? '{{ __('Permanent') }}' : '{{ __('Expiry Date Set') }}'"></span>
</div>
<div class="bg-white dark:bg-slate-900/50 rounded-2xl p-4 border border-slate-100 dark:border-slate-700/50 flex items-center justify-center gap-3">
<svg class="w-5 h-5 text-cyan-500/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xl font-mono font-black text-slate-700 dark:text-slate-200 tracking-wider" x-text="getDisplayEditExpiry()"></span>
</div>
</div>
</div>
</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 Welcome Gift') }}
</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">
{{ __('Welcome Gift 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" />
</div>
<div class="text-center w-full space-y-4">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 leading-relaxed px-4">
{{ __('Scan this code at the machine or share the link with the customer.') }}
</p>
<div class="flex items-center justify-center gap-3">
<button @click="copyQrLink()"
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-slate-100 dark:border-slate-700 hover:border-cyan-500/20 transition-all flex items-center justify-center gap-2 group"
title="{{ __('Copy Link') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13.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>
<button @click="copyQrCode()"
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-slate-100 dark:border-slate-700 hover:border-cyan-500/20 transition-all flex items-center justify-center gap-2 group"
title="{{ __('Copy Code') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span class="text-xs font-black uppercase tracking-widest">{{ __('Copy') }}</span>
</button>
<button @click="downloadQrCode()"
class="flex-1 py-3 px-4 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-slate-100 dark:border-slate-700 hover:border-emerald-500/20 transition-all flex items-center justify-center gap-2 group"
title="{{ __('Download Image') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 12l4.5 4.5m0 0l4.5-4.5M12 3v13.5" />
</svg>
<span class="text-xs font-black uppercase tracking-widest">{{ __('Save') }}</span>
</button>
</div>
</div>
</div>
<div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-center border-t border-slate-100 dark:border-slate-800">
<button @click="showQrModal = false" class="btn-luxury-primary w-full py-4 rounded-2xl">{{
__('Close') }}</button>
</div>
</div>
</div>
</div>
{{-- Delete Modal --}}
<x-confirm-modal alpineVar="showDeleteModal" confirmAction="submitDelete()" :title="__('Confirm Deactivation')"
:message="__('Are you sure you want to deactivate this welcome gift? It will no longer be usable by guests.')"
:confirmText="__('Yes, Deactivate')" :cancelText="__('Cancel')" iconType="danger" confirmColor="rose" />
</div>
@endsection

View File

@ -0,0 +1,355 @@
<form action="{{ route('admin.sales.store-gifts') }}" 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" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search by code, name or machine...') }}">
</div>
<div class="w-full md:w-48 relative focus-within:z-20">
<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="
const selectEl = document.getElementById('filter-status');
if (selectEl) {
selectEl.value = ' ';
if (window.HSSelect) {
const inst = window.HSSelect.getInstance(selectEl);
if (inst) inst.setValue(' ');
}
}
$el.closest('form').querySelector('input[name=search]').value = '';
$el.closest('form').dispatchEvent(new Event('submit'));
"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all"
title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</form>
{{-- Table (Desktop) --}}
<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">
{{ __('Welcome Gift 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">
{{ __('Discount Value') }}
</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">
{{ __('Usage 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">
{{ __('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 ($welcomeGifts as $gift)
<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($gift->status === 'active' && (!$gift->expires_at || $gift->expires_at->isFuture()))
<span
@click="activeQrCode = '{{ $gift->code }}'; activeTicketUrl = '{{ $gift->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">
{{ $gift->code }}
</span>
@else
<span 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">
{{ $gift->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">
{{ $gift->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$gift->machine->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<span class="text-sm font-black text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 px-3 py-1 rounded-lg border border-indigo-100 dark:border-indigo-500/20">
{{ $gift->discount_label }}
</span>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($gift->usage_type === 'unlimited')
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded border border-slate-200 dark:border-slate-700">
{{ __('Unlimited') }} ({{ __('Used: :count', ['count' => $gift->usage_count]) }})
</span>
@else
@if($gift->usage_count > 0)
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 bg-slate-50 dark:bg-slate-800/40 px-2 py-0.5 rounded border border-slate-200/60 dark:border-slate-700/60 line-through">
{{ __('Used') }}
</span>
@else
<span class="text-xs font-bold text-cyan-600 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-500/10 px-2 py-0.5 rounded border border-cyan-100 dark:border-cyan-500/20">
{{ __('Once') }}
</span>
@endif
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($gift->expires_at)
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">
{{ $gift->expires_at->format('Y-m-d H:i') }}</div>
<div class="text-xs font-bold {{ $gift->expires_at->isPast() ? 'text-rose-500' : 'text-slate-400' }} mt-0.5">
{{ $gift->expires_at->isPast() ? __('Expired') : $gift->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 = $gift->status === 'active' && $gift->expires_at?->isPast() ? 'expired'
: ($gift->status === 'disabled' ? 'cancelled' : $gift->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($gift->status === 'active' && (!$gift->expires_at || $gift->expires_at->isFuture()))
<button type="button"
@click="activeQrCode = '{{ $gift->code }}'; activeTicketUrl = '{{ $gift->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 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>
<button type="button" @click="openEditModal({
id: {{ $gift->id }},
code: '{{ $gift->code }}',
name: '{{ addslashes($gift->name) }}',
discount_type: '{{ $gift->discount_type }}',
discount_value: {{ $gift->discount_value }},
input_fold: {{ $gift->input_fold ?? 'null' }},
usage_type: '{{ $gift->usage_type }}',
usage_limit: {{ $gift->usage_limit ?? 'null' }},
expires_at: '{{ $gift->expires_at ? $gift->expires_at->format('Y-m-d\TH: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>
@endif
@if($gift->status === 'active')
<form id="delete-form-desktop-{{ $gift->id }}"
action="{{ route('admin.sales.store-gifts.destroy', $gift) }}" method="POST">
@csrf
@method('DELETE')
<button type="button"
@click="confirmDelete('delete-form-desktop-{{ $gift->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 Gift') }}">
<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="7" :message="__('No welcome gifts found')" />
@endforelse
</tbody>
</table>
</div>
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($welcomeGifts as $gift)
<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" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8.25v-1.5m0 1.5c-1.355 0-2.5-.38-2.5-1.5 0-1.002 1.042-1.5 2.5-1.5s2.5.498 2.5 1.5c0 1.12-1.145 1.5-2.5 1.5zM3 12v6.75A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V12M3 12h18M3 12a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 12m-9 0v9" />
</svg>
</div>
<div class="min-w-0">
@if($gift->status === 'active' && (!$gift->expires_at || $gift->expires_at->isFuture()))
<h3 @click="activeQrCode = '{{ $gift->code }}'; activeTicketUrl = '{{ $gift->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">
{{ $gift->code }}
</h3>
@else
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate tracking-tight">
{{ $gift->code }}
</h3>
@endif
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $gift->name }}
</p>
</div>
</div>
<div class="min-w-0">
@php
$displayStatusMobile = $gift->status === 'active' && $gift->expires_at?->isPast() ?
'expired' : ($gift->status === 'disabled' ? 'cancelled' : $gift->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">
{{ $gift->machine->name }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Discount Value') }}</p>
<p class="text-sm font-bold text-indigo-600 dark:text-indigo-400">
{{ $gift->discount_label }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Usage Status') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
@if($gift->usage_type === 'unlimited')
{{ __('Unlimited') }} ({{ $gift->usage_count }})
@else
@if($gift->usage_count > 0)
<span class="text-slate-400 dark:text-slate-500 line-through">{{ __('Used') }}</span>
@else
<span class="text-cyan-600 dark:text-cyan-400">{{ __('Once') }}</span>
@endif
@endif
</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">
{{ $gift->expires_at ? $gift->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($gift->status === 'active' && (!$gift->expires_at || $gift->expires_at->isFuture()))
<button type="button"
@click="activeQrCode = '{{ $gift->code }}'; activeTicketUrl = '{{ $gift->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>
<button type="button" @click="openEditModal({
id: {{ $gift->id }},
code: '{{ $gift->code }}',
name: '{{ addslashes($gift->name) }}',
discount_type: '{{ $gift->discount_type }}',
discount_value: {{ $gift->discount_value }},
input_fold: {{ $gift->input_fold ?? 'null' }},
usage_type: '{{ $gift->usage_type }}',
usage_limit: {{ $gift->usage_limit ?? 'null' }},
expires_at: '{{ $gift->expires_at ? $gift->expires_at->format('Y-m-d\TH: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>
@endif
@if($gift->status === 'active')
<form id="delete-form-mobile-{{ $gift->id }}"
action="{{ route('admin.sales.store-gifts.destroy', $gift) }}" method="POST" class="flex-1">
@csrf
@method('DELETE')
<button type="button" @click="confirmDelete('delete-form-mobile-{{ $gift->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 welcome gifts found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $welcomeGifts->appends(request()->query())->links('vendor.pagination.luxury') }}
</div>

View File

@ -0,0 +1,312 @@
{{-- Filter Form --}}
<form action="{{ route('admin.sales.store-gifts') }}" 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="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" 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_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"
@change="$el.closest('form').dispatchEvent(new Event('submit'))"
>
@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-56 sm:flex-none"
x-data="{ fp: null }"
x-init="fp = flatpickr($refs.startDate, {
disableMobile: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
defaultHour: 0,
defaultMinute: 0,
locale: 'zh_tw',
position: 'auto right',
defaultDate: '{{ request('start_date') }}'
})">
<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="text" name="start_date" x-ref="startDate"
value="{{ request('start_date') }}"
placeholder="{{ __('Start Time') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full text-sm font-bold cursor-pointer">
<div class="absolute -top-2 left-4 px-1 bg-white dark:bg-slate-900 text-[9px] font-black text-slate-400 uppercase tracking-widest opacity-0 group-focus-within:opacity-100 transition-opacity pointer-events-none">{{ __('Start Time') }}</div>
</div>
{{-- 結束時間 --}}
<div class="relative group w-full sm:w-56 sm:flex-none"
x-data="{ fp: null }"
x-init="fp = flatpickr($refs.endDate, {
disableMobile: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
defaultHour: 23,
defaultMinute: 59,
locale: 'zh_tw',
position: 'auto right',
defaultDate: '{{ request('end_date') }}'
})">
<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="text" name="end_date" x-ref="endDate"
value="{{ request('end_date') }}"
placeholder="{{ __('End Time') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full text-sm font-bold cursor-pointer">
<div class="absolute -top-2 left-4 px-1 bg-white dark:bg-slate-900 text-[9px] font-black text-slate-400 uppercase tracking-widest opacity-0 group-focus-within:opacity-100 transition-opacity pointer-events-none">{{ __('End Time') }}</div>
</div>
{{-- 6 個月限制提示 (僅在未搜尋時間時顯示) --}}
@if(!request('start_date') && !request('end_date'))
<div class="hidden md:flex items-center gap-2 px-4 py-2 bg-amber-50/50 dark:bg-amber-900/10 border border-amber-100/50 dark:border-amber-800/30 rounded-xl">
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-[10px] font-bold text-amber-600/80 dark:text-amber-500/80 uppercase tracking-wider">
{{ __('Showing last 6 months by default when no date range is selected') }}
</span>
</div>
@endif
{{-- 按鈕組 --}}
<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.store-gifts') }}?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">
{{ __('Machine / Welcome Gift') }}</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">
{{ __('Welcome Gift 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">
{{ __('Discount') }}</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">
{{ __('Sales Record') }}</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">
{{ __('Time') }}</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-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 8.25v-1.5m0 1.5c-1.355 0-2.5-.38-2.5-1.5 0-1.002 1.042-1.5 2.5-1.5s2.5.498 2.5 1.5c0 1.12-1.145 1.5-2.5 1.5zM3 12v6.75A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V12M3 12h18M3 12a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 12m-9 0v9" />
</svg>
</div>
<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">
{{ $log->machine->name ?? $log->machine->serial_no ?? '-' }}
</span>
<span
class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide mt-0.5">
{{ $log->welcomeGift->name ?? '-' }}
</span>
</div>
</div>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<x-status-badge :status="$log->action" size="sm" />
</td>
<td
class="px-6 py-5 text-center font-mono font-extrabold text-slate-700 dark:text-slate-200">
<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->welcomeGift->code ?? '-' }}
</span>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
<span class="text-xs font-extrabold text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 px-2 py-1 rounded-lg border border-slate-200/50 dark:border-slate-700/50">
{{ $log->welcomeGift->discount_label ?? '-' }}
</span>
</td>
<td class="px-6 py-5 text-center whitespace-nowrap">
@if($log->order_id)
<a href="{{ route('admin.sales.index', ['tab' => 'orders', 'search' => $log->order?->order_no ?? $log->order_id]) }}"
class="text-xs font-black text-cyan-600 dark:text-cyan-400 hover:underline flex items-center justify-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
{{ $log->order?->order_no ?? '#' . $log->order_id }}
</a>
@else
<span class="text-slate-300 dark:text-slate-700">-</span>
@endif
</td>
<td class="px-6 py-5 text-right whitespace-nowrap">
<span
class="text-xs font-mono font-black text-slate-400 tracking-widest">
{{ $log->created_at?->format('Y-m-d H:i:s') }}
</span>
</td>
</tr>
@empty
<tr>
<td colspan="6" 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" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8.25v-1.5m0 1.5c-1.355 0-2.5-.38-2.5-1.5 0-1.002 1.042-1.5 2.5-1.5s2.5.498 2.5 1.5c0 1.12-1.145 1.5-2.5 1.5zM3 12v6.75A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V12M3 12h18M3 12a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 12m-9 0v9" />
</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-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">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8.25v-1.5m0 1.5c-1.355 0-2.5-.38-2.5-1.5 0-1.002 1.042-1.5 2.5-1.5s2.5.498 2.5 1.5c0 1.12-1.145 1.5-2.5 1.5zM3 12v6.75A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V12M3 12h18M3 12a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 12m-9 0v9" />
</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">
{{ $log->machine->name ?? $log->machine->serial_no ?? '-' }}</h3>
<p
class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest truncate mt-0.5">
{{ $log->welcomeGift->name ?? '-' }}</p>
</div>
</div>
<div class="shrink-0">
<x-status-badge :status="$log->action" size="sm" />
</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">{{
__('Welcome Gift Code') }}</p>
<p class="text-sm font-mono font-black text-slate-800 dark:text-slate-100">
{{ $log->welcomeGift->code ?? '-' }}
</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Discount') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $log->welcomeGift->discount_label ?? '-' }}
</p>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{
__('Sales Record') }}</p>
@if($log->order_id)
<a href="{{ route('admin.sales.index', ['tab' => 'orders', 'search' => $log->order?->order_no ?? $log->order_id]) }}"
class="text-sm font-black text-cyan-600 dark:text-cyan-400 hover:underline">
{{ $log->order?->order_no ?? '#' . $log->order_id }}
</a>
@else
<p class="text-sm font-bold text-slate-300 dark:text-slate-700">-</p>
@endif
</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

@ -15,9 +15,17 @@
</template>
</p>
</div>
<button type="button" @click="showOrderDetails = false" class="p-2.5 rounded-full hover:bg-slate-50 dark:hover:bg-slate-800 text-slate-400 transition-colors border border-slate-100 dark:border-slate-800 shadow-sm">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<div class="flex items-center gap-3">
<button type="button" @click="window.open('/admin/warehouses/replenishments/' + activeOrder.id + '/print', '_blank')" class="p-2.5 rounded-full hover:bg-slate-50 dark:hover:bg-slate-800 text-cyan-500 hover:text-cyan-600 transition-colors border border-slate-100 dark:border-slate-800 shadow-sm flex items-center justify-center" title="{{ __('Print Order') }}">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.82l-.024-.03c-1.285-1.56-1.93-3.19-1.93-5.166C4.766 4.902 6.713 3 9.12 3c2.407 0 4.354 1.902 4.354 4.624 0 1.977-.645 3.607-1.93 5.167-.004.005-.008.01-.012.015L9.12 15.652l-2.4-1.832z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12h2m-2 4h2m-2-8h2M3 12h2m-2 4h2m-2-8h2M12 18H8.5c-.83 0-1.5-.67-1.5-1.5V14m8.5 4H16m0 0v-4.5c0-.83-.67-1.5-1.5-1.5H11" />
</svg>
</button>
<button type="button" @click="showOrderDetails = false" class="p-2.5 rounded-full hover:bg-slate-50 dark:hover:bg-slate-800 text-slate-400 transition-colors border border-slate-100 dark:border-slate-800 shadow-sm">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
{{-- Content --}}
@ -68,23 +76,35 @@
</div>
{{-- Action Buttons --}}
<template x-if="activeOrder.status !== 'completed' && activeOrder.status !== 'cancelled'">
<div class="flex flex-wrap gap-2">
<button type="button" @click="openAssignModal(activeOrder.id)" class="px-4 py-2 rounded-xl text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all border border-slate-200 dark:border-slate-700">
{{ __('Assign Personnel') }}
</button>
<template x-if="activeOrder.status === 'pending'">
<button type="button" @click="advanceStatus(activeOrder.id, 'prepared')" class="px-4 py-2 rounded-xl text-xs font-black bg-cyan-500/10 text-cyan-500 border border-cyan-500/20 hover:bg-cyan-500 hover:text-white transition-all">{{ __('Confirm Prepare') }}</button>
</template>
<template x-if="activeOrder.status === 'prepared'">
<button type="button" @click="advanceStatus(activeOrder.id, 'delivering')" class="px-4 py-2 rounded-xl text-xs font-black bg-indigo-500/10 text-indigo-500 border border-indigo-500/20 hover:bg-indigo-500 hover:text-white transition-all">{{ __('Start Delivery') }}</button>
</template>
<template x-if="activeOrder.status === 'delivering'">
<button type="button" @click="advanceStatus(activeOrder.id, 'completed')" class="px-4 py-2 rounded-xl text-xs font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 hover:bg-emerald-500 hover:text-white transition-all">{{ __('Confirm Complete') }}</button>
</template>
<button type="button" @click="confirmCancel(activeOrder.id)" class="px-4 py-2 rounded-xl text-xs font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all">{{ __('Cancel Order') }}</button>
</div>
</template>
<div class="flex flex-wrap gap-2">
{{-- 列印按鈕永遠顯示 --}}
<button type="button" @click="window.open('/admin/warehouses/replenishments/' + activeOrder.id + '/print', '_blank')" class="px-4 py-2 rounded-xl text-xs font-black bg-cyan-500 text-white hover:bg-cyan-600 transition-all shadow-lg shadow-cyan-500/10 flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.82l-.024-.03c-1.285-1.56-1.93-3.19-1.93-5.166C4.766 4.902 6.713 3 9.12 3c2.407 0 4.354 1.902 4.354 4.624 0 1.977-.645 3.607-1.93 5.167-.004.005-.008.01-.012.015L9.12 15.652l-2.4-1.832z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12h2m-2 4h2m-2-8h2M3 12h2m-2 4h2m-2-8h2M12 18H8.5c-.83 0-1.5-.67-1.5-1.5V14m8.5 4H16m0 0v-4.5c0-.83-.67-1.5-1.5-1.5H11" />
</svg>
{{ __('Print Order') }}
</button>
{{-- 其他需要依狀態顯示的動作 --}}
<template x-if="activeOrder.status !== 'completed' && activeOrder.status !== 'cancelled'">
<div class="flex flex-wrap gap-2">
<button type="button" @click="openAssignModal(activeOrder.id)" class="px-4 py-2 rounded-xl text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all border border-slate-200 dark:border-slate-700">
{{ __('Assign Personnel') }}
</button>
<template x-if="activeOrder.status === 'pending'">
<button type="button" @click="advanceStatus(activeOrder.id, 'prepared')" class="px-4 py-2 rounded-xl text-xs font-black bg-cyan-500/10 text-cyan-500 border border-cyan-500/20 hover:bg-cyan-500 hover:text-white transition-all">{{ __('Confirm Prepare') }}</button>
</template>
<template x-if="activeOrder.status === 'prepared'">
<button type="button" @click="advanceStatus(activeOrder.id, 'delivering')" class="px-4 py-2 rounded-xl text-xs font-black bg-indigo-500/10 text-indigo-500 border border-indigo-500/20 hover:bg-indigo-500 hover:text-white transition-all">{{ __('Start Delivery') }}</button>
</template>
<template x-if="activeOrder.status === 'delivering'">
<button type="button" @click="advanceStatus(activeOrder.id, 'completed')" class="px-4 py-2 rounded-xl text-xs font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 hover:bg-emerald-500 hover:text-white transition-all">{{ __('Confirm Complete') }}</button>
</template>
<button type="button" @click="confirmCancel(activeOrder.id)" class="px-4 py-2 rounded-xl text-xs font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all">{{ __('Cancel Order') }}</button>
</div>
</template>
</div>
{{-- Items List --}}
<div class="space-y-5">

View File

@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full bg-white">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('Replenishment Order') }} - {{ $order->order_no }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800;900&family=Plus+Jakarta+Sans:wght@400;500;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS (Vite setup) -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: #fff;
color: #0f172a;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.font-display {
font-family: 'Outfit', sans-serif;
}
@media print {
.no-print {
display: none !important;
}
body {
background: #ffffff !important;
color: #000000 !important;
font-size: 12px;
}
.print-border {
border-color: #94a3b8 !important;
}
.print-bg-gray {
background-color: #f1f5f9 !important;
}
@page {
size: A4 portrait;
margin: 15mm;
}
}
</style>
</head>
<body class="p-6 sm:p-12 min-h-screen flex flex-col justify-between">
<!-- Container -->
<div class="max-w-4xl mx-auto w-full flex-1">
<!-- Top Operations (No Print) -->
<div class="no-print flex items-center justify-between gap-4 mb-8 p-4 bg-slate-50 dark:bg-slate-900 rounded-2xl border border-slate-200/50 dark:border-slate-800/50">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-cyan-500 animate-pulse"></span>
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{ __('Ready to print') }}</span>
</div>
<div class="flex gap-3">
<button onclick="window.print()" class="px-5 py-2.5 bg-cyan-500 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:bg-cyan-600 transition-all active:scale-95 flex items-center gap-2 shadow-lg shadow-cyan-500/20">
<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="M6.72 13.82l-.024-.03c-1.285-1.56-1.93-3.19-1.93-5.166C4.766 4.902 6.713 3 9.12 3c2.407 0 4.354 1.902 4.354 4.624 0 1.977-.645 3.607-1.93 5.167-.004.005-.008.01-.012.015L9.12 15.652l-2.4-1.832z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12h2m-2 4h2m-2-8h2M3 12h2m-2 4h2m-2-8h2M12 18H8.5c-.83 0-1.5-.67-1.5-1.5V14m8.5 4H16m0 0v-4.5c0-.83-.67-1.5-1.5-1.5H11" />
</svg>
{{ __('Print') }}
</button>
<button onclick="window.close()" class="px-5 py-2.5 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95">
{{ __('Close') }}
</button>
</div>
</div>
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-6 pb-6 border-b-2 border-slate-900 print-border">
<div>
<!-- Brand -->
<p class="text-xs font-black text-cyan-600 uppercase tracking-[0.3em] font-display mb-1">Star Cloud System</p>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-4">Tenant: {{ Auth::user()->company?->name ?? __('Platform Operator') }}</p>
<!-- Document Title -->
<h1 class="text-3xl font-black text-slate-900 tracking-tight font-display uppercase">{{ __('Replenishment Order') }}</h1>
<p class="text-xs font-bold text-slate-500 mt-1 uppercase tracking-widest">{{ __('Vending Machine Product Replenishment') }}</p>
</div>
<div class="text-left sm:text-right flex flex-col sm:items-end">
<!-- Status Badge -->
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-xs font-black uppercase tracking-widest border border-slate-900 print-border text-slate-900 mb-3 w-fit">
{{ __($order->status) }}
</span>
<!-- Mono Order No -->
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Order Number') }}</p>
<p class="text-2xl font-black text-slate-900 font-mono tracking-tighter">{{ $order->order_no }}</p>
</div>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-8 py-8 border-b border-slate-200">
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Source Warehouse') }}</p>
<p class="text-sm font-extrabold text-slate-800">{{ $order->warehouse?->name ?? '-' }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Target Machine') }}</p>
<p class="text-sm font-extrabold text-slate-800">{{ $order->machine?->name ?? '-' }} <span class="text-xs font-mono font-bold text-slate-400 tracking-widest uppercase">({{ $order->machine?->serial_no }})</span></p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Created By') }}</p>
<p class="text-sm font-bold text-slate-700">{{ $order->creator?->name ?? __('System') }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Assigned Personnel') }}</p>
<p class="text-sm font-bold text-slate-700">{{ $order->assignee?->name ?? __('Unassigned') }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Created At') }}</p>
<p class="text-sm font-mono font-bold text-slate-600">{{ $order->created_at->timezone('Asia/Taipei')->format('Y-m-d H:i:s') }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Print Time') }}</p>
<p class="text-sm font-mono font-bold text-slate-600">{{ now()->timezone('Asia/Taipei')->format('Y-m-d H:i:s') }}</p>
</div>
@if($order->note)
<div class="col-span-1 md:col-span-2 space-y-1 pt-2 border-t border-dashed border-slate-200">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Note') }}</p>
<p class="text-xs font-bold text-slate-600 italic">{{ $order->note }}</p>
</div>
@endif
</div>
<!-- Items Table -->
<div class="py-8">
<h3 class="text-xs font-black text-slate-900 uppercase tracking-[0.2em] mb-4 font-display">{{ __('Replenishment Details') }} ({{ $items->count() }} {{ __('Items') }})</h3>
<table class="w-full text-left border-collapse table-fixed">
<thead>
<tr class="border-b-2 border-slate-900 print-border bg-slate-50 print-bg-gray">
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-16 text-center whitespace-nowrap">
{{ __('Slot') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-16 text-center whitespace-nowrap">
{{ __('Image') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest min-w-[200px] whitespace-nowrap">
{{ __('Product Name') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-28 whitespace-nowrap">
{{ __('Barcode') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-24 text-right whitespace-nowrap">
{{ __('Stock Snap') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-24 text-right whitespace-nowrap">
{{ __('Replenish Qty') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-20 text-center no-print whitespace-nowrap">
{{ __('Check') }}
</th>
<!-- 列印時顯示的手寫格 -->
<th class="hidden print:table-cell px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-24 text-center whitespace-nowrap">
{{ __('Actual Qty') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
@foreach($items as $index => $item)
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-4 py-3 font-mono font-black text-slate-900 text-center text-sm">
<span class="inline-block px-2 py-1 rounded bg-slate-100 print-bg-gray border border-slate-200 print-border">
{{ $item->slot_no }}
</span>
</td>
<td class="px-4 py-3">
<div class="mx-auto w-10 h-10 rounded-lg border border-slate-200 print-border overflow-hidden bg-slate-50 flex items-center justify-center shrink-0">
@if($item->product?->image_url)
<img src="{{ $item->product->image_url }}" class="w-full h-full object-cover">
@else
<svg class="w-5 h-5 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
@endif
</div>
</td>
<td class="px-4 py-3 min-w-[200px] break-words">
<p class="text-sm font-extrabold text-slate-800 tracking-tight whitespace-normal">{{ $item->product?->localized_name ?? __('Unknown') }}</p>
<p class="text-[9px] font-mono font-bold text-slate-400 uppercase tracking-widest mt-0.5">ID: {{ $item->product_id }}</p>
</td>
<td class="px-4 py-3 font-mono text-xs font-bold text-slate-500 break-all">
{{ $item->product?->barcode ?? '-' }}
</td>
<td class="px-4 py-3 text-right text-xs font-bold text-slate-500 font-mono whitespace-nowrap">
{{ $item->current_stock }} / {{ $item->max_stock }}
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<p class="text-lg font-black text-cyan-600 font-display tracking-tight">x{{ $item->quantity }}</p>
</td>
<!-- UI 頁面上的 check 欄位 -->
<td class="px-4 py-3 text-center no-print">
<div class="inline-flex items-center justify-center w-5 h-5 border-2 border-slate-300 rounded-md"></div>
</td>
<!-- 列印時的手寫框 -->
<td class="hidden print:table-cell px-4 py-3 text-center vertical-middle">
<div class="mx-auto w-12 h-6 border border-slate-400 print-border rounded"></div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<!-- Print Footer Warning (No Print) -->
<div class="no-print mt-4 p-4 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 font-bold text-xs">
<svg class="w-4 h-4 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-extrabold uppercase tracking-widest mb-1">{{ __('Print Notice') }}</p>
<p>{{ __('Please make sure to enable "Background graphics" in your browser print settings to print product images and styled components correctly.') }}</p>
</div>
</div>
</div>
<!-- Sign-off Section -->
<div class="max-w-4xl mx-auto w-full mt-12 pt-8 border-t border-slate-200">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
<div class="space-y-6">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Prepared By') }}</p>
<div class="mx-auto w-32 border-b border-slate-400 print-border h-8"></div>
</div>
<div class="space-y-6">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Warehouse Stock Out') }}</p>
<div class="mx-auto w-32 border-b border-slate-400 print-border h-8"></div>
</div>
<div class="space-y-6">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Courier/Replenisher') }}</p>
<div class="mx-auto w-32 border-b border-slate-400 print-border h-8"></div>
</div>
<div class="space-y-6">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Machine Inbound Confirmation') }}</p>
<div class="mx-auto w-32 border-b border-slate-400 print-border h-8"></div>
</div>
</div>
<div class="mt-8 text-center text-[10px] font-bold text-slate-400 uppercase tracking-widest">
Star Cloud © 2026. All rights reserved.
</div>
</div>
<!-- Auto Print Script -->
<script>
window.addEventListener('DOMContentLoaded', () => {
// 自動觸發列印
setTimeout(() => {
window.print();
}, 500);
});
</script>
</body>
</html>

View File

@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full bg-white">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('Transfer Order') }} - {{ $order->order_no }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800;900&family=Plus+Jakarta+Sans:wght@400;500;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS (Vite setup) -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: #fff;
color: #0f172a;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.font-display {
font-family: 'Outfit', sans-serif;
}
@media print {
.no-print {
display: none !important;
}
body {
background: #ffffff !important;
color: #000000 !important;
font-size: 12px;
}
.print-border {
border-color: #94a3b8 !important;
}
.print-bg-gray {
background-color: #f1f5f9 !important;
}
@page {
size: A4 portrait;
margin: 15mm;
}
}
</style>
</head>
<body class="p-6 sm:p-12 min-h-screen flex flex-col justify-between">
<!-- Container -->
<div class="max-w-4xl mx-auto w-full flex-1">
<!-- Top Operations (No Print) -->
<div class="no-print flex items-center justify-between gap-4 mb-8 p-4 bg-slate-50 dark:bg-slate-900 rounded-2xl border border-slate-200/50 dark:border-slate-800/50">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-cyan-500 animate-pulse"></span>
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{ __('Ready to print') }}</span>
</div>
<div class="flex gap-3">
<button onclick="window.print()" class="px-5 py-2.5 bg-cyan-500 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:bg-cyan-600 transition-all active:scale-95 flex items-center gap-2 shadow-lg shadow-cyan-500/20">
<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="M6.72 13.82l-.024-.03c-1.285-1.56-1.93-3.19-1.93-5.166C4.766 4.902 6.713 3 9.12 3c2.407 0 4.354 1.902 4.354 4.624 0 1.977-.645 3.607-1.93 5.167-.004.005-.008.01-.012.015L9.12 15.652l-2.4-1.832z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12h2m-2 4h2m-2-8h2M3 12h2m-2 4h2m-2-8h2M12 18H8.5c-.83 0-1.5-.67-1.5-1.5V14m8.5 4H16m0 0v-4.5c0-.83-.67-1.5-1.5-1.5H11" />
</svg>
{{ __('Print') }}
</button>
<button onclick="window.close()" class="px-5 py-2.5 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95">
{{ __('Close') }}
</button>
</div>
</div>
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-6 pb-6 border-b-2 border-slate-900 print-border">
<div>
<!-- Brand -->
<p class="text-xs font-black text-cyan-600 uppercase tracking-[0.3em] font-display mb-1">Star Cloud System</p>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-4">Tenant: {{ Auth::user()->company?->name ?? __('Platform Operator') }}</p>
<!-- Document Title -->
<h1 class="text-3xl font-black text-slate-900 tracking-tight font-display uppercase">{{ __('Transfer Order') }}</h1>
<p class="text-xs font-bold text-slate-500 mt-1 uppercase tracking-widest">
{{ $order->type === \App\Models\Warehouse\TransferOrder::TYPE_W2W ? __('Warehouse to Warehouse Transfer') : __('Machine to Warehouse Return') }}
</p>
</div>
<div class="text-left sm:text-right flex flex-col sm:items-end">
<!-- Status Badge -->
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-xs font-black uppercase tracking-widest border border-slate-900 print-border text-slate-900 mb-3 w-fit">
{{ __($order->status) }}
</span>
<!-- Mono Order No -->
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Order Number') }}</p>
<p class="text-2xl font-black text-slate-900 font-mono tracking-tighter">{{ $order->order_no }}</p>
</div>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-8 py-8 border-b border-slate-200">
<div class="space-y-1">
@if($order->type === \App\Models\Warehouse\TransferOrder::TYPE_W2W)
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Source Warehouse') }}</p>
<p class="text-sm font-extrabold text-slate-800">{{ $order->fromWarehouse?->name ?? '-' }}</p>
@else
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Source Machine') }}</p>
<p class="text-sm font-extrabold text-slate-800">
{{ $order->fromMachine?->name ?? '-' }}
<span class="text-xs font-mono font-bold text-slate-400 tracking-widest uppercase">({{ $order->fromMachine?->serial_no }})</span>
</p>
@endif
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Target Warehouse') }}</p>
<p class="text-sm font-extrabold text-slate-800">{{ $order->toWarehouse?->name ?? '-' }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Created By') }}</p>
<p class="text-sm font-bold text-slate-700">{{ $order->creator?->name ?? __('System') }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Transfer Type') }}</p>
<p class="text-sm font-bold text-slate-700">
{{ $order->type === \App\Models\Warehouse\TransferOrder::TYPE_W2W ? __('Warehouse to Warehouse') : __('Machine to Warehouse') }}
</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Created At') }}</p>
<p class="text-sm font-mono font-bold text-slate-600">{{ $order->created_at->timezone('Asia/Taipei')->format('Y-m-d H:i:s') }}</p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Print Time') }}</p>
<p class="text-sm font-mono font-bold text-slate-600">{{ now()->timezone('Asia/Taipei')->format('Y-m-d H:i:s') }}</p>
</div>
@if($order->note)
<div class="col-span-1 md:col-span-2 space-y-1 pt-2 border-t border-dashed border-slate-200">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Note') }}</p>
<p class="text-xs font-bold text-slate-600 italic">{{ $order->note }}</p>
</div>
@endif
</div>
<!-- Items Table -->
<div class="py-8">
<h3 class="text-xs font-black text-slate-900 uppercase tracking-[0.2em] mb-4 font-display">{{ __('Transfer Details') }} ({{ $items->count() }} {{ __('Items') }})</h3>
<table class="w-full text-left border-collapse table-fixed">
<thead>
<tr class="border-b-2 border-slate-900 print-border bg-slate-50 print-bg-gray">
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-16 text-center whitespace-nowrap">
#
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-16 text-center whitespace-nowrap">
{{ __('Image') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest min-w-[200px] whitespace-nowrap">
{{ __('Product Name') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-32 whitespace-nowrap">
{{ __('Barcode') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-24 text-right whitespace-nowrap">
{{ __('Transfer Qty') }}
</th>
<th class="px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-20 text-center no-print whitespace-nowrap">
{{ __('Check') }}
</th>
<!-- 列印時顯示的手寫格 -->
<th class="hidden print:table-cell px-4 py-3 text-[10px] font-black text-slate-500 uppercase tracking-widest w-24 text-center whitespace-nowrap">
{{ __('Actual Qty') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
@foreach($items as $index => $item)
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-4 py-3 font-mono font-black text-slate-900 text-center text-sm">
{{ $index + 1 }}
</td>
<td class="px-4 py-3">
<div class="mx-auto w-10 h-10 rounded-lg border border-slate-200 print-border overflow-hidden bg-slate-50 flex items-center justify-center shrink-0">
@if($item->product?->image_url)
<img src="{{ $item->product->image_url }}" class="w-full h-full object-cover">
@else
<svg class="w-5 h-5 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
@endif
</div>
</td>
<td class="px-4 py-3 min-w-[200px] break-words">
<p class="text-sm font-extrabold text-slate-800 tracking-tight whitespace-normal">{{ $item->product?->localized_name ?? __('Unknown') }}</p>
<p class="text-[9px] font-mono font-bold text-slate-400 uppercase tracking-widest mt-0.5">ID: {{ $item->product_id }}</p>
</td>
<td class="px-4 py-3 font-mono text-xs font-bold text-slate-500 break-all">
{{ $item->product?->barcode ?? '-' }}
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<p class="text-lg font-black text-cyan-600 font-display tracking-tight">x{{ $item->quantity }}</p>
</td>
<!-- UI 頁面上的 check 欄位 -->
<td class="px-4 py-3 text-center no-print">
<div class="inline-flex items-center justify-center w-5 h-5 border-2 border-slate-300 rounded-md"></div>
</td>
<!-- 列印時的手寫框 -->
<td class="hidden print:table-cell px-4 py-3 text-center vertical-middle">
<div class="mx-auto w-12 h-6 border border-slate-400 print-border rounded"></div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<!-- Print Footer Warning (No Print) -->
<div class="no-print mt-4 p-4 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 font-bold text-xs">
<svg class="w-4 h-4 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-extrabold uppercase tracking-widest mb-1">{{ __('Print Notice') }}</p>
<p>{{ __('Please make sure to enable "Background graphics" in your browser print settings to print product images and styled components correctly.') }}</p>
</div>
</div>
</div>
<!-- Sign-off Section -->
<div class="max-w-4xl mx-auto w-full mt-12 pt-8 border-t border-slate-200">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
<div class="space-y-6">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Prepared By') }}</p>
<div class="mx-auto w-32 border-b border-slate-400 print-border h-8"></div>
</div>
<div class="space-y-6">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Transfer Out Confirmation') }}</p>
<div class="mx-auto w-32 border-b border-slate-400 print-border h-8"></div>
</div>
<div class="space-y-6">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Courier/Dispatcher') }}</p>
<div class="mx-auto w-32 border-b border-slate-400 print-border h-8"></div>
</div>
<div class="space-y-6">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Transfer In Confirmation') }}</p>
<div class="mx-auto w-32 border-b border-slate-400 print-border h-8"></div>
</div>
</div>
<div class="mt-8 text-center text-[10px] font-bold text-slate-400 uppercase tracking-widest">
Star Cloud © 2026. All rights reserved.
</div>
</div>
<!-- Auto Print Script -->
<script>
window.addEventListener('DOMContentLoaded', () => {
// 自動觸發列印
setTimeout(() => {
window.print();
}, 500);
});
</script>
</body>
</html>

View File

@ -707,12 +707,20 @@
</div>
</template>
</div>
<button type="button" @click="showOrderDetails = 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">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="flex items-center gap-3">
<button type="button" @click="window.open('/admin/warehouses/transfers/' + activeOrder.id + '/print', '_blank')" class="p-2 text-cyan-500 hover:text-cyan-600 bg-white dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-full focus:outline-none transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center" title="{{ __('Print Order') }}">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.82l-.024-.03c-1.285-1.56-1.93-3.19-1.93-5.166C4.766 4.902 6.713 3 9.12 3c2.407 0 4.354 1.902 4.354 4.624 0 1.977-.645 3.607-1.93 5.167-.004.005-.008.01-.012.015L9.12 15.652l-2.4-1.832z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12h2m-2 4h2m-2-8h2M3 12h2m-2 4h2m-2-8h2M12 18H8.5c-.83 0-1.5-.67-1.5-1.5V14m8.5 4H16m0 0v-4.5c0-.83-.67-1.5-1.5-1.5H11" />
</svg>
</button>
<button type="button" @click="showOrderDetails = 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">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
@ -792,6 +800,17 @@
</template>
</div>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-2">
<button type="button" @click="window.open('/admin/warehouses/transfers/' + activeOrder.id + '/print', '_blank')" class="px-4 py-2 rounded-xl text-xs font-black bg-cyan-500 text-white hover:bg-cyan-600 transition-all shadow-lg shadow-cyan-500/10 flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.82l-.024-.03c-1.285-1.56-1.93-3.19-1.93-5.166C4.766 4.902 6.713 3 9.12 3c2.407 0 4.354 1.902 4.354 4.624 0 1.977-.645 3.607-1.93 5.167-.004.005-.008.01-.012.015L9.12 15.652l-2.4-1.832z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 12h2m-2 4h2m-2-8h2M3 12h2m-2 4h2m-2-8h2M12 18H8.5c-.83 0-1.5-.67-1.5-1.5V14m8.5 4H16m0 0v-4.5c0-.83-.67-1.5-1.5-1.5H11" />
</svg>
{{ __('Print Order') }}
</button>
</div>
<!-- Items List -->
<div class="space-y-5">
<div class="flex items-center justify-between px-1">

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="min-h-screen">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
@ -13,27 +13,17 @@
body { font-family: 'Outfit', sans-serif; }
</style>
</head>
<body class="h-full bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-6 antialiased">
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
<div class="w-full max-w-sm animate-luxury-in">
{{-- Header Area --}}
<div class="text-center mb-8">
<div class="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 rounded-full shadow-sm border border-slate-100 dark:border-slate-800 mb-6">
<div class="w-2 h-2 rounded-full bg-cyan-500 animate-pulse"></div>
<span class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">{{ __('Valid Authorization') }}</span>
</div>
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Pass') }} <span class="text-cyan-500">{{ __('Code') }}</span></h1>
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Pass Code') }}</h1>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Scan to authorize testing or maintenance') }}</p>
</div>
{{-- Ticket Card --}}
<div class="relative group">
{{-- Background Glow --}}
<div class="absolute -inset-4 bg-gradient-to-tr from-cyan-500/20 to-emerald-500/20 blur-3xl opacity-50 transition-opacity duration-500 group-hover:opacity-100"></div>
<div class="relative luxury-card rounded-[3rem] overflow-hidden border-slate-200/50 dark:border-slate-700/50 shadow-2xl bg-white dark:bg-slate-900">
{{-- Machine Section --}}
<div class="px-8 pt-10 pb-6 border-b border-dashed border-slate-100 dark:border-slate-800 relative">
{{-- Notch cutouts for ticket look --}}
<div class="absolute -left-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-r border-slate-100 dark:border-slate-800"></div>
<div class="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-l border-slate-100 dark:border-slate-800"></div>
@ -47,7 +37,6 @@
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight leading-tight mb-1">
{{ $passCode->machine->name }}
</h2>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ $passCode->machine->serial_no }}</p>
</div>
</div>
@ -64,7 +53,6 @@
</div>
</div>
{{-- QR Section --}}
<div class="p-10 flex flex-col items-center">
<div class="p-6 bg-white rounded-[2.5rem] shadow-xl border border-slate-100 mb-8 transform transition-transform duration-500 group-hover:scale-[1.02]">
<x-qr-code :data="$passCode->code" :dynamic="false" size="300" class="w-48 h-48 sm:w-64 sm:h-64" />
@ -76,7 +64,6 @@
</div>
</div>
{{-- Expiry Area --}}
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800 text-center">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{{ __('Expiry Time') }}</p>
<p class="text-sm font-black text-slate-600 dark:text-slate-300">
@ -85,11 +72,6 @@
</div>
</div>
</div>
{{-- Footer Branding --}}
<div class="mt-12 text-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.4em] opacity-50">{{ config('app.name') }} &copy; {{ date('Y') }}</p>
</div>
</div>
</body>
</html>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="min-h-screen">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
@ -13,38 +13,29 @@
body { font-family: 'Outfit', sans-serif; }
</style>
</head>
<body class="h-full bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-6 antialiased">
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
@php
$slot = $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first();
$product = $slot?->product;
@endphp
<div class="w-full max-w-sm animate-luxury-in">
{{-- Header Area --}}
<div class="text-center mb-8">
<div class="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 rounded-full shadow-sm border border-slate-100 dark:border-slate-800 mb-6">
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">{{ __('Valid Ticket') }}</span>
</div>
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Pickup') }} <span class="text-cyan-500">{{ __('Ticket') }}</span></h1>
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Pickup Code') }}</h1>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Scan to pick up your product') }}</p>
</div>
{{-- Ticket Card --}}
<div class="relative group">
{{-- Background Glow --}}
<div class="absolute -inset-4 bg-gradient-to-tr from-cyan-500/20 to-emerald-500/20 blur-3xl opacity-50 transition-opacity duration-500 group-hover:opacity-100"></div>
<div class="relative luxury-card rounded-[3rem] overflow-hidden border-slate-200/50 dark:border-slate-700/50 shadow-2xl bg-white dark:bg-slate-900">
{{-- Product Section --}}
<div class="px-8 pt-10 pb-6 border-b border-dashed border-slate-100 dark:border-slate-800 relative">
{{-- Notch cutouts for ticket look --}}
<div class="absolute -left-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-r border-slate-100 dark:border-slate-800"></div>
<div class="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-l border-slate-100 dark:border-slate-800"></div>
<div class="flex items-center gap-4 mb-6">
<div class="w-16 h-16 rounded-2xl bg-slate-50 dark:bg-slate-800 border border-slate-100 dark:border-slate-700 p-2 flex items-center justify-center overflow-hidden">
@php
$slot = $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first();
$product = $slot?->product;
@endphp
@if($product && $product->image_url)
<img src="{{ $product->image_url }}" class="w-full h-full object-cover">
<img src="{{ $product->image_url }}" class="w-full h-full object-cover" alt="{{ $product->name }}">
@else
<svg class="w-8 h-8 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
@ -55,6 +46,7 @@
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight leading-tight mb-1">
{{ $product?->name ?? __('Unknown Product') }}
</h2>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ $pickupCode->machine->name ?? $pickupCode->machine->serial_no }}</p>
</div>
</div>
@ -66,12 +58,11 @@
<div class="w-px h-8 bg-slate-100 dark:bg-slate-800"></div>
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Status') }}</span>
<span class="text-xs font-black px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-500 uppercase tracking-tighter">{{ __('Active') }}</span>
<span class="text-xs font-black px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 uppercase tracking-tighter">{{ __('Active') }}</span>
</div>
</div>
</div>
{{-- QR Section --}}
<div class="p-10 flex flex-col items-center">
<div class="p-6 bg-white rounded-[2.5rem] shadow-xl border border-slate-100 mb-8 transform transition-transform duration-500 group-hover:scale-[1.02]">
<x-qr-code :data="$pickupCode->code" :dynamic="false" size="300" class="w-48 h-48 sm:w-64 sm:h-64" />
@ -79,11 +70,10 @@
<div class="text-center space-y-2 mb-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em]">{{ __('Pickup Code') }}</p>
<p class="text-5xl font-black text-slate-800 dark:text-white tracking-tighter font-display italic">{{ $pickupCode->code }}</p>
<p class="text-5xl font-black text-slate-800 dark:text-white tracking-tighter font-display italic leading-none">{{ $pickupCode->code }}</p>
</div>
</div>
{{-- Expiry Area --}}
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800 text-center">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{{ __('Expiry Time') }}</p>
<p class="text-sm font-black text-slate-600 dark:text-slate-300">
@ -92,11 +82,6 @@
</div>
</div>
</div>
{{-- Footer Branding --}}
<div class="mt-12 text-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.4em] opacity-50">{{ config('app.name') }} &copy; {{ date('Y') }}</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="min-h-screen">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<title>{{ __('Welcome Gift') }} - {{ config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;900&display=swap" rel="stylesheet">
<style>
[x-cloak] { display: none !important; }
body { font-family: 'Outfit', sans-serif; }
</style>
</head>
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
<div class="w-full max-w-sm animate-luxury-in">
<div class="text-center mb-8">
<h1 class="text-4xl font-black text-slate-800 dark:text-white tracking-tight mb-2 font-display uppercase italic">{{ __('Welcome Gift') }}</h1>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Scan QR Code') }}</p>
</div>
<div class="relative group">
<div class="absolute -inset-4 bg-gradient-to-tr from-cyan-500/20 to-emerald-500/20 blur-3xl opacity-50 transition-opacity duration-500 group-hover:opacity-100"></div>
<div class="relative luxury-card rounded-[3rem] overflow-hidden border-slate-200/50 dark:border-slate-700/50 shadow-2xl bg-white dark:bg-slate-900">
<div class="px-8 pt-10 pb-6 border-b border-dashed border-slate-100 dark:border-slate-800 relative">
<div class="absolute -left-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-r border-slate-100 dark:border-slate-800"></div>
<div class="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-50 dark:bg-slate-950 rounded-full border-l border-slate-100 dark:border-slate-800"></div>
<div class="flex items-center gap-4 mb-6">
<div class="w-12 h-12 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-100 dark:border-slate-700 p-2 flex items-center justify-center overflow-hidden">
<svg class="w-6 h-6 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5a2 2 0 10-2 2h2zm0 0h4" />
</svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight leading-tight mb-1">
{{ $welcomeGift->machine->name }}
</h2>
</div>
</div>
<div class="flex items-center justify-between">
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Welcome Gift') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-white">{{ $welcomeGift->name }}</span>
</div>
<div class="w-px h-8 bg-slate-100 dark:bg-slate-800"></div>
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Status') }}</span>
<span class="text-xs font-black px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 uppercase tracking-tighter">{{ __('Active') }}</span>
</div>
</div>
</div>
<div class="p-10 flex flex-col items-center">
<div class="p-6 bg-white rounded-[2.5rem] shadow-xl border border-slate-100 mb-8 transform transition-transform duration-500 group-hover:scale-[1.02]">
<x-qr-code :data="$welcomeGift->code" :dynamic="false" size="300" class="w-48 h-48 sm:w-64 sm:h-64" />
</div>
<div class="text-center space-y-2 mb-2">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em]">{{ __('Welcome Gift Code') }}</p>
<p class="text-5xl font-black text-slate-800 dark:text-white tracking-tighter font-display italic leading-none">{{ $welcomeGift->code }}</p>
</div>
</div>
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-100 dark:border-slate-800 text-center">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">{{ __('Expiry Time') }}</p>
<p class="text-sm font-black text-slate-600 dark:text-slate-300">
{{ $welcomeGift->expires_at ? $welcomeGift->expires_at->format('Y/m/d H:i') : __('Permanent') }}
</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -210,12 +210,12 @@
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Pass Codes') }}</span>
</a></li>
@endcan
{{--
@can('menu.sales.store-gifts')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.sales.store-gifts') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.sales.store-gifts') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 12c-.07.665-.45 1.243-1.119 1.243H5.494c-.669 0-1.189-.578-1.119-1.243l-.625-12m16.5 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 7.5m17.25 0h-17.25m6 0v12m6-12v12m-9-12h12" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Store Gifts') }}</span>
</a></li>
--}}
@endcan
</ul>
</div>
</li>
@ -448,7 +448,7 @@
@endcan
--}}
@if(auth()->user()->isSystemAdmin() || (auth()->user()->isTenant() && (auth()->user()->can('menu.basic.machines') || auth()->user()->can('menu.basic.payment-configs') || auth()->user()->can('menu.basic.discord-notifications'))))
@if(auth()->user()->isSystemAdmin() || (auth()->user()->isTenant() && (auth()->user()->can('menu.basic.machines') || auth()->user()->can('menu.basic.payment-configs') || auth()->user()->can('menu.basic.discord-notifications') || auth()->user()->can('menu.basic.apk-versions'))))
{{-- 14.5. 基本設定 --}}
<li x-data="{ open: localStorage.getItem('menu_basic_settings') === 'true' || {{ request()->routeIs('admin.basic-settings.*') ? 'true' : 'false' }} }">
<button type="button" @click="if(sidebarCollapsed) { sidebarCollapsed = false; open = true; } else { open = !open; } localStorage.setItem('menu_basic_settings', open)" class="luxury-nav-item w-full text-start group">
@ -478,6 +478,12 @@
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Discord Notifications') }}</span>
</a></li>
@endcan
@can('menu.basic.apk-versions')
<li><a class="flex items-center gap-x-3 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.basic-settings.apk-versions.*') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.basic-settings.apk-versions.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('APK Versions') }}</span>
</a></li>
@endcan
</ul>
</div>
</li>

View File

@ -67,6 +67,7 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
Route::post('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']);
Route::post('machine/staff/verify/B680', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyStaffCard']);
Route::post('machine/welcome-gift/verify/B690', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyWelcomeGift']);
// 廣告與貨道清單 (B005, B009, B012)
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);

View File

@ -27,6 +27,7 @@ Route::get('/machines/distribution', [App\Http\Controllers\Admin\BasicSettings\M
// 公開取貨憑證頁面 (無需登入)
Route::get('/p/{slug}', [App\Http\Controllers\Guest\PickupController::class, 'show'])->name('pickup.ticket')->middleware('throttle:60,1');
Route::get('/t/{slug}', [App\Http\Controllers\Guest\PassCodeController::class, 'show'])->name('pass-code.ticket')->middleware('throttle:60,1');
Route::get('/g/{slug}', [App\Http\Controllers\Guest\WelcomeGiftController::class, 'show'])->name('welcome-gift.ticket')->middleware('throttle:60,1');
Route::get('/dashboard', function () {
return redirect()->route('admin.dashboard');
@ -99,6 +100,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::patch('/transfers/{transferOrder}/confirm', [App\Http\Controllers\Admin\WarehouseController::class, 'confirmTransfer'])->name('transfers.confirm');
Route::delete('/transfers/{transferOrder}', [App\Http\Controllers\Admin\WarehouseController::class, 'destroyTransfer'])->name('transfers.destroy');
Route::get('/transfers/{id}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'transferOrderDetails'])->name('transfers.details');
Route::get('/transfers/{id}/print', [App\Http\Controllers\Admin\WarehouseController::class, 'printTransfer'])->name('transfers.print');
// 模組 4機台庫存總覽 (Force Route Refresh)
Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory');
@ -114,6 +116,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::patch('/replenishments/{replenishmentOrder}/assign', [App\Http\Controllers\Admin\WarehouseController::class, 'assignReplenishment'])->name('replenishments.assign');
Route::get('/replenishments/machine-slots/{machine}', [App\Http\Controllers\Admin\WarehouseController::class, 'getMachineSlotsForReplenishment'])->name('replenishments.machine-slots');
Route::get('/replenishments/{order}/details', [App\Http\Controllers\Admin\WarehouseController::class, 'replenishmentOrderDetails'])->name('replenishments.details');
Route::get('/replenishments/{id}/print', [App\Http\Controllers\Admin\WarehouseController::class, 'printReplenishment'])->name('replenishments.print');
// AJAX 庫存查詢
Route::get('/ajax/stock', [App\Http\Controllers\Admin\WarehouseController::class, 'getStockAjax'])->name('ajax.stock');
@ -139,6 +142,9 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::get('/orders', [App\Http\Controllers\Admin\SalesController::class, 'orders'])->name('orders');
Route::get('/promotions', [App\Http\Controllers\Admin\SalesController::class, 'promotions'])->name('promotions');
Route::get('/store-gifts', [App\Http\Controllers\Admin\SalesController::class, 'storeGifts'])->name('store-gifts');
Route::post('/store-gifts', [App\Http\Controllers\Admin\SalesController::class, 'storeWelcomeGift'])->name('store-gifts.store');
Route::patch('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'updateWelcomeGift'])->name('store-gifts.update');
Route::delete('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'destroyWelcomeGift'])->name('store-gifts.destroy');
// 詳情路由必須放在最後,避免攔截其他具體路由
Route::get('/{order}', [App\Http\Controllers\Admin\SalesController::class, 'show'])->name('show');
@ -230,11 +236,22 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 13. 特殊權限管理
Route::prefix('special-permission')->name('special-permission.')->group(function () {
Route::get('/clear-stock', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'clearStock'])->name('clear-stock');
Route::get('/apk-versions', [App\Http\Controllers\Admin\SpecialPermissionController::class, 'apkVersions'])->name('apk-versions');
});
// 14. 基本設定
Route::prefix('basic-settings')->name('basic-settings.')->group(function () {
// APK 版本管理 (OTA)
Route::prefix('apk-versions')
->name('apk-versions.')
->middleware('can:menu.basic.apk-versions')
->group(function () {
Route::get('/', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'index'])->name('index');
Route::get('/create', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'create'])->name('create');
Route::post('/', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'store'])->name('store');
Route::delete('/{apkVersion}', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'destroy'])->name('destroy');
Route::post('/{apkVersion}/push', [App\Http\Controllers\Admin\BasicSettings\ApkVersionController::class, 'push'])->name('push');
});
// 機台設定
Route::prefix('machines')->name('machines.')->middleware('can:menu.basic.machines')->group(function () {
// 機台照片獨立更新

View File

@ -0,0 +1,174 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\System\User;
use App\Models\Warehouse\Warehouse;
use App\Models\Warehouse\TransferOrder;
use App\Models\Warehouse\ReplenishmentOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PrintStockOrdersTest extends TestCase
{
use RefreshDatabase;
private $companyA;
private $companyB;
private $userA;
private $userB;
private $warehouseA;
private $warehouseB;
private $machineA;
private $machineB;
protected function setUp(): void
{
parent::setUp();
// 1. 建立租戶公司
$this->companyA = Company::create(['name' => 'Company A', 'code' => 'COA']);
$this->companyB = Company::create(['name' => 'Company B', 'code' => 'COB']);
// 2. 建立使用者
$this->userA = User::factory()->create([
'company_id' => $this->companyA->id,
'name' => 'User A',
'email' => 'userA@star-cloud.com'
]);
$this->userB = User::factory()->create([
'company_id' => $this->companyB->id,
'name' => 'User B',
'email' => 'userB@star-cloud.com'
]);
// 3. 建立倉庫與機台(包含 serial_no 避免 Integrity constraint violation 錯誤)
$this->warehouseA = Warehouse::create([
'company_id' => $this->companyA->id,
'name' => 'Warehouse A',
'type' => 'main',
'is_active' => true
]);
$this->warehouseB = Warehouse::create([
'company_id' => $this->companyB->id,
'name' => 'Warehouse B',
'type' => 'main',
'is_active' => true
]);
$this->machineA = Machine::create([
'company_id' => $this->companyA->id,
'name' => 'Machine A',
'serial_no' => 'SN-MAA-01'
]);
$this->machineB = Machine::create([
'company_id' => $this->companyB->id,
'name' => 'Machine B',
'serial_no' => 'SN-MBB-02'
]);
}
/**
* 測試租戶可以成功列印自己公司的庫存調撥單
*/
public function test_tenant_can_print_own_transfer_order()
{
$this->actingAs($this->userA);
// 建立調撥單
$transfer = TransferOrder::create([
'company_id' => $this->companyA->id,
'order_no' => 'TF-202605210001',
'type' => 'warehouse_to_warehouse',
'from_warehouse_id' => $this->warehouseA->id,
'to_warehouse_id' => $this->warehouseA->id, // 本地測試可以使用同一個以簡化
'status' => 'draft',
'created_by' => $this->userA->id
]);
$response = $this->get("/admin/warehouses/transfers/{$transfer->id}/print");
$response->assertStatus(200);
$response->assertSee('TF-202605210001');
$response->assertSee('Star Cloud System');
$response->assertSee('經辦人');
$response->assertSee('調出確認');
}
/**
* 測試租戶可以成功列印自己公司的機台補貨單
*/
public function test_tenant_can_print_own_replenishment_order()
{
$this->actingAs($this->userA);
// 建立補貨單
$replenishment = ReplenishmentOrder::create([
'company_id' => $this->companyA->id,
'order_no' => 'RP-202605210002',
'warehouse_id' => $this->warehouseA->id,
'machine_id' => $this->machineA->id,
'status' => 'pending',
'created_by' => $this->userA->id
]);
$response = $this->get("/admin/warehouses/replenishments/{$replenishment->id}/print");
$response->assertStatus(200);
$response->assertSee('RP-202605210002');
$response->assertSee('Star Cloud System');
$response->assertSee('經辦人');
$response->assertSee('倉庫出庫確認');
}
/**
* 測試租戶嘗試越權存取(列印)其他租戶的調撥單會被阻斷(返回 404
*/
public function test_tenant_cannot_print_other_tenant_transfer_order()
{
// 先以 User B 建立調撥單
$this->actingAs($this->userB);
$transferB = TransferOrder::create([
'company_id' => $this->companyB->id,
'order_no' => 'TF-B-9999',
'type' => 'warehouse_to_warehouse',
'from_warehouse_id' => $this->warehouseB->id,
'to_warehouse_id' => $this->warehouseB->id,
'status' => 'draft',
'created_by' => $this->userB->id
]);
// 切換為 User A 請求列印 User B 的調撥單
$this->actingAs($this->userA);
$response = $this->get("/admin/warehouses/transfers/{$transferB->id}/print");
// 由於 TenantScoped Trait 自動隔離,會直接拋出 ModelNotFoundException 進而由 Laravel 轉換為 404
$response->assertStatus(404);
}
/**
* 測試租戶嘗試越權存取(列印)其他租戶的補貨單會被阻斷(返回 404
*/
public function test_tenant_cannot_print_other_tenant_replenishment_order()
{
// 先以 User B 建立補貨單
$this->actingAs($this->userB);
$replenishmentB = ReplenishmentOrder::create([
'company_id' => $this->companyB->id,
'order_no' => 'RP-B-9999',
'warehouse_id' => $this->warehouseB->id,
'machine_id' => $this->machineB->id,
'status' => 'pending',
'created_by' => $this->userB->id
]);
// 切換為 User A 請求列印 User B 的補貨單
$this->actingAs($this->userA);
$response = $this->get("/admin/warehouses/replenishments/{$replenishmentB->id}/print");
// 由於 TenantScoped Trait 自動隔離,會直接拋出 ModelNotFoundException 進而由 Laravel 轉換為 404
$response->assertStatus(404);
}
}

View File

@ -0,0 +1,242 @@
<?php
namespace Tests\Feature\Api\V1;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\Transaction\Order;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class WelcomeGiftTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private Machine $machine;
private string $apiToken;
protected function setUp(): void
{
parent::setUp();
// 建立 Company
$this->company = Company::create([
'name' => 'Test Company',
]);
$this->apiToken = 'test-machine-api-token-123456';
// 建立 Machine並帶入 api_token 與 company_id
$this->machine = Machine::factory()->create([
'company_id' => $this->company->id,
'api_token' => $this->apiToken,
]);
}
/**
* 1. 測試折數與趴數雙向換算 (Model Accessors)
*/
public function test_welcome_gift_discount_conversion_logic(): void
{
// A. 百分比折抵15% off (8.5折)
$giftA = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '15% off Welcome Gift',
'code' => '85000000',
'discount_type' => 'percentage',
'discount_value' => 15,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$this->assertEquals(8.5, $giftA->input_fold);
$this->assertEquals('八五折', $giftA->discount_label);
// B. 百分比折抵20% off (8折)
$giftB = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '20% off Welcome Gift',
'code' => '80000000',
'discount_type' => 'percentage',
'discount_value' => 20,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$this->assertEquals(8, $giftB->input_fold);
$this->assertEquals('八折', $giftB->discount_label);
// C. 金額折抵:折 50 元
$giftC = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '50 NTD off Welcome Gift',
'code' => '50000000',
'discount_type' => 'amount',
'discount_value' => 50,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$this->assertNull($giftC->input_fold);
$this->assertEquals('折 50 元', $giftC->discount_label);
}
/**
* 2. 測試 B690 API 驗證成功
*/
public function test_b690_verification_success(): void
{
$gift = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '15% off Welcome Gift',
'code' => '12345678',
'discount_type' => 'percentage',
'discount_value' => 15,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$response = $this->withHeader('Authorization', "Bearer {$this->apiToken}")
->postJson('/api/v1/app/machine/welcome-gift/verify/B690', [
'code' => '12345678',
]);
$response->assertStatus(200)
->assertJson([
'success' => true,
'code' => 200,
'data' => [
'name' => '15% off Welcome Gift',
'code_id' => $gift->id,
'discount_type' => 'percentage',
'discount_value' => 15,
'discount_label' => '八五折',
'usage_type' => 'once',
'status' => 'active',
],
]);
// 驗證是否有寫入 verify_success 日誌
$this->assertDatabaseHas('welcome_gift_logs', [
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'welcome_gift_id' => $gift->id,
'action' => 'verify_success',
]);
}
/**
* 3. 測試 B690 驗證防刷與 Lockout 限流鎖定
*/
public function test_b690_lockout_mechanism(): void
{
$lockoutKey = "welcome_gift_lockout:{$this->machine->id}";
$failsKey = "welcome_gift_fails:{$this->machine->id}";
Cache::forget($lockoutKey);
Cache::forget($failsKey);
// 連續 4 次錯誤
for ($i = 0; $i < 4; $i++) {
$response = $this->withHeader('Authorization', "Bearer {$this->apiToken}")
->postJson('/api/v1/app/machine/welcome-gift/verify/B690', [
'code' => '00000000',
]);
$response->assertStatus(404)
->assertJson([
'success' => false,
'remaining_attempts' => 4 - $i,
]);
}
// 第 5 次錯誤,觸發 Lockout 60 秒鎖定
$response = $this->withHeader('Authorization', "Bearer {$this->apiToken}")
->postJson('/api/v1/app/machine/welcome-gift/verify/B690', [
'code' => '00000000',
]);
$response->assertStatus(404)
->assertJson([
'success' => false,
'remaining_attempts' => 0,
]);
// 第 6 次呼叫,因 Lockout 鎖定,直接回傳 429
$response = $this->withHeader('Authorization', "Bearer {$this->apiToken}")
->postJson('/api/v1/app/machine/welcome-gift/verify/B690', [
'code' => '00000000',
]);
$response->assertStatus(429)
->assertJson([
'success' => false,
'message' => 'Too many attempts. Locked.',
]);
}
/**
* 4. 測試交易核銷閉環 (Finalize Transaction)
*/
public function test_transaction_service_finalize_will_consume_welcome_gift(): void
{
// 建立一個單次使用限制的來店禮
$gift = WelcomeGift::create([
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'name' => '15% off Welcome Gift',
'code' => '12345678',
'discount_type' => 'percentage',
'discount_value' => 15,
'usage_type' => 'once',
'usage_limit' => 1,
'status' => 'active',
]);
$transactionService = app(TransactionService::class);
$payload = [
'serial_no' => $this->machine->serial_no,
'flow_id' => 'FLOW-123456-WELCOME-GIFT',
'order' => [
'flow_id' => 'FLOW-123456-WELCOME-GIFT',
'total_amount' => 100,
'discount_amount' => 15,
'pay_amount' => 85,
'welcome_gift_id' => $gift->id,
],
];
// 執行交易 finalize
$order = $transactionService->finalizeTransaction($payload);
$this->assertInstanceOf(Order::class, $order);
$this->assertEquals($gift->id, $order->welcome_gift_id);
// 驗證來店禮 usage_count 被加 1且狀態變為 used (因為 usage_limit 為 1)
$gift->refresh();
$this->assertEquals(1, $gift->usage_count);
$this->assertEquals('used', $gift->status);
// 驗證核銷日誌被建立
$this->assertDatabaseHas('welcome_gift_logs', [
'company_id' => $this->company->id,
'machine_id' => $this->machine->id,
'welcome_gift_id' => $gift->id,
'order_id' => $order->id,
'action' => 'consume',
]);
}
}