[RELEASE] 晉升 demo 變更至 main 生產環境
1. 實作調撥單與機台補貨單的 A4 列印功能與多租戶防護。 2. 新增與優化來店禮、通行碼、取貨碼行銷促銷模組之多語系翻譯與 UI 排版。 3. 實作 OTA APK 版本管理與更新部署功能(含 API 與後台介面)。 4. 微調側邊欄選單順序,將 APK版本 移至基本設定選單最下方。 5. 從角色權限設定中徹底移除已廢棄的特殊權限 (menu.special-permission) 模組。
This commit is contained in:
commit
51d007d84e
@ -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`
|
||||
|
||||
@ -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** — 確保遵守三語系對齊、字母排序、斜線不逸出規範
|
||||
@ -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
|
||||
|
||||
161
.agents/skills/multilingual-specs/SKILL.md
Normal file
161
.agents/skills/multilingual-specs/SKILL.md
Normal 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` 確認修改範疇僅限於新增的語系,無亂序與無關的語系被刪除。
|
||||
@ -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. 推播 MQTT,command_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));
|
||||
}
|
||||
}
|
||||
@ -580,7 +580,6 @@ class PermissionController extends Controller
|
||||
'menu.analysis',
|
||||
'menu.data-config',
|
||||
'menu.remote',
|
||||
'menu.special-permission',
|
||||
'menu.basic',
|
||||
'menu.permissions',
|
||||
];
|
||||
|
||||
@ -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.'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -15,13 +15,4 @@ class SpecialPermissionController extends Controller
|
||||
'description' => '特殊權限庫存清空功能',
|
||||
]);
|
||||
}
|
||||
|
||||
// APK版本管理
|
||||
public function apkVersions()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'APK版本管理',
|
||||
'description' => 'APP版本控制與更新',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
26
app/Http/Controllers/Guest/WelcomeGiftController.php
Normal file
26
app/Http/Controllers/Guest/WelcomeGiftController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
31
app/Models/Machine/ApkVersion.php
Normal file
31
app/Models/Machine/ApkVersion.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
153
app/Models/Transaction/WelcomeGift.php
Normal file
153
app/Models/Transaction/WelcomeGift.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
app/Models/Transaction/WelcomeGiftLog.php
Normal file
56
app/Models/Transaction/WelcomeGiftLog.php
Normal 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 獲取)
|
||||
*/
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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)。'
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
480
lang/en.json
480
lang/en.json
File diff suppressed because it is too large
Load Diff
497
lang/ja.json
497
lang/ja.json
File diff suppressed because it is too large
Load Diff
445
lang/zh_TW.json
445
lang/zh_TW.json
File diff suppressed because it is too large
Load Diff
@ -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.')) {
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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.')) {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
913
resources/views/admin/sales/welcome-gifts/index.blade.php
Normal file
913
resources/views/admin/sales/welcome-gifts/index.blade.php
Normal 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
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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">
|
||||
|
||||
264
resources/views/admin/warehouses/print/replenishment.blade.php
Normal file
264
resources/views/admin/warehouses/print/replenishment.blade.php
Normal 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>
|
||||
268
resources/views/admin/warehouses/print/transfer.blade.php
Normal file
268
resources/views/admin/warehouses/print/transfer.blade.php
Normal 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>
|
||||
@ -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">
|
||||
|
||||
@ -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') }} © {{ date('Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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') }} © {{ date('Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
77
resources/views/guest/welcome-gift/show.blade.php
Normal file
77
resources/views/guest/welcome-gift/show.blade.php
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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 () {
|
||||
// 機台照片獨立更新
|
||||
|
||||
174
tests/Feature/Admin/PrintStockOrdersTest.php
Normal file
174
tests/Feature/Admin/PrintStockOrdersTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
242
tests/Feature/Api/V1/WelcomeGiftTest.php
Normal file
242
tests/Feature/Api/V1/WelcomeGiftTest.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user