Compare commits

..

7 Commits

Author SHA1 Message Date
daf8b1ebcc [FEAT] 強化機台技術員 Token 安全性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
1. 為 B000 登入發放之技術員 Token 增加 8 小時有效期限,防止 Token 永久有效之風險。
2026-04-13 17:24:57 +08:00
8f008ffb61 [FEAT] 實作 B014 機台參數下載 API 與 B000 登入認證強化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 強化 B000 登入接口:驗證成功後回傳 Sanctum Token 供後續初始化使用。
2. 實作 B014 (getSettings) API:整合機台、金流與發票設定,並映射至 Android App 預期欄位。
3. 強化安全性:B014 API 掛載 auth:sanctum 並執行 RBAC 權限檢查。
4. 更新 API 說明文件 (iot-spec.md, api-docs.php) 及技術規範 (SKILL.md)。
2026-04-13 17:04:52 +08:00
729890d7c7 [DOCS] 更新通訊系統與全域工具列開發藍圖
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
1. 更新 docs/future_todo.md:詳列第一階段核心工具(工具列、公告系統、快捷入口、身分模擬)與第二階段行銷功能(盲盒抽獎)的開發時程。
2026-04-13 16:19:37 +08:00
ad256d3d3b [FEAT] 廣告排程功能與 UI 優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 新增廣告排程功能,支援設定發布時間與下架時間。
2. 整合 Flatpickr 時間選擇器,提供與機台日誌一致的極簡奢華風 UI。
3. 優化廣告列表中的數字字體,套用 font-mono 與 tabular-nums,與客戶管理模組風格同步。
4. 修正 Alpine.js 資料同步邏輯,確保編輯模式下排程時間能正確回填。
2026-04-13 11:44:26 +08:00
5415b14a53 [DOCS] 更新 .gitignore 以排除 pptx 目錄
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m10s
1. 在 .gitignore 中新增 /docs/pptx 路徑,避免產出的簡報檔案意外進入版本控制。
2026-04-13 11:01:21 +08:00
c97776892e [FEAT] 優化客戶合約管理介面與修復日期偏移問題
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 整合「客戶詳情」與「合約歷程」至單一側邊欄,改用分頁 (Tabs) 切換介面。
2. 優化清單「服務期程」顯示:根據客戶模式(租賃/買斷)動態顯示對應期間,並使用完整文字標籤取代縮寫。
3. 修復日期 Bug:在 Company 模型指定日期序列化格式為 Y-m-d,解決時區轉換導致的日期減少一天問題。
4. 新增合約歷程資料表模型、遷移檔以及對應的多語系翻譯(中、英、日)。
5. 移除清單操作列中重複的合約歷程圖示。
2026-04-08 17:41:26 +08:00
a599b14df1 [FEAT] 優化機台硬體通訊協議與管理介面互動性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m6s
1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。
2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。
3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。
4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。
5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
2026-04-08 14:52:00 +08:00
35 changed files with 2096 additions and 189 deletions

View File

@@ -12,8 +12,18 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。 - **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
## 2. 身份認證 (Authentication) ## 2. 身份認證 (Authentication)
- **Bearer Token**:所有 API 必須在 Header 帶入 Authorization: Bearer <api_token>。 本系統採用兩階段認證模式:
- **身分綁定**:後端透過 Token 自動識別 machine_id禁止在 Body 帶入 machine 或 key 欄位。
### 2.1 維運人員認證 (User Authentication)
- **核發端點**B000 (登入)。
- **使用端點**B014 (參數下載)。
- **方式**:使用 Laravel Sanctum 核發之 **User Token**
- **Header**`Authorization: Bearer <user_token>`
### 2.2 機台通訊認證 (Machine Authentication)
- **適用 API**B010, B012, B013, B600 等後續通訊。
- **方式**:使用機台專屬之 **api_token**
- **Header**`Authorization: Bearer <api_token>`
--- ---
@@ -24,6 +34,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **URL**: POST /api/v1/app/admin/login/B000 - **URL**: POST /api/v1/app/admin/login/B000
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 | | machine | String | 是 | 機台編號 (serial_no) | M-001 |
@@ -35,9 +46,11 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **Response Body:** - **Response Body:**
> [!IMPORTANT] > [!IMPORTANT]
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。 > 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| message | String | 驗證結果 (Success 或 Failed) | Success | | message | String | 驗證結果 (Success 或 Failed) | Success |
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
--- ---
@@ -48,6 +61,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **Request Body:** 無 (GET 請求) - **Request Body:** 無 (GET 請求)
- **Response Body:** - **Response Body:**
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true | | success | Boolean | 請求是否成功 | true |
@@ -74,12 +88,14 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **URL**: PUT /api/v1/app/products/supplementary/B009 - **URL**: PUT /api/v1/app/products/supplementary/B009
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| account | String | 是 | 操作人員帳號 | 0999123456 | | account | String | 是 | 操作人員帳號 | 0999123456 |
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] | | data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
- **data 陣列內部欄位:** - **data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 | | 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| tid | Integer | 貨道編號 (Slot No) | 1 | | tid | Integer | 貨道編號 (Slot No) | 1 |
@@ -92,6 +108,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
> 當後端收到 B009 時,會根據 type 自動從該商品的配置中選取 spring_limit 或 track_limit 並自動更新該貨道的 max_stock 欄位。機台端無需手動計算上限。 > 當後端收到 B009 時,會根據 type 自動從該商品的配置中選取 spring_limit 或 track_limit 並自動更新該貨道的 max_stock 欄位。機台端無需手動計算上限。
- **Response Body (Success 200):** - **Response Body (Success 200):**
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| success | Boolean | 同步是否成功 | true | | success | Boolean | 同步是否成功 | true |
@@ -107,6 +124,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- **URL**: POST /api/v1/app/machine/status/B010 - **URL**: POST /api/v1/app/machine/status/B010
- **Authentication**: Bearer Token (Header) - **Authentication**: Bearer Token (Header)
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| current_page | Integer | 是 | 當前頁面代碼 (見下表) | 1 | | current_page | Integer | 是 | 當前頁面代碼 (見下表) | 1 |
@@ -119,6 +137,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| log_payload | Object | 否 | 額外日誌 JSON 對象 | {"code":500} | | log_payload | Object | 否 | 額外日誌 JSON 對象 | {"code":500} |
- **Response Body:** - **Response Body:**
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否處理成功 | true | | success | Boolean | 請求是否處理成功 | true |
@@ -175,3 +194,67 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| t060v41 | String | 物料編碼 (Material Code) | SKU-001 | | t060v41 | String | 物料編碼 (Material Code) | SKU-001 |
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 | | spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
| track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 | | track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 |
---
### 3.6 B013: 機台故障與異常狀態上報 (Error/Status Report)
用於接收機台發出的即時硬體狀態代碼(如卡貨、門未關),並自動由雲端後端翻譯為易讀日誌。
- **URL**: POST /api/v1/app/machine/error/B013
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| tid | Integer | 否 | 涉及之貨道編號 (Slot No) | 12 |
| error_code | String | 是 | 硬體狀態代碼 (4 位 16 進位) | "0403" |
- **回應 (Success 202):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求已接收 | true |
| code | Integer | 200 | 200 |
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0402** | Dispense successful | info | 出貨成功 |
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
| **0202** | Product empty | warning | 貨道缺貨 |
| **0412** | Elevator rise error | error | 昇降機上升異常 |
| **0415** | Pickup door error | error | 取貨門異常 |
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
| **5403** | Elevator failure | error | 昇降系統故障 |
---
### 3.7 B014: 機台參數與金鑰下載 (Config Download)
用於機台引導階段 (Provisioning),向雲端請求支付金鑰、發票設定及機台正式 API Token。
- **URL**: POST /api/v1/app/machine/setting/B014
- **Authentication**: **User Token** (Sanctum Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
- **Response Body (Success 200):**
| 欄位 (Key) | 說明 | 備註 |
| :--- | :--- | :--- |
| **t050v01** | 機台序號 | 即 machine_id |
| **api_token** | **機台正式 Token** | 初始化後應存於本地,後續 API 認證用 |
| **t050v41** | 玉山特店編號 | ESUN Merchant ID |
| **t050v42** | 玉山終端編號 | ESUN Terminal ID |
| **t050v43** | 玉山 Hash Key | ESUN Hash |
| **t050v34** | 發票特店 ID | Invoice Merchant ID |
| **t050v35** | 發票 Hash Key | Invoice Key |
| **t050v36** | 發票 Hash IV | Invoice IV |
| **TP_APP_ID** | 趨勢支付 AppID | TrendPay ID |
| **TP_APP_KEY** | 趨勢支付 Key | TrendPay Key |
> [!CAUTION]
> **安全性規範**B014 會回傳敏感金鑰與正式 Token背景必須強制進行 RBAC 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。

1
.gitignore vendored
View File

@@ -19,5 +19,6 @@ yarn-error.log
/.vscode /.vscode
/docs/API /docs/API
/docs/*.xlsx /docs/*.xlsx
/docs/pptx

View File

@@ -21,7 +21,9 @@ class AdvertisementController extends AdminController
// Tab 1: 廣告列表 // Tab 1: 廣告列表
$advertisements = Advertisement::with('company')->latest()->paginate(10); $advertisements = Advertisement::with('company')->latest()->paginate(10);
$allAds = Advertisement::active()->get();
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
$allAds = Advertisement::playing()->get();
// Tab 2: 機台廣告設置 (所需資料) // Tab 2: 機台廣告設置 (所需資料)
// 取得使用者有權限的機台列表 (已透過 Global Scope 過濾) // 取得使用者有權限的機台列表 (已透過 Global Scope 過濾)
@@ -54,6 +56,8 @@ class AdvertisementController extends AdminController
$request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB $request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB
], ],
'company_id' => 'nullable|exists:companies,id', 'company_id' => 'nullable|exists:companies,id',
'start_at' => 'nullable|date',
'end_at' => 'nullable|date|after_or_equal:start_at',
]); ]);
$user = auth()->user(); $user = auth()->user();
@@ -71,13 +75,15 @@ class AdvertisementController extends AdminController
$companyId = $user->company_id; $companyId = $user->company_id;
} }
Advertisement::create([ $advertisement = Advertisement::create([
'company_id' => $companyId, 'company_id' => $companyId,
'name' => $request->name, 'name' => $request->name,
'type' => $request->type, 'type' => $request->type,
'duration' => (int) $request->duration, 'duration' => (int) $request->duration,
'url' => Storage::disk('public')->url($path), 'url' => Storage::disk('public')->url($path),
'is_active' => true, 'is_active' => true,
'start_at' => $request->start_at,
'end_at' => $request->end_at,
]); ]);
if ($request->wantsJson()) { if ($request->wantsJson()) {
@@ -99,6 +105,8 @@ class AdvertisementController extends AdminController
'duration' => 'required|in:15,30,60', 'duration' => 'required|in:15,30,60',
'is_active' => 'boolean', 'is_active' => 'boolean',
'company_id' => 'nullable|exists:companies,id', 'company_id' => 'nullable|exists:companies,id',
'start_at' => 'nullable|date',
'end_at' => 'nullable|date|after_or_equal:start_at',
]; ];
if ($request->hasFile('file')) { if ($request->hasFile('file')) {
@@ -111,7 +119,7 @@ class AdvertisementController extends AdminController
$request->validate($rules); $request->validate($rules);
$data = $request->only(['name', 'type', 'duration']); $data = $request->only(['name', 'type', 'duration', 'start_at', 'end_at']);
$data['is_active'] = $request->has('is_active'); $data['is_active'] = $request->has('is_active');
$user = auth()->user(); $user = auth()->user();
@@ -150,7 +158,7 @@ class AdvertisementController extends AdminController
return redirect()->back()->with('success', __('Advertisement updated successfully.')); return redirect()->back()->with('success', __('Advertisement updated successfully.'));
} }
public function destroy(Advertisement $advertisement) public function destroy(Request $request, Advertisement $advertisement)
{ {
// 檢查是否有機台正投放中 // 檢查是否有機台正投放中
if ($advertisement->machineAdvertisements()->exists()) { if ($advertisement->machineAdvertisements()->exists()) {

View File

@@ -14,7 +14,8 @@ class CompanyController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$query = Company::query()->withCount(['users', 'machines']); $query = Company::query()->withCount(['users', 'machines'])
->with(['contracts.creator:id,name']);
// 搜尋 // 搜尋
if ($search = $request->input('search')) { if ($search = $request->input('search')) {
@@ -55,6 +56,10 @@ class CompanyController extends Controller
'contact_email' => 'nullable|email|max:255', 'contact_email' => 'nullable|email|max:255',
'start_date' => 'required|date', 'start_date' => 'required|date',
'end_date' => 'nullable|date', 'end_date' => 'nullable|date',
'warranty_start_date' => 'nullable|date',
'warranty_end_date' => 'nullable|date',
'software_start_date' => 'nullable|date',
'software_end_date' => 'nullable|date',
'status' => 'required|boolean', 'status' => 'required|boolean',
'note' => 'nullable|string', 'note' => 'nullable|string',
'settings' => 'nullable|array', 'settings' => 'nullable|array',
@@ -83,11 +88,28 @@ class CompanyController extends Controller
'contact_email' => $validated['contact_email'] ?? null, 'contact_email' => $validated['contact_email'] ?? null,
'start_date' => $validated['start_date'] ?? null, 'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null, 'end_date' => $validated['end_date'] ?? null,
'warranty_start_date' => $validated['warranty_start_date'] ?? null,
'warranty_end_date' => $validated['warranty_end_date'] ?? null,
'software_start_date' => $validated['software_start_date'] ?? null,
'software_end_date' => $validated['software_end_date'] ?? null,
'status' => $validated['status'], 'status' => $validated['status'],
'note' => $validated['note'] ?? null, 'note' => $validated['note'] ?? null,
'settings' => $validated['settings'] ?? [], 'settings' => $validated['settings'] ?? [],
]); ]);
// 記錄合約歷程
$company->contracts()->create([
'type' => $company->original_type,
'start_date' => $company->start_date,
'end_date' => $company->end_date,
'warranty_start_date' => $company->warranty_start_date,
'warranty_end_date' => $company->warranty_end_date,
'software_start_date' => $company->software_start_date,
'software_end_date' => $company->software_end_date,
'note' => __('Initial contract registration'),
'creator_id' => auth()->id(),
]);
// 如果有填寫帳號資訊,則建立管理員帳號 // 如果有填寫帳號資訊,則建立管理員帳號
if (!empty($validated['admin_username']) && !empty($validated['admin_password'])) { if (!empty($validated['admin_username']) && !empty($validated['admin_password'])) {
$user = \App\Models\System\User::create([ $user = \App\Models\System\User::create([
@@ -143,6 +165,10 @@ class CompanyController extends Controller
'contact_email' => 'nullable|email|max:255', 'contact_email' => 'nullable|email|max:255',
'start_date' => 'required|date', 'start_date' => 'required|date',
'end_date' => 'nullable|date', 'end_date' => 'nullable|date',
'warranty_start_date' => 'nullable|date',
'warranty_end_date' => 'nullable|date',
'software_start_date' => 'nullable|date',
'software_end_date' => 'nullable|date',
'status' => 'required|boolean', 'status' => 'required|boolean',
'note' => 'nullable|string', 'note' => 'nullable|string',
'settings' => 'nullable|array', 'settings' => 'nullable|array',
@@ -154,7 +180,22 @@ class CompanyController extends Controller
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN); $validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
} }
$company->update($validated); DB::transaction(function () use ($validated, $company) {
$company->update($validated);
// 記錄合約歷程
$company->contracts()->create([
'type' => $company->current_type,
'start_date' => $company->start_date,
'end_date' => $company->end_date,
'warranty_start_date' => $company->warranty_start_date,
'warranty_end_date' => $company->warranty_end_date,
'software_start_date' => $company->software_start_date,
'software_end_date' => $company->software_end_date,
'note' => $validated['note'] ?? __('Contract information updated'),
'creator_id' => auth()->id(),
]);
});
// 分支邏輯:若停用客戶,連帶停用其所有帳號 // 分支邏輯:若停用客戶,連帶停用其所有帳號
if ($validated['status'] == 0) { if ($validated['status'] == 0) {

View File

@@ -71,17 +71,21 @@ class MachineController extends AdminController
*/ */
public function logsAjax(Request $request, Machine $machine) public function logsAjax(Request $request, Machine $machine)
{ {
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 20);
$startDate = $request->get('start_date', now()->format('Y-m-d')); $startDate = $request->get('start_date');
$endDate = $request->get('end_date', now()->format('Y-m-d')); $endDate = $request->get('end_date');
$logs = $machine->logs() $logs = $machine->logs()
->when($request->level, function ($query, $level) { ->when($request->level, function ($query, $level) {
return $query->where('level', $level); return $query->where('level', $level);
}) })
->whereDate('created_at', '>=', $startDate) ->when($startDate, function ($query, $start) {
->whereDate('created_at', '<=', $endDate) return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->when($request->type, function ($query, $type) { ->when($request->type, function ($query, $type) {
return $query->where('type', $type); return $query->where('type', $type);
}) })

View File

@@ -318,7 +318,11 @@ class PermissionController extends Controller
} }
if ($user->isSystemAdmin() && $request->filled('company_id')) { if ($user->isSystemAdmin() && $request->filled('company_id')) {
$query->where('company_id', $request->company_id); if ($request->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $request->company_id);
}
} }
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 10);

View File

@@ -9,6 +9,7 @@ use App\Models\System\User;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Jobs\Machine\ProcessStateLog;
class MachineAuthController extends Controller class MachineAuthController extends Controller
{ {
@@ -20,30 +21,14 @@ class MachineAuthController extends Controller
{ {
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式) // 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
$validated = $request->validate([ $validated = $request->validate([
'machine' => 'required|string', 'machine' => 'required|string',
'Su_Account' => 'required|string', 'Su_Account' => 'required|string',
'Su_Password' => 'required|string', 'Su_Password' => 'required|string',
'ip' => 'nullable|string', 'ip' => 'nullable|string',
'type' => 'nullable|string', 'type' => 'nullable|string',
]); ]);
// 2. 透過帳號尋找使用者 (允許使用 username 或 email) // 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
$user = User::where('username', $validated['Su_Account'])
->orWhere('email', $validated['Su_Account'])
->first();
// 若無此帳號或密碼錯誤
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $validated['machine']
]);
// 按現行設定Java 端只認 Success 字串,其餘視為帳密錯誤
return response()->json(['message' => 'Failed']);
}
// 3. 取得機台物件
// 因為此 API 無狀態 (沒有登入 session),為了避免被 global scope 擋住,直接取消所有 scope 來撈取
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first(); $machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
if (!$machine) { if (!$machine) {
@@ -53,40 +38,82 @@ class MachineAuthController extends Controller
return response()->json(['message' => 'Failed']); return response()->json(['message' => 'Failed']);
} }
// 4. RBAC 權限驗證 (遵循多租戶與機台授權規範) // 3. 透過帳號尋找使用者 (允許使用 username 或 email)
$user = User::where('username', $validated['Su_Account'])
->orWhere('email', $validated['Su_Account'])
->first();
// 4. 驗證密碼
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $validated['machine']
]);
// 寫入機台日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("Login failed: :account", ['account' => $validated['Su_Account']]),
'warning',
[],
'login'
);
return response()->json(['message' => 'Failed']);
}
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) { if ($user->isSystemAdmin()) {
// [系統管理員] : 擁有最高權限,可登入平台下轄所有機台,直接放行 $isAuthorized = true;
} elseif ($user->is_admin) { } elseif ($user->is_admin) {
// [公司管理員] : 不需要檢查 machine_user 表,但【必須驗證】該機台是否隸屬於他的公司 if ($machine->company_id === $user->company_id) {
if ($machine->company_id !== $user->company_id) { $isAuthorized = true;
Log::warning("B000 機台登入失敗: 企圖越權登入其他公司的機台", [
'user_id' => $user->id,
'user_company' => $user->company_id,
'machine_company' => $machine->company_id
]);
return response()->json(['message' => 'Forbidden']);
} }
} else { } else {
// [一般租戶帳號] : (包括補貨員等)必須嚴格檢查該帳號有沒有被分配到這台機器的 machine_user 關聯授權 if ($user->machines()->where('machine_id', $machine->id)->exists()) {
if (!$user->machines()->where('machine_id', $machine->id)->exists()) { $isAuthorized = true;
Log::warning("B000 機台登入失敗: 該帳號沒有此機台的授權", [
'user_id' => $user->id,
'machine_id' => $machine->id
]);
return response()->json(['message' => 'Forbidden']);
} }
} }
// 5. 驗證完美通過!回傳固定字串 Success 讓 Java 端放行 if (!$isAuthorized) {
Log::warning("B000 機台登入失敗: 權限不足", [
'user_id' => $user->id,
'machine_id' => $machine->id
]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("Unauthorized login attempt: :account", ['account' => $user->username]),
'warning',
[],
'login'
);
return response()->json(['message' => 'Forbidden']);
}
// 6. 驗證完美通過!
Log::info("B000 機台登入成功", [ Log::info("B000 機台登入成功", [
'account' => $user->username, 'account' => $user->username,
'machine' => $machine->serial_no 'machine' => $machine->serial_no
]); ]);
// 寫入成功登入日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("User logged in: :name", ['name' => $user->name ?? $user->username]),
'info',
[],
'login'
);
return response()->json([ return response()->json([
'message' => 'Success' 'message' => 'Success',
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
]); ]);
} }
} }

View File

@@ -9,8 +9,11 @@ use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat; use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus; use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory; use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
class MachineController extends Controller class MachineController extends Controller
{ {
@@ -22,6 +25,69 @@ class MachineController extends Controller
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key $data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key
// === 狀態異動觸發 (Redis 快取免查 DB) ===
$cacheKey = "machine:{$machine->serial_no}:state";
$oldState = Cache::get($cacheKey);
$currentPage = $data['current_page'] ?? null;
$doorStatus = $data['door_status'] ?? null;
$firmwareVersion = $data['firmware_version'] ?? null;
$model = $data['model'] ?? null;
if ($currentPage !== null || $doorStatus !== null || $firmwareVersion !== null || $model !== null) {
// 更新目前狀態到 Redis (保存 1 天)
$newState = $oldState ?? [];
if ($currentPage !== null) $newState['current_page'] = $currentPage;
if ($doorStatus !== null) $newState['door_status'] = $doorStatus;
if ($firmwareVersion !== null) $newState['firmware_version'] = $firmwareVersion;
if ($model !== null) $newState['model'] = $model;
Cache::put($cacheKey, $newState, 86400);
// 若有歷史紀錄才進行比對 (避開 Cache Miss 造成的雪崩)
if ($oldState !== null) {
// 1. 判斷頁面是否變更
if ($currentPage !== null && (string)$currentPage !== (string)($oldState['current_page'] ?? '')) {
// 只記錄「絕對狀態」,配合 lang 中 "Page X" 的翻譯
ProcessStateLog::dispatch($machine->id, $machine->company_id, "Page {$currentPage}", 'info');
}
// 2. 判斷門禁是否變更 (0: 關閉, 1: 開啟)
if ($doorStatus !== null && (string)$doorStatus !== (string)($oldState['door_status'] ?? '')) {
$doorMessage = $doorStatus == 1 ? "Door Opened" : "Door Closed";
$doorLevel = 'info'; // 不論開關門皆為 info避免觸發異常狀態
ProcessStateLog::dispatch($machine->id, $machine->company_id, $doorMessage, $doorLevel);
}
// 3. 判斷韌體版本是否變更
if ($firmwareVersion !== null && (string)$firmwareVersion !== (string)($oldState['firmware_version'] ?? '')) {
$oldVersion = $oldState['firmware_version'] ?? 'Unknown';
// 直接在 Controller 進行翻譯並填值,確保儲存到 DB 的是最終正確字串
$versionMessage = __("Firmware updated to :version", ['version' => $firmwareVersion]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$versionMessage,
'info',
['old' => $oldVersion, 'new' => $firmwareVersion]
);
}
// 4. 判斷型號是否變更
if ($model !== null && (string)$model !== (string)($oldState['model'] ?? '')) {
$oldModel = $oldState['model'] ?? 'Unknown';
$modelMessage = __("Model changed to :model", ['model' => $model]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$modelMessage,
'info',
['old' => $oldModel, 'new' => $model]
);
}
}
}
// 異步處理狀態更新 // 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data); ProcessHeartbeat::dispatch($machine->serial_no, $data);
@@ -207,7 +273,7 @@ class MachineController extends Controller
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id) $advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
->with([ ->with([
'advertisement' => function ($query) { 'advertisement' => function ($query) {
$query->active(); $query->playing();
} }
]) ])
->get() ->get()
@@ -372,4 +438,104 @@ class MachineController extends Controller
'data' => $products 'data' => $products
]); ]);
} }
/**
* B013: Report Machine Hardware Error/Status (Asynchronous)
*/
public function reportError(Request $request)
{
$machine = $request->get('machine');
$data = $request->only(['tid', 'error_code']);
// 異步分派處理 (Dispatch to queue)
ProcessMachineError::dispatch($machine->serial_no, $data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Error report accepted',
], 202); // 202 Accepted
}
/**
* B014: Download Machine Settings & Config (Synchronous, Requires User Auth)
* 用於機台引導階段,同步金流、發票與機台專屬 API Token。
*/
public function getSettings(Request $request)
{
$serialNo = $request->input('machine');
$user = $request->user();
// 1. 查找機台 (忽略全局範圍以進行認領)
$machine = Machine::withoutGlobalScopes()
->with(['paymentConfig', 'company'])
->where('serial_no', $serialNo)
->first();
if (!$machine) {
return response()->json([
'success' => false,
'code' => 404,
'message' => 'Machine not found'
], 404);
}
// 2. 權限加強驗證 (RBAC)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
$isAuthorized = true;
} elseif ($machine->company_id === $user->company_id) {
// 公司管理員或已授權員工才能存取
if ($user->is_admin || $user->machines()->where('machine_id', $machine->id)->exists()) {
$isAuthorized = true;
}
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Forbidden: You do not have permission to configure this machine'
], 403);
}
// 3. 獲取關聯設定
$paymentSettings = $machine->paymentConfig->settings ?? [];
$companySettings = $machine->company->settings ?? [];
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
$data = [
't050v01' => $machine->serial_no,
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
// 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '',
't050v43' => $paymentSettings['esun_hash'] ?? '',
// 電子發票 (綠界)
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
't050v35' => $companySettings['invoice_hash_key'] ?? '',
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
't050v38' => $companySettings['invoice_email'] ?? '',
// 趨勢支付 (TrendPay/Greenpay)
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// 各類行動支付特店 ID
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
];
return response()->json([
'success' => true,
'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列
]);
}
} }

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessMachineError implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(MachineService $service): void
{
$machine = Machine::where('serial_no', $this->serialNo)->first();
if ($machine) {
$service->recordErrorLog($machine, $this->data);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessStateLog implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $machineId;
protected $companyId;
protected $message;
protected $level;
protected $type;
protected $context;
/**
* Create a new job instance.
*/
public function __construct(int $machineId, ?int $companyId, string $message, string $level = 'info', array $context = [], string $type = 'status')
{
$this->machineId = $machineId;
$this->companyId = $companyId;
$this->message = $message;
$this->level = $level;
$this->context = $context;
$this->type = $type;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
MachineLog::create([
'machine_id' => $this->machineId,
'company_id' => $this->companyId,
'type' => $this->type,
'level' => $this->level,
'message' => $this->message,
'context' => $this->context,
]);
} catch (\Exception $e) {
Log::error("Failed to create state log for machine {$this->machineId}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -24,6 +24,33 @@ class MachineLog extends Model
'context' => 'array', 'context' => 'array',
]; ];
protected $appends = [
'translated_message',
];
/**
* 動態重組翻譯後的訊息
*/
public function getTranslatedMessageAttribute(): string
{
$context = $this->context;
// 若 context 中已有翻譯標籤 (B013 封裝),則進行動態重組
if (isset($context['translated_label'])) {
$label = __($context['translated_label']);
$tid = $context['tid'] ?? null;
$code = $context['raw_code'] ?? '0000';
if ($tid) {
return __('Slot') . " {$tid}: {$label} (Code: {$code})";
}
return "{$label} (Code: {$code})";
}
// 預設退回原始 message (支援歷史資料的翻譯判定與佔位符替換)
return __($this->message, $context ?? []);
}
public function machine() public function machine()
{ {
return $this->belongsTo(Machine::class); return $this->belongsTo(Machine::class);

View File

@@ -18,11 +18,15 @@ class Advertisement extends Model
'duration', 'duration',
'url', 'url',
'is_active', 'is_active',
'start_at',
'end_at',
]; ];
protected $casts = [ protected $casts = [
'duration' => 'integer', 'duration' => 'integer',
'is_active' => 'boolean', 'is_active' => 'boolean',
'start_at' => 'datetime',
'end_at' => 'datetime',
]; ];
/** /**
@@ -48,4 +52,21 @@ class Advertisement extends Model
{ {
return $query->where('is_active', true); return $query->where('is_active', true);
} }
/**
* Scope a query to only include advertisements that should be playing now.
*/
public function scopePlaying($query)
{
$now = now();
return $query->where('is_active', true)
->where(function ($q) use ($now) {
$q->whereNull('start_at')
->orWhere('start_at', '<=', $now);
})
->where(function ($q) use ($now) {
$q->whereNull('end_at')
->orWhere('end_at', '>=', $now);
});
}
} }

View File

@@ -25,17 +25,33 @@ class Company extends Model
'status', 'status',
'start_date', 'start_date',
'end_date', 'end_date',
'warranty_start_date',
'warranty_end_date',
'software_start_date',
'software_end_date',
'note', 'note',
'settings', 'settings',
]; ];
protected $casts = [ protected $casts = [
'start_date' => 'date', 'start_date' => 'date:Y-m-d',
'end_date' => 'date', 'end_date' => 'date:Y-m-d',
'warranty_start_date' => 'date:Y-m-d',
'warranty_end_date' => 'date:Y-m-d',
'software_start_date' => 'date:Y-m-d',
'software_end_date' => 'date:Y-m-d',
'status' => 'integer', 'status' => 'integer',
'settings' => 'array', 'settings' => 'array',
]; ];
/**
* Get the contract history for the company.
*/
public function contracts(): HasMany
{
return $this->hasMany(CompanyContract::class)->latest();
}
/** /**
* Get the users for the company. * Get the users for the company.
*/ */

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CompanyContract extends Model
{
use HasFactory;
protected $fillable = [
'company_id',
'type',
'start_date',
'end_date',
'warranty_start_date',
'warranty_end_date',
'software_start_date',
'software_end_date',
'note',
'creator_id',
];
protected $casts = [
'start_date' => 'date:Y-m-d',
'end_date' => 'date:Y-m-d',
'warranty_start_date' => 'date:Y-m-d',
'warranty_end_date' => 'date:Y-m-d',
'software_start_date' => 'date:Y-m-d',
'software_end_date' => 'date:Y-m-d',
];
/**
* Get the company that owns the contract.
*/
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
/**
* Get the user who created the record.
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'creator_id');
}
}

View File

@@ -10,6 +10,56 @@ use Carbon\Carbon;
class MachineService class MachineService
{ {
/**
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
*/
public const ERROR_CODE_MAP = [
// 出貨狀態類 (Prefix: 04 - BUY_STATUS)
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'],
'0402' => ['label' => 'Dispense successful', 'level' => 'info'],
'0403' => ['label' => 'Slot jammed', 'level' => 'error'],
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'],
'0406' => ['label' => 'Slot not found', 'level' => 'error'],
'0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'],
'0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'],
'0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'],
'040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'],
'0410' => ['label' => 'Elevator rising', 'level' => 'info'],
'0411' => ['label' => 'Elevator descending', 'level' => 'info'],
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'],
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'],
'0414' => ['label' => 'Pickup door closed', 'level' => 'info'],
'0415' => ['label' => 'Pickup door error', 'level' => 'error'],
'0416' => ['label' => 'Delivery door opened', 'level' => 'info'],
'0417' => ['label' => 'Delivery door open error', 'level' => 'error'],
'0418' => ['label' => 'Delivering product', 'level' => 'info'],
'0419' => ['label' => 'Delivery door closed', 'level' => 'info'],
'0420' => ['label' => 'Delivery door close error', 'level' => 'error'],
'0421' => ['label' => 'Hopper empty', 'level' => 'warning'],
'0422' => ['label' => 'Hopper overheated', 'level' => 'warning'],
'0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'],
'0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'],
'0426' => ['label' => 'Microwave door opened', 'level' => 'info'],
'0427' => ['label' => 'Microwave door error', 'level' => 'error'],
'04FF' => ['label' => 'Dispense stopped', 'level' => 'info'],
// 貨道狀態類 (Prefix: 02 - SLOT_STATUS)
'0201' => ['label' => 'Slot normal', 'level' => 'info'],
'0202' => ['label' => 'Product empty', 'level' => 'warning'],
'0203' => ['label' => 'Slot empty', 'level' => 'warning'],
'0206' => ['label' => 'Slot not closed', 'level' => 'warning'],
'0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'],
'0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'],
'0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'],
'0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'],
// 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS)
'5400' => ['label' => 'Machine normal', 'level' => 'info'],
'5401' => ['label' => 'Elevator sensor error', 'level' => 'error'],
'5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'],
'5403' => ['label' => 'Elevator failure', 'level' => 'error'],
];
/** /**
* Update machine heartbeat and status. * Update machine heartbeat and status.
* *
@@ -178,6 +228,36 @@ class MachineService
}); });
} }
/**
* B013: Record machine hardware error/status log with auto-translation.
*
* @param Machine $machine
* @param array $data
* @return MachineLog
*/
public function recordErrorLog(Machine $machine, array $data): MachineLog
{
$errorCode = $data['error_code'] ?? '0000';
$mapping = self::ERROR_CODE_MAP[$errorCode] ?? ['label' => 'Unknown Status', 'level' => 'error'];
$slotNo = $data['tid'] ?? null;
$label = $mapping['label'];
// 儲存原始英文格式作為 DB 備用,前端顯示會優先使用 model accessor 的動態翻譯內容
$message = $slotNo ? "Slot {$slotNo}: {$label} (Code: {$errorCode})" : "{$label} (Code: {$errorCode})";
return $machine->logs()->create([
'company_id' => $machine->company_id,
'type' => 'submachine',
'level' => $mapping['level'],
'message' => $message,
'context' => array_merge($data, [
'translated_label' => $label,
'raw_code' => $errorCode
]),
]);
}
/** /**
* Update machine slot stock (single slot). * Update machine slot stock (single slot).
* Legacy support for recordLog (Existing code). * Legacy support for recordLog (Existing code).

View File

@@ -8,6 +8,115 @@ return [
[ [
'name' => '機台核心通訊 (IoT Core)', 'name' => '機台核心通訊 (IoT Core)',
'apis' => [ 'apis' => [
[
'name' => 'B000: 維運人員登入認證 (Technician Login)',
'slug' => 'b000-tech-login',
'method' => 'POST',
'path' => '/api/v1/app/admin/login/B000',
'description' => '機台啟動引導的第一步。維運人員輸入個人帳密與機台編號進行認證,成功後核發臨時 Sanctum Token 供後續 B014 下載敏感設定使用。',
'headers' => [
'Content-Type' => 'application/json',
],
'parameters' => [
'username' => [
'type' => 'string',
'required' => true,
'description' => '維運人員帳號',
'example' => 'admin_test'
],
'password' => [
'type' => 'string',
'required' => true,
'description' => '維運人員密碼',
'example' => 'password123'
],
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號 (Serial No)',
'example' => 'SN202604130001'
],
],
'response_parameters' => [
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Success'
],
'token' => [
'type' => 'string',
'description' => '臨時身份認證 Token (Sanctum)',
'example' => '1|abcdefg...'
],
],
'request' => [
'username' => 'admin_test',
'password' => 'password123',
'machine' => 'SN202604130001'
],
'response' => [
'message' => 'Success',
'token' => '1|abcdefg...'
],
],
[
'name' => 'B014: 機台參數與金鑰下載 (Config Download)',
'slug' => 'b014-config-download',
'method' => 'POST',
'path' => '/api/v1/app/machine/setting/B014',
'description' => '機台引導階段的第二步。在人員登入後,透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。',
'headers' => [
'Authorization' => 'Bearer <user_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號',
'example' => 'SN202604130001'
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '是否成功',
'example' => true
],
'data' => [
'type' => 'array',
'description' => '配置物件陣列。包含t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
'example' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
]
],
],
'request' => [
'machine' => 'SN202604130001'
],
'response' => [
'success' => true,
'code' => 200,
'data' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v42' => '9001',
't050v43' => 'hash_key',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
]
],
'notes' => '此 API 受 auth:sanctum 保護,必須在 Header 帶上從 B000 取得的 Token。'
],
[ [
'name' => 'B005: 廣告清單同步 (Ad Sync)', 'name' => 'B005: 廣告清單同步 (Ad Sync)',
'slug' => 'b005-ad-sync', 'slug' => 'b005-ad-sync',
@@ -302,6 +411,54 @@ return [
] ]
], ],
'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。' 'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。'
],
[
'name' => 'B013: 機台故障與異常狀態上報 (Error/Status Report)',
'slug' => 'b013-error-report',
'method' => 'POST',
'path' => '/api/v1/app/machine/error/B013',
'description' => '用於接收機台發出的即時硬體狀態代碼(如卡貨、門未關)。身份由 Bearer Token 識別,回傳成功代表伺服器已將任務列入異步隊列處理。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'tid' => [
'type' => 'integer',
'required' => false,
'description' => '涉及到之具體貨道編號 (Slot No)',
'example' => 12
],
'error_code' => [
'type' => 'string',
'required' => true,
'description' => '硬體狀態代碼 (4 位 16 進位字串)',
'example' => '0403'
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否已成功接收',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
],
'request' => [
'tid' => 12,
'error_code' => '0403',
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Error report accepted'
],
'notes' => '硬體代碼對照表見後端 MachineService::ERROR_CODE_MAP 定義。
0402: 出貨成功, 0403: 貨道卡貨, 0202: 貨道缺貨, 0415: 取貨門異常...等。'
] ]
] ]
] ]

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->date('warranty_start_date')->nullable()->after('end_date')->comment('保固起始日');
$table->date('warranty_end_date')->nullable()->after('warranty_start_date')->comment('保固結束日');
$table->date('software_start_date')->nullable()->after('warranty_end_date')->comment('軟體服務起始日');
$table->date('software_end_date')->nullable()->after('software_start_date')->comment('軟體服務結束日');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->dropColumn([
'warranty_start_date',
'warranty_end_date',
'software_start_date',
'software_end_date'
]);
});
}
};

View File

@@ -0,0 +1,39 @@
<?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('company_contracts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained()->onDelete('cascade');
$table->string('type')->comment('buyout, lease');
$table->date('start_date')->nullable();
$table->date('end_date')->nullable();
$table->date('warranty_start_date')->nullable();
$table->date('warranty_end_date')->nullable();
$table->date('software_start_date')->nullable();
$table->date('software_end_date')->nullable();
$table->text('note')->nullable();
$table->foreignId('creator_id')->nullable()->constrained('users');
$table->timestamps();
$table->index(['company_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('company_contracts');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('advertisements', function (Blueprint $table) {
$table->dateTime('start_at')->nullable()->after('url')->comment('發布時間');
$table->dateTime('end_at')->nullable()->after('start_at')->comment('下架時間');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('advertisements', function (Blueprint $table) {
$table->dropColumn(['start_at', 'end_at']);
});
}
};

View File

@@ -4,6 +4,67 @@
--- ---
## 🔐 B000: 維運人員登入認證 (Technician Login)
機台引導階段 (Provisioning) 的第一步,用於核發臨時身份 Token 以便後續下載敏感設定。
### 1. API 資訊
- **Endpoint**: `POST /api/v1/app/admin/login/B000`
- **認證方式**: 無 (需傳入 `username`, `password`, `machine`)
- **回應內容**: `token` (Sanctum Token)
### 2. 回應範例
```json
{
"message": "Success",
"token": "3|abcdef1234567890..."
}
```
---
## 🔑 B014: 機台參數與金鑰下載 (Config Download)
下載機台運作所需的支付金鑰、電子發票設定與正式通訊 Token。
### 1. API 資訊
- **Endpoint**: `POST /api/v1/app/machine/setting/B014`
- **認證方式**: **Bearer Token** (需帶上 B000 取得的 Token)
- **Header**: `Authorization: Bearer {token}`
### 2. 請求參數
- `machine`: 機台序號 (Serial No)
### 3. 回應規格 (欄位映射)
| 欄位 | 說明 | 來源範例 |
| :--- | :--- | :--- |
| `t050v01` | 機台序號 | `SN2026041301` |
| `api_token` | **機台正式 Token** | 後續 B010/B600 認證用 |
| `t050v41` | 玉山特店編號 | `ESUN_STORE_ID` |
| `t050v43` | 玉山 Hash Key | `ESUN_HASH` |
| `t050v34` | 發票特店 ID | `INV_MID` |
| `TP_APP_ID` | 趨勢支付 AppID | `TP_APP_ID` |
### 4. 回應範例 (JSON)
```json
{
"success": true,
"code": 200,
"data": [
{
"t050v01": "SN2026041301",
"api_token": "mac_token_...",
"t050v41": "8081234567",
"t050v42": "9001",
"t050v43": "password123",
"t050v34": "2000132",
"TP_APP_ID": "GREEN_001",
"...": "..."
}
]
}
```
---
## 🟢 B010: 心跳上報與狀態同步 (Heartbeat & Status) ## 🟢 B010: 心跳上報與狀態同步 (Heartbeat & Status)
機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。 機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。

43
docs/future_todo.md Normal file
View File

@@ -0,0 +1,43 @@
# Star Cloud 近期開發待辦清單 (Target Roadmap)
本文件列出了 Star Cloud 系統近期優先開發的功能模組,旨在強化系統的營運溝通能力與非同步處理效率。
---
## 🟢 核心開發階段:全域工具列與通訊系統
*本階段為目前唯一開發重心*
### 1. 全域工具列升級 (Header Toolbar)
| 功能項目 | 具體描述 | 預計開發時間 |
| :--- | :--- | :--- |
| **☁️ 下載任務中心** | 整合 Redis Queue 處理耗時報表匯出。商戶點擊匯出後背景執行,完成後透過 Header 圖示點擊下載。 | 2 天 |
| **🔔 通知中心** | 串接 Laravel Database Notifications顯示系統消息、機台警告與業務通知帶有紅點提示。 | 1 天 |
| **❓ 幫助/客服中心** | 於 Header 置入問號圖示,點擊觸發側邊抽屜 (Offcanvas),展示 FAQ 與客服聯繫窗口。 | 0.5 天 |
| **🎭 帳號切換與身分模擬** | **整合於頭像下拉選單**:支援「系統管理員切換租戶」與「租戶管理員切換子帳號」,提供顯眼的頂部模擬狀態橫幅。 | 1.5 天 |
### 2. 公告與溝通系統 (Communication System)
| 功能項目 | 具體描述 | 預計開發時間 |
| :--- | :--- | :--- |
| **📢 系統公告管理** | 建立後台發布介面,支援針對全體或特定租戶發布「一般」或「重要」公告。 | 1.5 天 |
| **🛡️ 登錄強制公告** | 實作具備「滑動解鎖」功能的彈窗。使用者必須將公告滑到底部,解鎖按鈕後才能進入 Dashboard。 | 1 天 |
### 3. 儀表板優化 (Dashboard Enhancement)
| 功能項目 | 具體描述 | 預計開發時間 |
| :--- | :--- | :--- |
| **🚀 儀表板快捷入口** | 在儀表板頂部加入一排快捷圖示(如:機台管理、訂單查詢、會員中心),方便商戶快速跳轉核心功能。 | 0.5 天 |
---
## 🟡 第二階段:進階行銷與營運工具
*優先順序:中 | 預計總工時:約 5 個開發日*
| 功能項目 | 具體描述 | 預計開發時間 |
| :--- | :--- | :--- |
| **🎁 互動盲盒抽獎** | **後台端**:實作中獎機率配置、獎項庫存管理、活動排程。**終端 API**:提供給機台大螢幕 H5/React 遊戲呼叫的開獎與配置介面。 | 4 天 |
---
## 📝 實作標準
1. **UI/UX**: 必須符合 `ui-minimal-luxury` 規範Outfit 字體、青色點綴、柔和投影)。
2. **安全性**: 權限控制必須嚴格過濾 `company_id`,公告需支援「已讀紀錄」追蹤。
3. **效能**: 下載中心必須使用非同步隊列,嚴禁在 Request 週期內執行耗時匯出。

View File

@@ -17,7 +17,7 @@
"Account updated successfully.": "Account updated successfully.", "Account updated successfully.": "Account updated successfully.",
"Account:": "Account:", "Account:": "Account:",
"accounts": "Account Management", "accounts": "Account Management",
"Accounts / Machines": "Accounts / Machines", "Accounts \/ Machines": "Accounts \/ Machines",
"Action": "Action", "Action": "Action",
"Actions": "Actions", "Actions": "Actions",
"Active": "Active", "Active": "Active",
@@ -51,7 +51,7 @@
"Advertisement Management": "Advertisement Management", "Advertisement Management": "Advertisement Management",
"Advertisement updated successfully": "Advertisement updated successfully", "Advertisement updated successfully": "Advertisement updated successfully",
"Advertisement updated successfully.": "Advertisement updated successfully.", "Advertisement updated successfully.": "Advertisement updated successfully.",
"Advertisement Video/Image": "Advertisement Video/Image", "Advertisement Video\/Image": "Advertisement Video\/Image",
"Affiliated Company": "Affiliated Company", "Affiliated Company": "Affiliated Company",
"Affiliated Unit": "Company Name", "Affiliated Unit": "Company Name",
"Affiliation": "Company Name", "Affiliation": "Company Name",
@@ -127,7 +127,7 @@
"Back to List": "Back to List", "Back to List": "Back to List",
"Badge Settings": "Badge Settings", "Badge Settings": "Badge Settings",
"Barcode": "Barcode", "Barcode": "Barcode",
"Barcode / Material": "Barcode / Material", "Barcode \/ Material": "Barcode \/ Material",
"Basic Information": "Basic Information", "Basic Information": "Basic Information",
"Basic Settings": "Basic Settings", "Basic Settings": "Basic Settings",
"Basic Specifications": "Basic Specifications", "Basic Specifications": "Basic Specifications",
@@ -164,7 +164,7 @@
"Change": "Change", "Change": "Change",
"Change Stock": "Change Stock", "Change Stock": "Change Stock",
"Channel Limits": "Channel Limits", "Channel Limits": "Channel Limits",
"Channel Limits (Track/Spring)": "Channel Limits (Track/Spring)", "Channel Limits (Track\/Spring)": "Channel Limits (Track\/Spring)",
"Channel Limits Configuration": "Channel Limits Configuration", "Channel Limits Configuration": "Channel Limits Configuration",
"ChannelId": "ChannelId", "ChannelId": "ChannelId",
"ChannelSecret": "ChannelSecret", "ChannelSecret": "ChannelSecret",
@@ -277,7 +277,7 @@
"Dispensing": "Dispensing", "Dispensing": "Dispensing",
"Duration": "Duration", "Duration": "Duration",
"Duration (Seconds)": "Duration (Seconds)", "Duration (Seconds)": "Duration (Seconds)",
"e.g. 500ml / 300g": "e.g. 500ml / 300g", "e.g. 500ml \/ 300g": "e.g. 500ml \/ 300g",
"e.g. John Doe": "e.g. John Doe", "e.g. John Doe": "e.g. John Doe",
"e.g. johndoe": "e.g. johndoe", "e.g. johndoe": "e.g. johndoe",
"e.g. Taiwan Star": "e.g. Taiwan Star", "e.g. Taiwan Star": "e.g. Taiwan Star",
@@ -315,7 +315,7 @@
"Enable Material Code": "Enable Material Code", "Enable Material Code": "Enable Material Code",
"Enable Points": "Enable Points", "Enable Points": "Enable Points",
"Enabled": "Enabled", "Enabled": "Enabled",
"Enabled/Disabled": "Enabled/Disabled", "Enabled\/Disabled": "Enabled\/Disabled",
"End Date": "End Date", "End Date": "End Date",
"Engineer": "Engineer", "Engineer": "Engineer",
"English": "English", "English": "English",
@@ -341,7 +341,7 @@
"Execution Time": "Execution Time", "Execution Time": "Execution Time",
"Exp": "Exp", "Exp": "Exp",
"Expired": "Expired", "Expired": "Expired",
"Expired / Disabled": "Expired / Disabled", "Expired \/ Disabled": "Expired \/ Disabled",
"Expiring": "Expiring", "Expiring": "Expiring",
"Expiry": "Expiry", "Expiry": "Expiry",
"Expiry Date": "Expiry Date", "Expiry Date": "Expiry Date",
@@ -570,7 +570,7 @@
"Monthly cumulative revenue overview": "Monthly cumulative revenue overview", "Monthly cumulative revenue overview": "Monthly cumulative revenue overview",
"Monthly Transactions": "Monthly Transactions", "Monthly Transactions": "Monthly Transactions",
"Multilingual Names": "Multilingual Names", "Multilingual Names": "Multilingual Names",
"N/A": "N/A", "N\/A": "N\/A",
"Name": "Name", "Name": "Name",
"Name in English": "Name in English", "Name in English": "Name in English",
"Name in Japanese": "Name in Japanese", "Name in Japanese": "Name in Japanese",
@@ -713,7 +713,7 @@
"Position": "Position", "Position": "Position",
"Preview": "Preview", "Preview": "Preview",
"Previous": "Previous", "Previous": "Previous",
"Price / Member": "Price / Member", "Price \/ Member": "Price \/ Member",
"Pricing Information": "Pricing Information", "Pricing Information": "Pricing Information",
"Product Count": "Product Count", "Product Count": "Product Count",
"Product created successfully": "Product created successfully", "Product created successfully": "Product created successfully",
@@ -825,7 +825,7 @@
"Scale level and access control": "層級與存取控制", "Scale level and access control": "層級與存取控制",
"Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.", "Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.",
"Search accounts...": "Search accounts...", "Search accounts...": "Search accounts...",
"Search by name or S/N...": "Search by name or S/N...", "Search by name or S\/N...": "Search by name or S\/N...",
"Search cargo lane": "Search cargo lane", "Search cargo lane": "Search cargo lane",
"Search Company Title...": "Search Company Title...", "Search Company Title...": "Search Company Title...",
"Search company...": "Search company...", "Search company...": "Search company...",
@@ -880,7 +880,6 @@
"Showing :from to :to of :total items": "Showing :from to :to of :total items", "Showing :from to :to of :total items": "Showing :from to :to of :total items",
"Sign in to your account": "Sign in to your account", "Sign in to your account": "Sign in to your account",
"Signed in as": "Signed in as", "Signed in as": "Signed in as",
"Slot": "Slot",
"Slot Mechanism (default: Conveyor, check for Spring)": "Slot Mechanism (default: Conveyor, check for Spring)", "Slot Mechanism (default: Conveyor, check for Spring)": "Slot Mechanism (default: Conveyor, check for Spring)",
"Slot No": "Slot No", "Slot No": "Slot No",
"Slot Status": "Slot Status", "Slot Status": "Slot Status",
@@ -902,7 +901,7 @@
"Start Date": "Start Date", "Start Date": "Start Date",
"Statistics": "Statistics", "Statistics": "Statistics",
"Status": "Status", "Status": "Status",
"Status / Temp / Sub / Card / Scan": "Status / Temp / Sub / Card / Scan", "Status \/ Temp \/ Sub \/ Card \/ Scan": "Status \/ Temp \/ Sub \/ Card \/ Scan",
"Stock": "Stock", "Stock": "Stock",
"Stock & Expiry": "Stock & Expiry", "Stock & Expiry": "Stock & Expiry",
"Stock & Expiry Management": "Stock & Expiry Management", "Stock & Expiry Management": "Stock & Expiry Management",
@@ -1052,5 +1051,106 @@
"You cannot delete your own account.": "You cannot delete your own account.", "You cannot delete your own account.": "You cannot delete your own account.",
"Your email address is unverified.": "Your email address is unverified.", "Your email address is unverified.": "Your email address is unverified.",
"Your recent account activity": "Your recent account activity", "Your recent account activity": "Your recent account activity",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "Dispensing in progress",
"Dispense successful": "Dispense successful",
"Slot jammed": "Slot jammed",
"Motor not stopped": "Motor not stopped",
"Slot not found": "Slot not found",
"Dispense error (0407)": "Dispense error (0407)",
"Dispense error (0408)": "Dispense error (0408)",
"Dispense error (0409)": "Dispense error (0409)",
"Dispense error (040A)": "Dispense error (040A)",
"Elevator rising": "Elevator rising",
"Elevator descending": "Elevator descending",
"Elevator rise error": "Elevator rise error",
"Elevator descent error": "Elevator descent error",
"Pickup door closed": "Pickup door closed",
"Pickup door error": "Pickup door error",
"Delivery door opened": "Delivery door opened",
"Delivery door open error": "Delivery door open error",
"Delivering product": "Delivering product",
"Delivery door closed": "Delivery door closed",
"Delivery door close error": "Delivery door close error",
"Hopper empty": "Hopper empty",
"Hopper overheated": "Hopper overheated",
"Hopper heating timeout": "Hopper heating timeout",
"Hopper error (0424)": "Hopper error (0424)",
"Microwave door opened": "Microwave door opened",
"Microwave door error": "Microwave door error",
"Dispense stopped": "Dispense stopped",
"Slot normal": "Slot normal",
"Product empty": "Product empty",
"Slot empty": "Slot empty",
"Slot not closed": "Slot not closed",
"Slot motor error (0207)": "Slot motor error (0207)",
"Slot motor error (0208)": "Slot motor error (0208)",
"Slot motor error (0209)": "Slot motor error (0209)",
"Hopper empty (0212)": "Hopper empty (0212)",
"Machine normal": "Machine normal",
"Elevator sensor error": "Elevator sensor error",
"Pickup door not closed": "Pickup door not closed",
"Elevator failure": "Elevator failure",
"Slot": "Slot",
"Page 0": "Offline",
"Page 1": "Home",
"Page 2": "Vending",
"Page 3": "Admin",
"Page 4": "Restock",
"Page 5": "Tutorial",
"Page 6": "Purchasing",
"Page 7": "Locked",
"Page 60": "Dispense Success",
"Page 61": "Slot Test",
"Page 62": "Payment Selection",
"Page 63": "Waiting for Payment",
"Page 64": "Dispensing",
"Page 65": "Receipt",
"Page 66": "Passcode",
"Page 67": "Pickup Code",
"Page 68": "Message",
"Page 69": "Purchase Cancelled",
"Page 610": "Purchase Ended",
"Page 611": "Store Gift",
"Page 612": "Dispense Failed",
"Door Opened": "Door Opened",
"Door Closed": "Door Closed",
"Firmware updated to :version": "Firmware updated to :version",
"Model changed to :model": "Model changed to :model",
"User logged in: :name": "User logged in: :name",
"Login failed: :account": "Login failed: :account",
"Unauthorized login attempt: :account": "Unauthorized login attempt: :account",
"Contract Model": "Contract Model",
"Warranty Service": "Warranty Service",
"Software Service": "Software Service",
"Modification History": "Modification History",
"No history records": "No history records",
"Initial contract registration": "Initial contract registration",
"Contract information updated": "Contract information updated",
"Warranty Start": "Warranty Start",
"Warranty End": "Warranty End",
"Software Start": "Software Start",
"Software End": "Software End",
"Contract History": "Contract History",
"Unlimited": "Unlimited",
"Change Note": "Change Note",
"Log Time": "Log Time",
"Service Periods": "Service Periods",
"Creator": "Creator",
"View Full History": "View Full History",
"Contract Start": "Contract Start",
"Contract End": "Contract End",
"Contract History Detail": "Contract History Detail",
"by": "by",
"Service Terms": "Service Periods",
"Contract": "Contract",
"Warranty": "Warranty",
"Software": "Software",
"Schedule": "Schedule",
"Immediate": "Immediate",
"Indefinite": "Indefinite",
"Ongoing": "Ongoing",
"Waiting": "Waiting",
"Publish Time": "Publish Time",
"Expired Time": "Expired Time"
} }

View File

@@ -17,7 +17,7 @@
"Account updated successfully.": "アカウントが正常に更新されました。", "Account updated successfully.": "アカウントが正常に更新されました。",
"Account:": "アカウント:", "Account:": "アカウント:",
"accounts": "アカウント管理", "accounts": "アカウント管理",
"Accounts / Machines": "アカウント / 機体", "Accounts \/ Machines": "アカウント \/ 機体",
"Action": "操作", "Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "有効", "Active": "有効",
@@ -51,7 +51,7 @@
"Advertisement Management": "広告管理", "Advertisement Management": "広告管理",
"Advertisement updated successfully": "広告の更新に成功しました", "Advertisement updated successfully": "広告の更新に成功しました",
"Advertisement updated successfully.": "広告の更新に成功しました。", "Advertisement updated successfully.": "広告の更新に成功しました。",
"Advertisement Video/Image": "広告動画/画像", "Advertisement Video\/Image": "広告動画\/画像",
"Affiliated Company": "所属会社", "Affiliated Company": "所属会社",
"Affiliated Unit": "所属会社", "Affiliated Unit": "所属会社",
"Affiliation": "所属会社", "Affiliation": "所属会社",
@@ -127,7 +127,7 @@
"Back to List": "リストに戻る", "Back to List": "リストに戻る",
"Badge Settings": "バッジ設定", "Badge Settings": "バッジ設定",
"Barcode": "バーコード", "Barcode": "バーコード",
"Barcode / Material": "バーコード / 素材", "Barcode \/ Material": "バーコード \/ 素材",
"Basic Information": "基本情報", "Basic Information": "基本情報",
"Basic Settings": "基本設定", "Basic Settings": "基本設定",
"Basic Specifications": "基本仕様", "Basic Specifications": "基本仕様",
@@ -164,7 +164,7 @@
"Change": "変更", "Change": "変更",
"Change Stock": "在庫変更", "Change Stock": "在庫変更",
"Channel Limits": "チャネル制限", "Channel Limits": "チャネル制限",
"Channel Limits (Track/Spring)": "チャネル上限(トラック/スプリング)", "Channel Limits (Track\/Spring)": "チャネル上限(トラック\/スプリング)",
"Channel Limits Configuration": "チャネル制限設定", "Channel Limits Configuration": "チャネル制限設定",
"ChannelId": "チャネルID", "ChannelId": "チャネルID",
"ChannelSecret": "チャネルシークレット", "ChannelSecret": "チャネルシークレット",
@@ -277,7 +277,7 @@
"Dispensing": "払い出し中", "Dispensing": "払い出し中",
"Duration": "再生時間", "Duration": "再生時間",
"Duration (Seconds)": "再生時間(秒)", "Duration (Seconds)": "再生時間(秒)",
"e.g. 500ml / 300g": "例: 500ml / 300g", "e.g. 500ml \/ 300g": "例: 500ml \/ 300g",
"e.g. John Doe": "例:山田太郎", "e.g. John Doe": "例:山田太郎",
"e.g. johndoe": "例yamadataro", "e.g. johndoe": "例yamadataro",
"e.g. Taiwan Star": "例Taiwan Star", "e.g. Taiwan Star": "例Taiwan Star",
@@ -315,7 +315,7 @@
"Enable Material Code": "品目コードを有効にする", "Enable Material Code": "品目コードを有効にする",
"Enable Points": "ポイントを有効にする", "Enable Points": "ポイントを有効にする",
"Enabled": "有効", "Enabled": "有効",
"Enabled/Disabled": "有効/無効", "Enabled\/Disabled": "有効\/無効",
"End Date": "終了日", "End Date": "終了日",
"Engineer": "エンジニア", "Engineer": "エンジニア",
"English": "英語", "English": "英語",
@@ -341,7 +341,7 @@
"Execution Time": "実行時間", "Execution Time": "実行時間",
"Exp": "有効期限", "Exp": "有効期限",
"Expired": "期限切れ", "Expired": "期限切れ",
"Expired / Disabled": "期限切れ / 無効", "Expired \/ Disabled": "期限切れ \/ 無効",
"Expiring": "期限間近", "Expiring": "期限間近",
"Expiry": "期限", "Expiry": "期限",
"Expiry Date": "有効期限", "Expiry Date": "有効期限",
@@ -569,7 +569,7 @@
"Monthly cumulative revenue overview": "月間累積収益の概要", "Monthly cumulative revenue overview": "月間累積収益の概要",
"Monthly Transactions": "月間取引数", "Monthly Transactions": "月間取引数",
"Multilingual Names": "多言語名称", "Multilingual Names": "多言語名称",
"N/A": "N/A", "N\/A": "N\/A",
"Name": "名前", "Name": "名前",
"Name in English": "英語名", "Name in English": "英語名",
"Name in Japanese": "日本語名", "Name in Japanese": "日本語名",
@@ -630,7 +630,7 @@
"OEE.Hours": "時間", "OEE.Hours": "時間",
"OEE.Orders": "注文数", "OEE.Orders": "注文数",
"OEE.Sales": "売上高", "OEE.Sales": "売上高",
"of": "/", "of": "\/",
"Offline": "オフライン", "Offline": "オフライン",
"Offline Machines": "オフライン機体", "Offline Machines": "オフライン機体",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。", "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントを削除すると、そのすべてのリソースとデータが完全に削除されます。削除する前に、保存しておきたいデータをダウンロードしてください。",
@@ -712,7 +712,7 @@
"Position": "位置", "Position": "位置",
"Preview": "プレビュー", "Preview": "プレビュー",
"Previous": "前へ", "Previous": "前へ",
"Price / Member": "価格 / 会員", "Price \/ Member": "価格 \/ 会員",
"Pricing Information": "価格情報", "Pricing Information": "価格情報",
"Product Count": "商品数", "Product Count": "商品数",
"Product created successfully": "商品が正常に作成されました", "Product created successfully": "商品が正常に作成されました",
@@ -824,7 +824,7 @@
"Scale level and access control": "規模レベルとアクセス制御", "Scale level and access control": "規模レベルとアクセス制御",
"Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。", "Scan this code to quickly access the maintenance form for this device.": "このコードをスキャンして、端末のメンテナンスフォームに素早くアクセスします。",
"Search accounts...": "アカウントを検索...", "Search accounts...": "アカウントを検索...",
"Search by name or S/N...": "名称または製造番号で検索...", "Search by name or S\/N...": "名称または製造番号で検索...",
"Search cargo lane": "貨道を検索", "Search cargo lane": "貨道を検索",
"Search Company Title...": "会社名を検索...", "Search Company Title...": "会社名を検索...",
"Search company...": "会社を検索...", "Search company...": "会社を検索...",
@@ -901,7 +901,7 @@
"Start Date": "開始日", "Start Date": "開始日",
"Statistics": "統計", "Statistics": "統計",
"Status": "ステータス", "Status": "ステータス",
"Status / Temp / Sub / Card / Scan": "状態 / 温度 / 子機 / カード / スキャン", "Status \/ Temp \/ Sub \/ Card \/ Scan": "状態 \/ 温度 \/ 子機 \/ カード \/ スキャン",
"Stock": "在庫", "Stock": "在庫",
"Stock & Expiry": "在庫と消費期限", "Stock & Expiry": "在庫と消費期限",
"Stock & Expiry Management": "在庫・期限管理", "Stock & Expiry Management": "在庫・期限管理",
@@ -1051,5 +1051,105 @@
"You cannot delete your own account.": "自分自身のアカウントを削除することはできません。", "You cannot delete your own account.": "自分自身のアカウントを削除することはできません。",
"Your email address is unverified.": "メールアドレスが未認証です。", "Your email address is unverified.": "メールアドレスが未認証です。",
"Your recent account activity": "最近のアカウントアクティビティ", "Your recent account activity": "最近のアカウントアクティビティ",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "商品搬送中",
"Dispense successful": "搬送成功",
"Slot jammed": "貨道詰まり (K-PDT)",
"Motor not stopped": "モーター停止異常",
"Slot not found": "指定貨道が見つかりません",
"Dispense error (0407)": "搬送異常 (0407)",
"Dispense error (0408)": "搬送異常 (0408)",
"Dispense error (0409)": "搬送異常 (0409)",
"Dispense error (040A)": "搬送異常 (040A)",
"Elevator rising": "昇降機上昇中",
"Elevator descending": "昇降機下降中",
"Elevator rise error": "昇降機上昇異常",
"Elevator descent error": "昇降機下降異常",
"Pickup door closed": "取出口ドア閉鎖",
"Pickup door error": "取出口ドア異常",
"Delivery door opened": "配送口ドア開放",
"Delivery door open error": "配送口ドア開放異常",
"Delivering product": "商品配送中",
"Delivery door closed": "配送口ドア閉鎖",
"Delivery door close error": "配送口ドア閉鎖異常",
"Hopper empty": "ホッパー空",
"Hopper overheated": "ホッパー過熱",
"Hopper heating timeout": "ホッパー加熱タイムアウト",
"Hopper error (0424)": "ホッパー異常 (0424)",
"Microwave door opened": "電子レンジドア開放",
"Microwave door error": "電子レンジドア異常",
"Dispense stopped": "搬送停止",
"Slot normal": "貨道正常",
"Product empty": "品切れ (PDT_EMPTY)",
"Slot empty": "貨道空 (SLOT_EMPTY)",
"Slot not closed": "貨道未閉鎖",
"Slot motor error (0207)": "貨道モーター故障 (0207)",
"Slot motor error (0208)": "貨道モーター故障 (0208)",
"Slot motor error (0209)": "貨道モーター故障 (0209)",
"Hopper empty (0212)": "ホッパー空 (0212)",
"Machine normal": "システム正常",
"Elevator sensor error": "昇降センサー異常",
"Pickup door not closed": "取出口ドア未閉鎖",
"Elevator failure": "昇降システム故障",
"Page 0": "オフライン",
"Page 1": "ホーム",
"Page 2": "販売ページ",
"Page 3": "管理ページ",
"Page 4": "補充ページ",
"Page 5": "チュートリアル",
"Page 6": "購入中",
"Page 7": "ロック中",
"Page 60": "排出成功",
"Page 61": "スロットテスト",
"Page 62": "支払い方法選択",
"Page 63": "支払い待ち",
"Page 64": "排出中",
"Page 65": "レシート",
"Page 66": "パスコード",
"Page 67": "受取コード",
"Page 68": "メッセージ",
"Page 69": "購入キャンセル",
"Page 610": "購入完了",
"Page 611": "来店ギフト",
"Page 612": "排出失敗",
"Door Opened": "機ドア開放",
"Door Closed": "機ドア閉鎖",
"Firmware updated to :version": "ファームウェア更新::version",
"Model changed to :model": "モデル変更::model",
"User logged in: :name": "ユーザーログイン::name",
"Login failed: :account": "ログイン失敗::account",
"Unauthorized login attempt: :account": "不許可のログイン試行::account",
"Contract Model": "契約モデル",
"Warranty Service": "保証サービス",
"Software Service": "ソフトウェアサービス",
"Modification History": "変更履歴",
"No history records": "履歴なし",
"Initial contract registration": "初期契約登録",
"Contract information updated": "契約情報更新",
"Warranty Start": "保証開始",
"Warranty End": "保証終了",
"Software Start": "ソフト開始",
"Software End": "ソフト終了",
"Contract History": "契約履歴",
"Unlimited": "無期限",
"Change Note": "変更メモ",
"Log Time": "記録時間",
"Service Periods": "サービス期間",
"Creator": "作成者",
"View Full History": "全履歴を表示",
"Contract Start": "契約開始",
"Contract End": "契約終了",
"Contract History Detail": "契約履歴の詳細",
"by": "作成者:",
"Service Terms": "サービス期間",
"Contract": "契約",
"Warranty": "保証",
"Software": "ソフトウェア",
"Schedule": "スケジュール設定",
"Immediate": "即時",
"Indefinite": "無期限",
"Ongoing": "進行中",
"Waiting": "待機中",
"Publish Time": "公開時間",
"Expired Time": "終了時間"
} }

View File

@@ -17,7 +17,7 @@
"Account updated successfully.": "帳號已成功更新。", "Account updated successfully.": "帳號已成功更新。",
"Account:": "帳號:", "Account:": "帳號:",
"accounts": "帳號管理", "accounts": "帳號管理",
"Accounts / Machines": "帳號 / 機台", "Accounts \/ Machines": "帳號 \/ 機台",
"Action": "操作", "Action": "操作",
"Actions": "操作", "Actions": "操作",
"Active": "使用中", "Active": "使用中",
@@ -51,7 +51,7 @@
"Advertisement Management": "廣告管理", "Advertisement Management": "廣告管理",
"Advertisement updated successfully": "廣告更新成功", "Advertisement updated successfully": "廣告更新成功",
"Advertisement updated successfully.": "廣告更新成功。", "Advertisement updated successfully.": "廣告更新成功。",
"Advertisement Video/Image": "廣告影片/圖片", "Advertisement Video\/Image": "廣告影片\/圖片",
"Affiliated Company": "公司名稱", "Affiliated Company": "公司名稱",
"Affiliated Unit": "公司名稱", "Affiliated Unit": "公司名稱",
"Affiliation": "所屬單位", "Affiliation": "所屬單位",
@@ -127,7 +127,7 @@
"Back to List": "返回列表", "Back to List": "返回列表",
"Badge Settings": "識別證", "Badge Settings": "識別證",
"Barcode": "條碼", "Barcode": "條碼",
"Barcode / Material": "條碼 / 物料編碼", "Barcode \/ Material": "條碼 \/ 物料編碼",
"Basic Information": "基本資訊", "Basic Information": "基本資訊",
"Basic Settings": "基本設定", "Basic Settings": "基本設定",
"Basic Specifications": "基本規格", "Basic Specifications": "基本規格",
@@ -164,7 +164,7 @@
"Change": "更換", "Change": "更換",
"Change Stock": "零錢庫存", "Change Stock": "零錢庫存",
"Channel Limits": "貨道上限", "Channel Limits": "貨道上限",
"Channel Limits (Track/Spring)": "貨道上限 (履帶/彈簧)", "Channel Limits (Track\/Spring)": "貨道上限 (履帶\/彈簧)",
"Channel Limits Configuration": "貨道上限配置", "Channel Limits Configuration": "貨道上限配置",
"ChannelId": "ChannelId", "ChannelId": "ChannelId",
"ChannelSecret": "ChannelSecret", "ChannelSecret": "ChannelSecret",
@@ -277,7 +277,7 @@
"Dispensing": "出貨", "Dispensing": "出貨",
"Duration": "時長", "Duration": "時長",
"Duration (Seconds)": "播放秒數", "Duration (Seconds)": "播放秒數",
"e.g. 500ml / 300g": "例如500ml / 300g", "e.g. 500ml \/ 300g": "例如500ml \/ 300g",
"e.g. John Doe": "例如:張曉明", "e.g. John Doe": "例如:張曉明",
"e.g. johndoe": "例如xiaoming", "e.g. johndoe": "例如xiaoming",
"e.g. Taiwan Star": "例如:台灣之星", "e.g. Taiwan Star": "例如:台灣之星",
@@ -315,7 +315,7 @@
"Enable Material Code": "啟用物料編號", "Enable Material Code": "啟用物料編號",
"Enable Points": "啟用點數規則", "Enable Points": "啟用點數規則",
"Enabled": "已啟用", "Enabled": "已啟用",
"Enabled/Disabled": "啟用/停用", "Enabled\/Disabled": "啟用\/停用",
"End Date": "截止日", "End Date": "截止日",
"Engineer": "維修人員", "Engineer": "維修人員",
"English": "英文", "English": "英文",
@@ -341,7 +341,7 @@
"Execution Time": "執行時間", "Execution Time": "執行時間",
"Exp": "效期", "Exp": "效期",
"Expired": "已過期", "Expired": "已過期",
"Expired / Disabled": "已過期 / 停用", "Expired \/ Disabled": "已過期 \/ 停用",
"Expiring": "效期將屆", "Expiring": "效期將屆",
"Expiry": "效期", "Expiry": "效期",
"Expiry Date": "有效日期", "Expiry Date": "有效日期",
@@ -570,7 +570,7 @@
"Monthly cumulative revenue overview": "本月累計營收概況", "Monthly cumulative revenue overview": "本月累計營收概況",
"Monthly Transactions": "本月交易統計", "Monthly Transactions": "本月交易統計",
"Multilingual Names": "多語系名稱", "Multilingual Names": "多語系名稱",
"N/A": "不適用", "N\/A": "不適用",
"Name": "名稱", "Name": "名稱",
"Name in English": "英文名稱", "Name in English": "英文名稱",
"Name in Japanese": "日文名稱", "Name in Japanese": "日文名稱",
@@ -677,7 +677,7 @@
"Pending": "等待機台領取", "Pending": "等待機台領取",
"pending": "等待機台領取", "pending": "等待機台領取",
"Performance": "效能 (Performance)", "Performance": "效能 (Performance)",
"Permanent": "永久授權", "Permanent": "永久",
"Permanently Delete Account": "永久刪除帳號", "Permanently Delete Account": "永久刪除帳號",
"Permission Settings": "權限設定", "Permission Settings": "權限設定",
"Permissions": "權限", "Permissions": "權限",
@@ -713,7 +713,7 @@
"Position": "投放位置", "Position": "投放位置",
"Preview": "預覽", "Preview": "預覽",
"Previous": "上一頁", "Previous": "上一頁",
"Price / Member": "售價 / 會員價", "Price \/ Member": "售價 \/ 會員價",
"Pricing Information": "價格資訊", "Pricing Information": "價格資訊",
"Product Count": "商品數量", "Product Count": "商品數量",
"Product created successfully": "商品已成功建立", "Product created successfully": "商品已成功建立",
@@ -825,7 +825,7 @@
"Scale level and access control": "層級與存取控制", "Scale level and access control": "層級與存取控制",
"Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。", "Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。",
"Search accounts...": "搜尋帳號...", "Search accounts...": "搜尋帳號...",
"Search by name or S/N...": "搜尋名稱或序號...", "Search by name or S\/N...": "搜尋名稱或序號...",
"Search cargo lane": "搜尋貨道編號或商品名稱", "Search cargo lane": "搜尋貨道編號或商品名稱",
"Search Company Title...": "搜尋公司名稱...", "Search Company Title...": "搜尋公司名稱...",
"Search company...": "搜尋公司...", "Search company...": "搜尋公司...",
@@ -880,7 +880,6 @@
"Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項", "Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項",
"Sign in to your account": "隨時隨地掌控您的業務。", "Sign in to your account": "隨時隨地掌控您的業務。",
"Signed in as": "登入身份", "Signed in as": "登入身份",
"Slot": "貨道",
"Slot Mechanism (default: Conveyor, check for Spring)": "貨道機制 (預設履帶,勾選為彈簧)", "Slot Mechanism (default: Conveyor, check for Spring)": "貨道機制 (預設履帶,勾選為彈簧)",
"Slot No": "貨道編號", "Slot No": "貨道編號",
"Slot Status": "貨道效期", "Slot Status": "貨道效期",
@@ -902,7 +901,7 @@
"Start Date": "起始日", "Start Date": "起始日",
"Statistics": "數據統計", "Statistics": "數據統計",
"Status": "狀態", "Status": "狀態",
"Status / Temp / Sub / Card / Scan": "狀態 / 溫度 / 下位機 / 刷卡機 / 掃碼機", "Status \/ Temp \/ Sub \/ Card \/ Scan": "狀態 \/ 溫度 \/ 下位機 \/ 刷卡機 \/ 掃碼機",
"Stock": "庫存", "Stock": "庫存",
"Stock & Expiry": "庫存與效期", "Stock & Expiry": "庫存與效期",
"Stock & Expiry Management": "庫存與效期管理", "Stock & Expiry Management": "庫存與效期管理",
@@ -1052,5 +1051,106 @@
"You cannot delete your own account.": "您無法刪除自己的帳號。", "You cannot delete your own account.": "您無法刪除自己的帳號。",
"Your email address is unverified.": "您的電子郵件地址尚未驗證。", "Your email address is unverified.": "您的電子郵件地址尚未驗證。",
"Your recent account activity": "最近的帳號活動", "Your recent account activity": "最近的帳號活動",
"待填寫": "待填寫" "待填寫": "待填寫",
"Dispensing in progress": "正在出貨中",
"Dispense successful": "出貨成功",
"Slot jammed": "貨道卡貨 (K-PDT)",
"Motor not stopped": "電機未停止",
"Slot not found": "找不到指定貨道",
"Dispense error (0407)": "出貨過程異常 (0407)",
"Dispense error (0408)": "出貨過程異常 (0408)",
"Dispense error (0409)": "出貨過程異常 (0409)",
"Dispense error (040A)": "出貨過程異常 (040A)",
"Elevator rising": "升降平台上升中",
"Elevator descending": "升降平台下降中",
"Elevator rise error": "升降平台上升異常",
"Elevator descent error": "升降平台下降異常",
"Pickup door closed": "取貨門已關閉",
"Pickup door error": "取貨門運作異常",
"Delivery door opened": "送貨門開啟",
"Delivery door open error": "送貨門開啟異常",
"Delivering product": "正在送出商品",
"Delivery door closed": "送貨門關閉",
"Delivery door close error": "送貨門關閉異常",
"Hopper empty": "料斗箱空",
"Hopper overheated": "料斗箱過熱",
"Hopper heating timeout": "料斗箱加熱逾時",
"Hopper error (0424)": "料斗箱異常 (0424)",
"Microwave door opened": "微波爐門開啟",
"Microwave door error": "微波爐門異常",
"Dispense stopped": "出貨停止",
"Slot normal": "貨道正常",
"Product empty": "貨道缺貨 (PDT_EMPTY)",
"Slot empty": "貨道空 (SLOT_EMPTY)",
"Slot not closed": "貨道未關閉",
"Slot motor error (0207)": "貨道電機故障 (0207)",
"Slot motor error (0208)": "貨道電機故障 (0208)",
"Slot motor error (0209)": "貨道電機故障 (0209)",
"Hopper empty (0212)": "料斗空 (0212)",
"Machine normal": "機台系統正常",
"Elevator sensor error": "升降箱感測異常",
"Pickup door not closed": "取貨門未關閉",
"Elevator failure": "升降系統故障",
"Slot": "貨道",
"Page 0": "離線",
"Page 1": "主頁面",
"Page 2": "販賣頁",
"Page 3": "管理頁",
"Page 4": "補貨頁",
"Page 5": "教學頁",
"Page 6": "購買中",
"Page 7": "鎖定頁",
"Page 60": "出貨成功",
"Page 61": "貨道測試",
"Page 62": "付款選擇",
"Page 63": "等待付款",
"Page 64": "出貨",
"Page 65": "收據簽單",
"Page 66": "通行碼",
"Page 67": "取貨碼",
"Page 68": "訊息顯示",
"Page 69": "取消購買",
"Page 610": "購買結束",
"Page 611": "來店禮",
"Page 612": "出貨失敗",
"Door Opened": "機門已開啟",
"Door Closed": "機門已關閉",
"Firmware updated to :version": "韌體版本更新::version",
"Model changed to :model": "型號變更::model",
"User logged in: :name": "使用者登入::name",
"Login failed: :account": "登入失敗::account",
"Unauthorized login attempt: :account": "越權登入嘗試::account",
"Contract Model": "合約模式",
"Warranty Service": "保固服務",
"Software Service": "軟體服務",
"Modification History": "異動歷程",
"No history records": "尚無歷史紀錄",
"Initial contract registration": "初始合約註冊",
"Contract information updated": "合約資訊已更新",
"Warranty Start": "保固起始",
"Warranty End": "保固結束",
"Software Start": "軟體起始",
"Software End": "軟體結束",
"Contract History": "合約歷程",
"Unlimited": "無限期",
"Change Note": "異動備註",
"Log Time": "記錄時間",
"Service Periods": "服務區間",
"Creator": "建立者",
"View Full History": "查看完整歷程",
"Contract Start": "合約起始",
"Contract End": "合約結束",
"Contract History Detail": "合約歷程詳情",
"by": "由",
"Service Terms": "服務期程",
"Contract": "合約",
"Warranty": "保固",
"Software": "軟體",
"Schedule": "排程區間",
"Immediate": "立即",
"Indefinite": "無限期",
"Ongoing": "進行中",
"Waiting": "等待中",
"Publish Time": "發布時間",
"Expired Time": "下架時間"
} }

7
package-lock.json generated
View File

@@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"flatpickr": "^4.6.13",
"pptxgenjs": "^4.0.1" "pptxgenjs": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -1506,6 +1507,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/flatpickr": {
"version": "4.6.13",
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
"license": "MIT"
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",

View File

@@ -19,6 +19,7 @@
"vite": "^5.0.0" "vite": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"flatpickr": "^4.6.13",
"pptxgenjs": "^4.0.1" "pptxgenjs": "^4.0.1"
} }
} }

View File

@@ -1,3 +1,5 @@
@import 'flatpickr/dist/flatpickr.min.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -155,6 +157,17 @@
[x-cloak] { [x-cloak] {
display: none !important; display: none !important;
} }
/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
} }
@layer components { @layer components {
@@ -317,4 +330,204 @@
.luxury-select-sm .hs-select-toggle { .luxury-select-sm .hs-select-toggle {
@apply py-2.5 text-sm !important; @apply py-2.5 text-sm !important;
} }
} }
/* Flatpickr Luxury Theme Overrides */
.flatpickr-calendar {
border: 1px solid #e2e8f0 !important;
border-radius: 1.25rem !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
background: #ffffff !important;
padding: 4px !important;
}
.dark .flatpickr-calendar {
background: #1e293b !important;
border-color: #334155 !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important;
}
.flatpickr-day {
color: #475569 !important;
border-radius: 12px !important;
font-weight: 500 !important;
}
.dark .flatpickr-day {
color: #cbd5e1 !important;
}
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay {
color: #cbd5e1 !important;
opacity: 0.3;
}
.dark .flatpickr-day.prevMonthDay,
.dark .flatpickr-day.nextMonthDay {
color: #475569 !important;
opacity: 0.8;
}
.flatpickr-day.today {
border-color: #06b6d4 !important;
color: #06b6d4 !important;
}
.flatpickr-day.selected {
background: linear-gradient(135deg, #06b6d4, #3b82f6) !important;
border-color: transparent !important;
color: white !important;
box-shadow: 0 8px 15px -3px rgba(6, 182, 212, 0.4) !important;
}
.flatpickr-day:not(.selected):hover {
background: #f1f5f9 !important;
}
.dark .flatpickr-day:not(.selected):hover {
background: #334155 !important;
}
/* Weekdays & Header */
.flatpickr-weekday {
color: #94a3b8 !important;
font-weight: 800 !important;
font-size: 11px !important;
}
.dark .flatpickr-weekday {
color: #475569 !important;
}
.flatpickr-months .flatpickr-month {
color: #1e293b !important;
fill: currentColor !important;
}
.dark .flatpickr-months .flatpickr-month {
color: #f8fafc !important;
}
.flatpickr-prev-month,
.flatpickr-next-month {
color: #1e293b !important;
fill: currentColor !important;
@apply transition-colors duration-200 !important;
}
.dark .flatpickr-prev-month,
.dark .flatpickr-next-month {
color: #cbd5e1 !important;
}
.flatpickr-prev-month:hover,
.flatpickr-next-month:hover {
color: #06b6d4 !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
font-weight: 800 !important;
@apply bg-transparent dark:bg-slate-800 text-slate-900 dark:text-slate-100 !important;
border: none !important;
border-radius: 6px !important;
padding: 2px 4px !important;
cursor: pointer !important;
}
.flatpickr-monthDropdown-month {
@apply bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 !important;
}
/* Time Section */
.flatpickr-time {
border-top: 1px solid #f1f5f9 !important;
margin-top: 8px !important;
padding-top: 8px !important;
}
.dark .flatpickr-time {
border-top-color: #334155 !important;
}
.flatpickr-time input {
color: #1e293b !important;
font-weight: 800 !important;
border-radius: 8px !important;
}
.dark .flatpickr-time input {
color: #f8fafc !important;
}
.flatpickr-time input:hover,
.flatpickr-time input:focus {
background: #f1f5f9 !important;
}
.dark .flatpickr-time input:hover,
.dark .flatpickr-time input:focus {
background: #334155 !important;
}
.dark .flatpickr-time .flatpickr-am-pm {
color: #f8fafc !important;
}
/* Time Stepper Arrows & Separator */
.flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #94a3b8 !important;
}
.flatpickr-time .numInputWrapper span.arrowUp:hover:after {
border-bottom-color: #06b6d4 !important;
}
.flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #94a3b8 !important;
}
.flatpickr-time .numInputWrapper span.arrowDown:hover:after {
border-top-color: #06b6d4 !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #64748b !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowUp:hover:after {
border-bottom-color: #06b6d4 !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #64748b !important;
}
.dark .flatpickr-time .numInputWrapper span.arrowDown:hover:after {
border-top-color: #06b6d4 !important;
}
.flatpickr-time .numInputWrapper span {
border-color: #f1f5f9 !important;
}
.dark .flatpickr-time .numInputWrapper span {
border-color: #334155 !important;
background: transparent !important;
}
.dark .flatpickr-time .numInputWrapper span:hover {
background: #334155 !important;
}
.flatpickr-time .flatpickr-time-separator {
color: #94a3b8 !important;
}
.dark .flatpickr-time .flatpickr-time-separator {
color: #64748b !important;
}
.flatpickr-weekdays {
background: transparent !important;
}

View File

@@ -3,11 +3,32 @@ import './bootstrap';
import Alpine from 'alpinejs'; import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse'; import collapse from '@alpinejs/collapse';
// 初始化 Preline UI
import 'preline';
// 引入 Flatpickr 與語系
import flatpickr from "flatpickr";
import { MandarinTraditional } from "flatpickr/dist/l10n/zh-tw.js";
import { Japanese } from "flatpickr/dist/l10n/ja.js";
const docLang = document.documentElement.lang.toLowerCase();
if (docLang.includes('zh')) {
flatpickr.localize(MandarinTraditional);
window.flatpickrLocale = MandarinTraditional;
} else if (docLang.includes('ja')) {
flatpickr.localize(Japanese);
window.flatpickrLocale = Japanese;
} else {
// English is the default in flatpickr
window.flatpickrLocale = 'default';
}
window.flatpickr = flatpickr;
Alpine.plugin(collapse); Alpine.plugin(collapse);
window.Alpine = Alpine; window.Alpine = Alpine;
// 確保其他套件都初始化完成後再啟動 Alpine
Alpine.start(); Alpine.start();
// 初始化 Preline UI
import 'preline';

View File

@@ -71,6 +71,7 @@ $baseRoute = 'admin.data-config.advertisements';
@endif @endif
<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">{{ __('Type') }}</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">{{ __('Type') }}</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">{{ __('Duration') }}</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">{{ __('Duration') }}</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">{{ __('Schedule') }}</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">{{ __('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-center">{{ __('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> <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> </tr>
@@ -104,15 +105,34 @@ $baseRoute = 'admin.data-config.advertisements';
{{ __($ad->type) }} {{ __($ad->type) }}
</span> </span>
</td> </td>
<td class="px-6 py-4 text-center whitespace-nowrap text-sm font-black text-slate-700 dark:text-slate-200"> <td class="px-6 py-4 text-center whitespace-nowrap text-sm font-mono font-bold text-slate-700 dark:text-slate-200">
{{ $ad->duration }}s {{ $ad->duration }}s
</td> </td>
<td class="px-6 py-4 text-center whitespace-nowrap">
<div class="flex flex-col items-center gap-0.5">
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('From') }}: {{ $ad->start_at?->format('Y-m-d H:i') ?? __('Immediate') }}</span>
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('To') }}: {{ $ad->end_at?->format('Y-m-d H:i') ?? __('Indefinite') }}</span>
</div>
</td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
@if($ad->is_active) @php
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Active') }}</span> $now = now();
@else $isStarted = !$ad->start_at || $ad->start_at <= $now;
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span> $isExpired = $ad->end_at && $ad->end_at < $now;
@endif $isPlaying = $ad->is_active && $isStarted && !$isExpired;
@endphp
<div class="flex flex-col items-center gap-1">
@if(!$ad->is_active)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-slate-500/10 text-slate-500 border border-slate-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
@elseif($isExpired)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Expired') }}</span>
@elseif(!$isStarted)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 tracking-widest uppercase">{{ __('Waiting') }}</span>
@else
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Ongoing') }}</span>
@endif
</div>
</td> </td>
<td class="px-6 py-4 text-right"> <td class="px-6 py-4 text-right">
<div class="flex justify-end items-center gap-2"> <div class="flex justify-end items-center gap-2">
@@ -211,7 +231,7 @@ $baseRoute = 'admin.data-config.advertisements';
</button> </button>
<div class="flex-1 min-w-0 flex flex-col justify-center cursor-pointer group-hover:text-cyan-500 transition-colors" @click="openPreview(assign.advertisement)"> <div class="flex-1 min-w-0 flex flex-col justify-center cursor-pointer group-hover:text-cyan-500 transition-colors" @click="openPreview(assign.advertisement)">
<p class="text-xs font-black text-slate-700 dark:text-white truncate transition-colors" x-text="assign.advertisement.name"></p> <p class="text-xs font-black text-slate-700 dark:text-white truncate transition-colors" x-text="assign.advertisement.name"></p>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter mt-0.5" x-text="assign.advertisement.duration + 's'"></p> <p class="text-[11px] font-mono font-bold text-slate-400 uppercase tracking-tight mt-0.5" x-text="assign.advertisement.duration + 's'"></p>
</div> </div>
<button @click="removeAssignment(assign.id)" class="p-1.5 text-slate-300 hover:text-rose-500 transition-colors"> <button @click="removeAssignment(assign.id)" class="p-1.5 text-slate-300 hover:text-rose-500 transition-colors">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg> <svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
@@ -324,7 +344,7 @@ $baseRoute = 'admin.data-config.advertisements';
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span> <span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
<span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span> <span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span>
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span> <span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
<span class="text-white/80 font-bold tracking-widest text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span> <span class="text-white/80 font-mono font-bold tracking-tight text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -385,7 +405,9 @@ $baseRoute = 'admin.data-config.advertisements';
type: 'image', type: 'image',
duration: 15, duration: 15,
is_active: true, is_active: true,
url: '' url: '',
start_at: '',
end_at: ''
}, },
// Assign Modal // Assign Modal
@@ -604,6 +626,17 @@ $baseRoute = 'admin.data-config.advertisements';
} }
}, },
formatDateForInput(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
},
async submitAssignment() { async submitAssignment() {
try { try {
const response = await fetch(this.urls.assign, { const response = await fetch(this.urls.assign, {
@@ -650,6 +683,14 @@ $baseRoute = 'admin.data-config.advertisements';
if (document.querySelector('#ad_company_select')) { if (document.querySelector('#ad_company_select')) {
window.HSSelect.getInstance('#ad_company_select')?.setValue(this.adForm.company_id || ''); window.HSSelect.getInstance('#ad_company_select')?.setValue(this.adForm.company_id || '');
} }
// 確保 Flatpickr 實例同步顯示目前的時間值
if (this.$refs.startAtPicker?._flatpickr) {
this.$refs.startAtPicker._flatpickr.setDate(this.adForm.start_at);
}
if (this.$refs.endAtPicker?._flatpickr) {
this.$refs.endAtPicker._flatpickr.setDate(this.adForm.end_at);
}
}); });
} }
}); });
@@ -685,7 +726,7 @@ $baseRoute = 'admin.data-config.advertisements';
openAddModal() { openAddModal() {
this.adFormMode = 'add'; this.adFormMode = 'add';
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '' }; this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '', start_at: '', end_at: '' };
this.fileName = ''; this.fileName = '';
this.mediaPreview = null; this.mediaPreview = null;
this.isAdModalOpen = true; this.isAdModalOpen = true;
@@ -693,7 +734,11 @@ $baseRoute = 'admin.data-config.advertisements';
openEditModal(ad) { openEditModal(ad) {
this.adFormMode = 'edit'; this.adFormMode = 'edit';
this.adForm = { ...ad }; this.adForm = {
...ad,
start_at: this.formatDateForInput(ad.start_at),
end_at: this.formatDateForInput(ad.end_at)
};
this.fileName = ''; this.fileName = '';
this.mediaPreview = ad.url; // Use existing URL as preview this.mediaPreview = ad.url; // Use existing URL as preview
this.isAdModalOpen = true; this.isAdModalOpen = true;

View File

@@ -94,6 +94,51 @@
</div> </div>
</div> </div>
<!-- Scheduling -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Publish Time') }}
</label>
<div class="relative group/input">
<input type="text" name="start_at" x-ref="startAtPicker" x-model="adForm.start_at"
x-init="flatpickr($refs.startAtPicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
onClose: (selectedDates, dateStr) => { adForm.start_at = dateStr; }
})"
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
placeholder="YYYY/MM/DD HH:MM">
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Expired Time') }}
</label>
<div class="relative group/input">
<input type="text" name="end_at" x-ref="endAtPicker" x-model="adForm.end_at"
x-init="flatpickr($refs.endAtPicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
onClose: (selectedDates, dateStr) => { adForm.end_at = dateStr; }
})"
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
placeholder="YYYY/MM/DD HH:MM">
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
</div>
</div>
</div>
<!-- File Upload (Luxury UI Pattern) --> <!-- File Upload (Luxury UI Pattern) -->
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1"> <label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">

View File

@@ -2,8 +2,11 @@
@section('content') @section('content')
<div class="space-y-6" x-data="{ <div class="space-y-6" x-data="{
showModal: false, showModal: false,
showHistoryModal: false,
editing: false, editing: false,
sidebarView: 'detail',
currentCompany: { currentCompany: {
id: '', id: '',
name: '', name: '',
@@ -16,6 +19,10 @@
contact_email: '', contact_email: '',
start_date: '', start_date: '',
end_date: '', end_date: '',
warranty_start_date: '',
warranty_end_date: '',
software_start_date: '',
software_end_date: '',
status: 1, status: 1,
note: '', note: '',
settings: { settings: {
@@ -28,7 +35,10 @@
this.currentCompany = { this.currentCompany = {
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease', id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
tax_id: '', contact_name: '', contact_phone: '', tax_id: '', contact_name: '', contact_phone: '',
contact_email: '', start_date: '', end_date: '', status: 1, note: '', contact_email: '', start_date: '', end_date: '',
warranty_start_date: '', warranty_end_date: '',
software_start_date: '', software_end_date: '',
status: 1, note: '',
settings: { enable_material_code: false, enable_points: false } settings: { enable_material_code: false, enable_points: false }
}; };
this.showModal = true; this.showModal = true;
@@ -39,6 +49,10 @@
...company, ...company,
start_date: company.start_date ? company.start_date.substring(0, 10) : '', start_date: company.start_date ? company.start_date.substring(0, 10) : '',
end_date: company.end_date ? company.end_date.substring(0, 10) : '', end_date: company.end_date ? company.end_date.substring(0, 10) : '',
warranty_start_date: company.warranty_start_date ? company.warranty_start_date.substring(0, 10) : '',
warranty_end_date: company.warranty_end_date ? company.warranty_end_date.substring(0, 10) : '',
software_start_date: company.software_start_date ? company.software_start_date.substring(0, 10) : '',
software_end_date: company.software_end_date ? company.software_end_date.substring(0, 10) : '',
settings: { settings: {
enable_material_code: company.settings?.enable_material_code || false, enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false enable_points: company.settings?.enable_points || false
@@ -57,9 +71,13 @@
detailCompany: { detailCompany: {
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease', id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
tax_id: '', contact_name: '', contact_phone: '', contact_email: '', tax_id: '', contact_name: '', contact_phone: '', contact_email: '',
start_date: '', end_date: '', status: 1, note: '', start_date: '', end_date: '',
warranty_start_date: '', warranty_end_date: '',
software_start_date: '', software_end_date: '',
status: 1, note: '',
settings: { enable_material_code: false, enable_points: false }, settings: { enable_material_code: false, enable_points: false },
users_count: 0, machines_count: 0 users_count: 0, machines_count: 0,
contracts: []
}, },
openDetailSidebar(company) { openDetailSidebar(company) {
this.detailCompany = { this.detailCompany = {
@@ -69,15 +87,40 @@
enable_points: company.settings?.enable_points || false enable_points: company.settings?.enable_points || false
} }
}; };
this.sidebarView = 'detail';
this.showDetail = true; this.showDetail = true;
}, },
openHistorySidebar(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.sidebarView = 'history';
this.showDetail = true;
},
openFullHistory() {
this.showHistoryModal = true;
},
openHistory(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.showHistoryModal = true;
},
submitConfirmedForm() { submitConfirmedForm() {
if (this.statusToggleSource === 'list') { if (this.statusToggleSource === 'list') {
this.$refs.statusToggleForm.submit(); this.$refs.statusToggleForm.submit();
} else { } else {
this.$refs.companyForm.submit(); this.$refs.companyForm.submit();
} }
} },
}"> }">
<!-- Header --> <!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
@@ -164,7 +207,7 @@
{{ __('Accounts / Machines') }}</th> {{ __('Accounts / Machines') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Contract Period') }}</th> {{ __('Service Terms') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}</th> {{ __('Actions') }}</th>
@@ -235,20 +278,53 @@
</div> </div>
</div> </div>
</td> </td>
<td class="px-6 py-6 text-center text-slate-500 dark:text-slate-400"> <td class="px-6 py-6 border-b border-slate-50 dark:border-slate-800/50">
<div class="flex flex-col items-center gap-1.5 font-mono"> <div class="flex flex-col gap-2.5 min-w-[200px] mx-auto w-fit">
<div class="flex items-center gap-2 min-w-[130px] justify-center"> <!-- Contract Period (Only for Lease) -->
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{{ __('From:') }}</span> @if($company->current_type === 'lease')
<span class="text-[13px] font-bold tracking-tighter text-slate-600 dark:text-slate-300"> <div class="flex items-center gap-3 group/term">
{{ $company->start_date ? $company->start_date->format('Y-m-d') : '--' }} <span class="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-600 text-[10px] font-black border border-blue-500/20 group-hover/term:bg-blue-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Contract') }}
</span> </span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->start_date ? $company->start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->end_date && $company->end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-600 dark:text-slate-300' }}">
{{ $company->end_date ? $company->end_date->format('Y-m-d') : __('Permanent') }}
</span>
</div>
</div> </div>
<div class="flex items-center gap-2 min-w-[130px] justify-center"> @endif
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{{ __('To:') }}</span>
<span class="text-[13px] font-bold tracking-tighter {{ $company->end_date && $company->end_date->isPast() ? 'text-rose-500' : 'text-slate-800 dark:text-slate-200' }}"> @if($company->current_type === 'buyout')
{{ $company->end_date ? $company->end_date->format('Y-m-d') : __('Permanent') }} <!-- Warranty Period -->
<div class="flex items-center gap-3 group/term">
<span class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-600 text-[10px] font-black border border-amber-500/20 group-hover/term:bg-amber-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Warranty') }}
</span> </span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->warranty_start_date ? $company->warranty_start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->warranty_end_date && $company->warranty_end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-500 dark:text-slate-400' }}">
{{ $company->warranty_end_date ? $company->warranty_end_date->format('Y-m-d') : '--' }}
</span>
</div>
</div> </div>
<!-- Software Period -->
<div class="flex items-center gap-3 group/term">
<span class="px-2 py-0.5 rounded-md bg-purple-500/10 text-purple-600 text-[10px] font-black border border-purple-500/20 group-hover/term:bg-purple-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Software') }}
</span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->software_start_date ? $company->software_start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->software_end_date && $company->software_end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-500 dark:text-slate-400' }}">
{{ $company->software_end_date ? $company->software_end_date->format('Y-m-d') : '--' }}
</span>
</div>
</div>
@endif
</div> </div>
</td> </td>
<td class="px-6 py-6 text-right"> <td class="px-6 py-6 text-right">
@@ -447,11 +523,11 @@
<input type="text" name="tax_id" x-model="currentCompany.tax_id" <input type="text" name="tax_id" x-model="currentCompany.tax_id"
class="luxury-input w-full"> class="luxury-input w-full">
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3" x-show="currentCompany.current_type === 'lease'">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Start Date') }} <span class="text-rose-500 ml-0.5">*</span></label> __('Start Date') }} <span class="text-rose-500 ml-0.5">*</span></label>
<input type="date" name="start_date" x-model="currentCompany.start_date" required <input type="date" name="start_date" x-model="currentCompany.start_date" :required="currentCompany.current_type === 'lease'"
class="luxury-input w-full px-2"> class="luxury-input w-full px-2">
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -462,6 +538,38 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Buyout Specific Dates -->
<div x-show="currentCompany.current_type === 'buyout'" x-transition class="space-y-4 pt-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-[10px] font-black text-amber-600 uppercase tracking-widest pl-1">{{ __('Warranty Start') }}</label>
<input type="date" name="warranty_start_date" x-model="currentCompany.warranty_start_date"
class="luxury-input w-full px-2 border-amber-100 dark:border-amber-900/30">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-amber-600 uppercase tracking-widest pl-1">{{ __('Warranty End') }}</label>
<input type="date" name="warranty_end_date" x-model="currentCompany.warranty_end_date"
class="luxury-input w-full px-2 border-amber-100 dark:border-amber-900/30">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest pl-1">{{ __('Software Start') }}</label>
<input type="date" name="software_start_date" x-model="currentCompany.software_start_date"
class="luxury-input w-full px-2 border-indigo-100 dark:border-indigo-900/30">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest pl-1">{{ __('Software End') }}</label>
<input type="date" name="software_end_date" x-model="currentCompany.software_end_date"
class="luxury-input w-full px-2 border-indigo-100 dark:border-indigo-900/30">
</div>
</div>
</div>
<!-- Keep hidden start_date for buyout as well if needed by backend -->
<input type="hidden" name="start_date" :value="currentCompany.start_date" x-if="currentCompany.current_type === 'buyout' && !currentCompany.start_date">
</div>
</div> </div>
<!-- Admin Account Section (Account Creation) - Only show when creating --> <!-- Admin Account Section (Account Creation) - Only show when creating -->
@@ -652,7 +760,7 @@
<!-- Header --> <!-- Header -->
<div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900 sticky top-0 z-10"> <div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900 sticky top-0 z-10">
<div> <div>
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Customer Details') }}</h2> <h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight" x-text="sidebarView === 'history' ? '{{ __('Contract History Detail') }}' : '{{ __('Customer Details') }}'"></h2>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]" x-text="detailCompany.name"></p> <p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]" x-text="detailCompany.name"></p>
<span class="text-xs font-mono font-black text-cyan-500 px-1.5 py-0.5 bg-cyan-500/10 rounded" x-text="detailCompany.code"></span> <span class="text-xs font-mono font-black text-cyan-500 px-1.5 py-0.5 bg-cyan-500/10 rounded" x-text="detailCompany.code"></span>
@@ -666,7 +774,19 @@
</div> </div>
<!-- Body --> <!-- Body -->
<div class="flex-1 overflow-y-auto px-8 py-8 space-y-8 custom-scrollbar"> <div class="flex-1 overflow-y-auto px-8 pt-4 pb-8 space-y-5 custom-scrollbar">
<!-- Tab Switcher -->
<div class="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800/60 rounded-xl">
<button @click="sidebarView = 'detail'"
:class="sidebarView === 'detail' ? 'bg-white dark:bg-slate-700 text-slate-800 dark:text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'"
class="flex-1 py-2.5 px-3 text-xs font-black uppercase tracking-[0.15em] rounded-lg transition-all">{{ __('Customer Details') }}</button>
<button @click="sidebarView = 'history'"
:class="sidebarView === 'history' ? 'bg-white dark:bg-slate-700 text-slate-800 dark:text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'"
class="flex-1 py-2.5 px-3 text-xs font-black uppercase tracking-[0.15em] rounded-lg transition-all">{{ __('Contract History Detail') }}</button>
</div>
<!-- Detail View -->
<div x-show="sidebarView === 'detail'" class="space-y-8">
<!-- Validity & Status Section --> <!-- Validity & Status Section -->
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Account Status') }}</h3> <h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Account Status') }}</h3>
@@ -690,38 +810,56 @@
</div> </div>
<!-- Business Type --> <!-- Business Type -->
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80"> <div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 space-y-4">
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] mb-4">{{ __('Business Type') }}</h4> <div class="flex justify-between items-start pb-3 border-b border-slate-100/50 dark:border-slate-800/50">
<div class="space-y-4"> <div>
<div class="flex items-center justify-between"> <h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em]">{{ __('Contract Model') }}</h4>
<span class="text-[11px] font-bold text-slate-400">{{ __('Original:') }}</span>
<span class="px-2.5 py-1 rounded-lg text-[11px] font-bold uppercase tracking-widest"
:class="detailCompany.original_type === 'buyout' ? 'bg-amber-500/10 text-amber-600' : 'bg-blue-500/10 text-blue-600'"
x-text="detailCompany.original_type === 'buyout' ? '{{ __('Buyout') }}' : '{{ __('Lease') }}'"></span>
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-slate-400">{{ __('Current:') }}</span>
<span class="px-2.5 py-1 rounded-lg text-[11px] font-bold uppercase tracking-widest"
:class="detailCompany.current_type === 'buyout' ? 'bg-amber-500/10 text-amber-600 border border-amber-500/20' : 'bg-blue-500/10 text-blue-600 border border-blue-500/20'"
x-text="detailCompany.current_type === 'buyout' ? '{{ __('Buyout') }}' : '{{ __('Lease') }}'"></span>
</div> </div>
<span :class="detailCompany.current_type === 'lease' ? 'text-blue-500 bg-blue-500/10' : 'text-amber-500 bg-amber-500/10'"
class="text-[10px] font-black uppercase tracking-wider px-2.5 py-1 rounded-lg">
<span x-text="detailCompany.current_type === 'lease' ? '{{ __('Lease') }}' : '{{ __('Buyout') }}'"></span>
</span>
</div> </div>
<!-- Lease Info Display -->
<template x-if="detailCompany.current_type === 'lease'">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ __('Start Date') }}</p>
<p class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.start_date?.substring(0, 10) || '-'"></p>
</div>
<div class="space-y-1">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ __('End Date') }}</p>
<p class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.end_date?.substring(0, 10) || '{{ __('Unlimited') }}'"></p>
</div>
</div>
</template>
<!-- Buyout Info Display -->
<template x-if="detailCompany.current_type === 'buyout'">
<div class="space-y-4">
<div class="grid grid-cols-1 gap-3">
<div class="p-3 bg-amber-50/30 dark:bg-amber-500/5 rounded-xl border border-amber-100/50 dark:border-amber-500/10">
<p class="text-[9px] font-black text-amber-600 uppercase tracking-widest">{{ __('Warranty Service') }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.warranty_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400">~</span>
<span class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.warranty_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
<div class="p-3 bg-indigo-50/30 dark:bg-indigo-500/5 rounded-xl border border-indigo-100/50 dark:border-indigo-500/10">
<p class="text-[9px] font-black text-indigo-600 uppercase tracking-widest">{{ __('Software Service') }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.software_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400">~</span>
<span class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.software_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
</div>
</div>
</template>
</div> </div>
<!-- Contract Period -->
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] mb-4">{{ __('Contract Period') }}</h4>
<div class="space-y-3 font-mono">
<div class="flex items-center gap-3">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter min-w-[32px]">{{ __('From:') }}</span>
<div class="text-[13px] font-bold tracking-tighter text-slate-700 dark:text-slate-200" x-text="detailCompany.start_date || '--'"></div>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter min-w-[32px]">{{ __('To:') }}</span>
<div class="text-[13px] font-bold tracking-tighter text-slate-800 dark:text-white" :class="detailCompany.end_date_expired ? 'text-rose-500' : ''" x-text="detailCompany.end_date || '{{ __('Permanent') }}'"></div>
</div>
</div>
</div>
</div> </div>
</section> </section>
@@ -803,6 +941,82 @@
<p class="text-sm font-bold text-slate-600 dark:text-slate-400 leading-relaxed italic" x-text="detailCompany.note"></p> <p class="text-sm font-bold text-slate-600 dark:text-slate-400 leading-relaxed italic" x-text="detailCompany.note"></p>
</div> </div>
</section> </section>
</div>
<!-- History View -->
<div x-show="sidebarView === 'history'" class="space-y-6">
<template x-for="(contract, index) in (detailCompany.contracts || [])" :key="contract.id">
<div class="bg-slate-50/50 dark:bg-slate-800/30 rounded-2xl border border-slate-100 dark:border-slate-800/80 overflow-hidden">
<!-- Card Header -->
<div class="px-5 py-3 bg-white dark:bg-slate-800/50 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<div class="flex items-center gap-2.5">
<div class="size-7 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-[9px] font-black text-slate-500" x-text="'#' + (detailCompany.contracts.length - index)"></div>
<div>
<p class="text-[10px] font-black text-slate-700 dark:text-slate-200" x-text="new Date(contract.created_at).toLocaleString()"></p>
<div class="flex items-center gap-1.5 mt-0.5">
<span :class="contract.type === 'lease' ? 'text-blue-500 bg-blue-500/10' : 'text-amber-500 bg-amber-500/10'"
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded"
x-text="contract.type === 'lease' ? '{{ __('Lease') }}' : '{{ __('Buyout') }}'"></span>
<span class="text-[9px] text-slate-400">{{ __('by') }} <span class="text-slate-600 dark:text-slate-300" x-text="contract.creator?.name || 'System'"></span></span>
</div>
</div>
</div>
<template x-if="index === 0">
<span class="px-2 py-0.5 bg-emerald-500 text-white text-[8px] font-black uppercase tracking-widest rounded-full">{{ __('Current') }}</span>
</template>
</div>
<!-- Card Body -->
<div class="p-5 space-y-4">
<!-- Lease dates -->
<template x-if="contract.type === 'lease'">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Contract Start') }}</p>
<p class="text-sm font-black text-slate-700 dark:text-white font-mono" x-text="contract.start_date?.substring(0, 10) || '-'"></p>
</div>
<div class="space-y-1.5">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Contract End') }}</p>
<p class="text-sm font-black text-slate-700 dark:text-white font-mono" x-text="contract.end_date?.substring(0, 10) || '{{ __('Unlimited') }}'"></p>
</div>
</div>
</template>
<!-- Buyout dates -->
<template x-if="contract.type === 'buyout'">
<div class="grid grid-cols-1 gap-4">
<div class="p-4 bg-amber-50/40 dark:bg-amber-500/5 rounded-2xl border border-amber-100/50 dark:border-amber-500/10">
<p class="text-[10px] font-black text-amber-600 uppercase tracking-widest mb-2">{{ __('Warranty Service') }}</p>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono" x-text="contract.warranty_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400 font-bold">~</span>
<span class="text-sm font-black text-slate-800 dark:text-white font-mono" x-text="contract.warranty_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
<div class="p-4 bg-indigo-50/40 dark:bg-indigo-500/5 rounded-2xl border border-indigo-100/50 dark:border-indigo-500/10">
<p class="text-[10px] font-black text-indigo-600 uppercase tracking-widest mb-2">{{ __('Software Service') }}</p>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono" x-text="contract.software_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400 font-bold">~</span>
<span class="text-sm font-black text-slate-800 dark:text-white font-mono" x-text="contract.software_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
</div>
</template>
<!-- Note -->
<div class="pt-2 border-t border-slate-100/50 dark:border-slate-800/50" x-show="contract.note">
<p class="text-[9px] font-bold text-slate-400 italic" x-text="contract.note"></p>
</div>
</div>
</div>
</template>
<div x-show="!(detailCompany.contracts || []).length" class="py-12 text-center">
<div class="size-14 rounded-2xl bg-slate-50 dark:bg-slate-800/50 flex items-center justify-center mx-auto mb-3 border border-slate-100 dark:border-slate-800">
<svg class="size-7 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18c-2.305 0-4.408.867-6 2.292m0-14.25v14.25" /></svg>
</div>
<p class="text-xs font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('No history records') }}</p>
</div>
</div>
</div> </div>
<!-- Footer --> <!-- Footer -->
@@ -815,7 +1029,7 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
<style> <style>

View File

@@ -95,7 +95,10 @@ $roleSelectConfig = [
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </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 roles...') }}"> <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 roles...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@@ -106,6 +109,7 @@ $roleSelectConfig = [
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -211,7 +215,8 @@ $roleSelectConfig = [
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search users...') }}"> placeholder="{{ __('Search users...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@@ -222,6 +227,7 @@ $roleSelectConfig = [
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
<div class="overflow-x-auto"> <div class="overflow-x-auto">

View File

@@ -24,17 +24,17 @@
selectedMachine: null, selectedMachine: null,
slots: [], slots: [],
inventorySlots: [], inventorySlots: [],
currentPage: 1,
lastPage: 1,
init() { init() {
const d = new Date(); const now = new Date();
const today = [ const pad = (n) => String(n).padStart(2, '0');
d.getFullYear(), const formatDate = (date, time) => `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${time}`;
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0') this.startDate = formatDate(now, '00:00');
].join('-'); this.endDate = formatDate(now, '23:59');
this.startDate = today; this.$watch('activeTab', () => this.fetchLogs(1));
this.endDate = today;
this.$watch('activeTab', () => this.fetchLogs());
}, },
async openLogPanel(id, sn, name) { async openLogPanel(id, sn, name) {
@@ -54,15 +54,21 @@
}, },
async fetchLogs() { async fetchLogs(page = 1) {
this.loading = true; this.loading = true;
this.currentPage = page;
try { try {
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab; let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab + '&page=' + page;
if (this.startDate) url += '&start_date=' + this.startDate; if (this.startDate) url += '&start_date=' + this.startDate;
if (this.endDate) url += '&end_date=' + this.endDate; if (this.endDate) url += '&end_date=' + this.endDate;
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
if (data.success) this.logs = data.data.data || data.data || []; if (data.success) {
this.logs = data.data || [];
this.currentPage = data.pagination.current_page;
this.lastPage = data.pagination.last_page;
}
} catch (e) { console.error('fetchLogs error:', e); } } catch (e) { console.error('fetchLogs error:', e); }
finally { this.loading = false; } finally { this.loading = false; }
}, },
@@ -100,6 +106,13 @@
finally { this.inventoryLoading = false; } finally { this.inventoryLoading = false; }
}, },
formatDateTime(dateStr) {
if (!dateStr) return '--';
const d = new Date(dateStr);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
},
getSlotColorClass(slot) { getSlotColorClass(slot) {
if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50'; if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
const todayStr = new Date().toISOString().split('T')[0]; const todayStr = new Date().toISOString().split('T')[0];
@@ -390,19 +403,35 @@
<label <label
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{ class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
__('From') }}</label> __('From') }}</label>
<input type="date" x-model="startDate" @change="fetchLogs()" <input type="text" x-ref="startDatePicker" x-model="startDate"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-32 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700"> x-init="flatpickr($refs.startDatePicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
defaultDate: startDate,
onClose: (selectedDates, dateStr) => { startDate = dateStr; fetchLogs(1); }
})"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-44 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
</div> </div>
<div class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2"> <div class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2">
<label <label
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{ class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
__('To') }}</label> __('To') }}</label>
<input type="date" x-model="endDate" @change="fetchLogs()" <input type="text" x-ref="endDatePicker" x-model="endDate"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-32 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700"> x-init="flatpickr($refs.endDatePicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
defaultDate: endDate,
onClose: (selectedDates, dateStr) => { endDate = dateStr; fetchLogs(1); }
})"
class="luxury-input text-[11px] h-9 sm:h-8 py-0 w-full sm:w-44 bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700">
</div> </div>
</div> </div>
<div class="flex justify-start sm:justify-end"> <div class="flex justify-start sm:justify-end">
<button @click="startDate = ''; endDate = ''; fetchLogs()" <button @click="startDate = ''; endDate = ''; fetchLogs(1)"
class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5"> class="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5">
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5"> stroke-width="2.5">
@@ -419,7 +448,7 @@
<!-- Body / Navigation Tabs --> <!-- Body / Navigation Tabs -->
<div class="flex-1 flex flex-col min-h-0"> <div class="flex-1 flex flex-col min-h-0">
<div <div
class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 px-6 sm:px-8 overflow-x-auto hide-scrollbar"> class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 px-6 sm:px-8 overflow-x-auto overflow-y-hidden hide-scrollbar">
<nav class="-mb-px flex space-x-6 sm:space-x-8" aria-label="Tabs"> <nav class="-mb-px flex space-x-6 sm:space-x-8" aria-label="Tabs">
<button @click="activeTab = 'status'" <button @click="activeTab = 'status'"
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'status', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'status'}" :class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'status', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'status'}"
@@ -486,7 +515,7 @@
class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all"> class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all">
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="text-[12px] font-bold text-slate-600 dark:text-slate-300" <div class="text-[12px] font-bold text-slate-600 dark:text-slate-300"
x-text="new Date(log.created_at).toLocaleString()"> x-text="formatDateTime(log.created_at)">
</div> </div>
</td> </td>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
@@ -501,7 +530,7 @@
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<p class="text-[13px] font-medium text-slate-700 dark:text-slate-200 truncate max-w-md" <p class="text-[13px] font-medium text-slate-700 dark:text-slate-200 truncate max-w-md"
:title="log.message" x-text="log.message"></p> :title="log.translated_message || log.message" x-text="log.translated_message || log.message"></p>
</td> </td>
</tr> </tr>
</template> </template>
@@ -517,7 +546,7 @@
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span <span
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest" class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest"
x-text="new Date(log.created_at).toLocaleString()"></span> x-text="formatDateTime(log.created_at)"></span>
<span :class="{ <span :class="{
'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20': log.level === 'info', 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20': log.level === 'info',
'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20': log.level === 'warning', 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20': log.level === 'warning',
@@ -528,7 +557,7 @@
</span> </span>
</div> </div>
<p class="text-[13px] font-bold text-slate-700 dark:text-slate-200 line-clamp-3 leading-relaxed" <p class="text-[13px] font-bold text-slate-700 dark:text-slate-200 line-clamp-3 leading-relaxed"
x-text="log.message"></p> x-text="log.translated_message || log.message"></p>
</div> </div>
</template> </template>
</div> </div>
@@ -554,6 +583,33 @@
</div> </div>
</div> </div>
</template> </template>
<!-- Pagination Footer -->
<div x-show="logs.length > 0" class="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
<div class="flex items-center gap-4">
<button @click="fetchLogs(currentPage - 1)"
:disabled="currentPage <= 1"
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest" x-text="currentPage"></span>
<span class="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase">/</span>
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest" x-text="lastPage"></span>
</div>
<button @click="fetchLogs(currentPage + 1)"
:disabled="currentPage >= lastPage"
class="p-2 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-400 disabled:opacity-30 disabled:cursor-not-allowed hover:text-cyan-500 hover:border-cyan-500/30 transition-all shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -61,7 +61,10 @@
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </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 roles...') }}"> <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 roles...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@@ -79,6 +82,7 @@
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
</div> </div>

View File

@@ -51,6 +51,9 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
// 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth) // 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth)
Route::prefix('app')->group(function () { Route::prefix('app')->group(function () {
Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1'); Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1');
// 機台啟動引導與參數下載 (需人員登入 Token)
Route::middleware('auth:sanctum')->post('machine/setting/B014', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSettings']);
}); });
Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () { Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () {
@@ -68,6 +71,9 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
// 統一商品主檔 API (B012 整合版) // 統一商品主檔 API (B012 整合版)
Route::match(['get', 'patch'], 'machine/products/B012', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getProducts']); Route::match(['get', 'patch'], 'machine/products/B012', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getProducts']);
// 機台故障與異常上報 (B013)
Route::post('machine/error/B013', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportError']);
// 交易、發票與出貨 (B600, B601, B602) // 交易、發票與出貨 (B600, B601, B602)
Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']); Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']);
Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']); Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']);