Compare commits
15 Commits
3dbb394862
...
demo
| Author | SHA1 | Date | |
|---|---|---|---|
| daf8b1ebcc | |||
| 8f008ffb61 | |||
| 729890d7c7 | |||
| ad256d3d3b | |||
| 5415b14a53 | |||
| c97776892e | |||
| a599b14df1 | |||
| c343df34ee | |||
| 253ae8afd4 | |||
| f2147ae6c4 | |||
| b60afc3abe | |||
| bbdc5bad9f | |||
| 08b6c60d2e | |||
| e085058d63 | |||
| e7ad7e3dc3 |
@@ -8,68 +8,253 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。
|
||||
|
||||
## 1. 核心命名原則
|
||||
- **語意化優先**:捨棄舊版 `M_` 前綴,統一使用 snake_case (如 `firmware_version`)。
|
||||
- **語意化優先**:捨棄舊版 M_ 前綴,統一使用 snake_case (如 firmware_version)。
|
||||
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
|
||||
|
||||
## 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>`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 機台核心 API (IoT Endpoints)
|
||||
|
||||
### 3.1 B010: 心跳上報與狀態同步
|
||||
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令。
|
||||
### 3.1 B000: 機台本地管理員同步登入
|
||||
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。
|
||||
|
||||
- **URL**: `POST /api/v1/app/machine/status/B010`
|
||||
- **URL**: POST /api/v1/app/admin/login/B000
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `current_page` | Integer | 是 | 當前頁面代碼 (見下表) | `1` |
|
||||
| `firmware_version` | String | 是 | 韌體版本號 | `1.0.5` |
|
||||
| `model` | String | 是 | 機台型號 | `STAR-V1` |
|
||||
| `temperature` | Float | 否 | 環境溫度 | `25.5` |
|
||||
| `door_status` | Integer | 否 | 門狀態 (0:關 / 1:開) | `0` |
|
||||
| `log` | String | 否 | 事件日誌簡述 | `Door opened` |
|
||||
| `log_level` | String | 否 | info, warn, error | `info` |
|
||||
| `log_payload` | Object | 否 | 額外日誌 JSON 對象 | `{"code":500}` |
|
||||
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
|
||||
| Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
|
||||
| Su_Password | String | 是 | 密碼 | password123 |
|
||||
| ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
|
||||
| type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
|
||||
|
||||
- **Response Body:**
|
||||
> [!IMPORTANT]
|
||||
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `success` | Boolean | 請求是否處理成功 | `true` |
|
||||
| `code` | Integer | 內部業務狀態碼 | `200` |
|
||||
| `message` | String | 回應訊息 | `OK` |
|
||||
| `status` | String | **雲端指令代碼** (見下表) | `49` |
|
||||
| message | String | 驗證結果 (Success 或 Failed) | Success |
|
||||
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 B005: 廣告清單同步
|
||||
用於機台端獲取目前應播放的廣告檔案 URL 清單。
|
||||
|
||||
- **URL**: GET /api/v1/app/machine/ad/B005
|
||||
- **Request Body:** 無 (GET 請求)
|
||||
|
||||
- **Response Body:**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 內部業務狀態碼 | 200 |
|
||||
| data | Array | 廣告物件陣列 | [{"t070v04": "https://..."}] |
|
||||
|
||||
**data 陣列內部欄位:**
|
||||
- t070v01: 廣告名稱 (Name)
|
||||
- t070v02: 播放長度 (Duration) — 秒數,若後台未設定,預設為 15 秒。
|
||||
- t070v03: 廣告位置 (Position/Flag) — (3: 待機廣告, 1: 販賣頁, 2: 來店禮)。
|
||||
- t070v04: 廣告 URL。
|
||||
- t070v05: 播放順位 (Sort Order)。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
|
||||
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
|
||||
|
||||
#### B009 權限驗證邏輯 (RBAC Compliance)
|
||||
系統會依據 account 欄位進行三層式權限核查:
|
||||
1. **系統層 (System Admin)**:當 company_id 為 null 時,具備全局管理權限,直接放行。
|
||||
2. **公司層 (Company Admin)**:當 is_admin 為 true 時,檢查機台的 company_id 是否與該帳號一致。
|
||||
3. **人員層 (Operator/User)**:當帳號僅為一般人員時,檢查 machine_user 授權表,確認該帳號有被分配至此機台。
|
||||
|
||||
- **URL**: PUT /api/v1/app/products/supplementary/B009
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| account | String | 是 | 操作人員帳號 | 0999123456 |
|
||||
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
|
||||
|
||||
- **data 陣列內部欄位:**
|
||||
|
||||
| 欄位 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| tid | Integer | 貨道編號 (Slot No) | 1 |
|
||||
| t060v00 | String | 商品資料庫 ID (或是 Barcode) | "1" |
|
||||
| num | Integer | 實體剩餘庫存數量 | 10 |
|
||||
| type | Integer | 貨道物理類型 (1: 履帶, 2: 彈簧)。若未提供,預設為 1。 | 1 |
|
||||
|
||||
> [!TIP]
|
||||
> **自動化上限同步邏輯**:
|
||||
> 當後端收到 B009 時,會根據 type 自動從該商品的配置中選取 spring_limit 或 track_limit 並自動更新該貨道的 max_stock 欄位。機台端無需手動計算上限。
|
||||
|
||||
- **Response Body (Success 200):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 同步是否成功 | true |
|
||||
| code | Integer | 內部業務狀態碼 | 200 |
|
||||
| message | String | 回應訊息 | Slot report synchronized success |
|
||||
| status | String | 固定回傳 49 代表同步完成 | "49" |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 B010: 心跳上報與狀態同步
|
||||
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/status/B010
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| current_page | Integer | 是 | 當前頁面代碼 (見下表) | 1 |
|
||||
| firmware_version | String | 是 | 韌體版本號 | 1.0.5 |
|
||||
| model | String | 是 | 機台型號 | STAR-V1 |
|
||||
| temperature | Float | 否 | 環境溫度 | 25.5 |
|
||||
| door_status | Integer | 否 | 門狀態 (0:關 / 1:開) | 0 |
|
||||
| log | String | 否 | 事件日誌簡述 | Door opened |
|
||||
| log_level | String | 否 | info, warn, error | info |
|
||||
| log_payload | Object | 否 | 額外日誌 JSON 對象 | {"code":500} |
|
||||
|
||||
- **Response Body:**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否處理成功 | true |
|
||||
| code | Integer | 內部業務狀態碼 | 200 |
|
||||
| message | String | 回應訊息 | OK |
|
||||
| status | String | **雲端指令代碼** (見下表) | 49 |
|
||||
|
||||
#### B010 代碼對照表
|
||||
|
||||
**頁面代碼 (current_page):**
|
||||
- `0`: 離線 / `1`: 主頁面 / `2`: 販賣頁 / `3`: 管理頁
|
||||
- `4`: 補貨頁 / `5`: 教學頁 / `6`: 購買中 / `7`: 鎖定頁
|
||||
- `60`: 出貨成功 / `61`: 貨道測試 / `62`: 付款選擇
|
||||
- `63`: 等待付款 / `64`: 出貨 / `65`: 收據簽單
|
||||
- `612`: 出貨失敗
|
||||
- 0: 離線 / 1: 主頁面 / 2: 販賣頁 / 3: 管理頁
|
||||
- 4: 補貨頁 / 5: 教學頁 / 6: 購買中 / 7: 鎖定頁
|
||||
- 60: 出貨成功 / 61: 貨道測試 / 62: 付款選擇
|
||||
- 63: 等待付款 / 64: 出貨 / 65: 收據簽單
|
||||
- 66: 通行碼 / 67: 取貨碼 / 68: 訊息顯示
|
||||
- 69: 取消購買 / 610: 購買結束 / 611: 來店禮
|
||||
- 612: 出貨失敗
|
||||
|
||||
**雲端指令代碼 (status):**
|
||||
- `49`: reload B017 (貨道同步)
|
||||
- `50`: reload B005 (基礎參數)
|
||||
- `51`: reboot (重啟系統)
|
||||
- `60`: reboot card machine (刷卡機重啟)
|
||||
- `61`: checkout (結帳)
|
||||
- `70`: unlock (解鎖) / `71`: lock (鎖定)
|
||||
- `72`: sellCode reload B023 (即期品)
|
||||
- `75`: exp reload B026 (效期)
|
||||
- `79`: read B050 (參數讀取)
|
||||
- `85`: reload B0552 (出貨腳本)
|
||||
- 49: reload B017 (貨道同步)
|
||||
- 51: reboot (重啟系統)
|
||||
- 60: reboot card machine (刷卡機重啟)
|
||||
- 61: checkout (觸發結帳)
|
||||
- 70: unlock (解鎖)
|
||||
- 71: lock (鎖定)
|
||||
- 85: reload B0552 (遠端出貨)
|
||||
|
||||
---
|
||||
|
||||
### 3.2 B017: 貨道與庫存同步 (規劃中)
|
||||
- **URL**: `POST /api/v1/app/machine/reload_msg/B017`
|
||||
- 說明:當機台收到 B010 回應 `status: 49` 時,應呼叫此 API 同步最新貨道佈局。
|
||||
### 3.5 B012: 商品配置與商品主檔同步 (Unified Sync)
|
||||
用於機台端獲取目前所有可販售商品的詳細配置。App 端應依據呼叫的方法決定數據處理方式。
|
||||
|
||||
### 3.3 B600: 交易數據回傳 (規劃中)
|
||||
- **URL**: `POST /api/v1/app/B600`
|
||||
- 說明:交易完成後提交支付方式、金額、商品與出貨結果。
|
||||
- **URL**: GET|PATCH /api/v1/app/machine/products/B012
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:** 無 (由 Token 自動識別機台)
|
||||
|
||||
#### 運作邏輯 (Client-side Logic):
|
||||
- **GET**:執行 **全量同步**。App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll() 以確保與伺服器完全一致。
|
||||
- **PATCH**:執行 **增量更新**。App 於收到成功回應後,僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。
|
||||
|
||||
| 欄位 | 型別 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| t060v00 | String | 商品資料庫 ID | "1" |
|
||||
| t060v01 | String | 商品名稱 | 可口可樂 330ml |
|
||||
| t060v01_en | String | 英文名稱 | Coca Cola |
|
||||
| t060v01_jp | String | 日文名稱 | コカコーラ |
|
||||
| t060v03 | String | 商品規格/簡述 | Cold Drink |
|
||||
| t060v06 | String | 圖片 URL | https://.../coke.png |
|
||||
| t060v09 | Float | 標準零售價 | 25.0 |
|
||||
| t060v11 | Integer | **貨道庫存上限** (預設履帶) | 10 |
|
||||
| t060v30 | Float | 會員價 | 20.0 |
|
||||
| t063v03 | Float | 本機銷售價格 (同定價) | 25.0 |
|
||||
| t060v40 | String | 行銷計畫 (Marketing Plan) | Buy 1 Get 1 |
|
||||
| t060v41 | String | 物料編碼 (Material Code) | SKU-001 |
|
||||
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
|
||||
| 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
1
.gitignore
vendored
@@ -19,5 +19,6 @@ yarn-error.log
|
||||
/.vscode
|
||||
/docs/API
|
||||
/docs/*.xlsx
|
||||
/docs/pptx
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ class AdvertisementController extends AdminController
|
||||
|
||||
// Tab 1: 廣告列表
|
||||
$advertisements = Advertisement::with('company')->latest()->paginate(10);
|
||||
$allAds = Advertisement::active()->get();
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
|
||||
$allAds = Advertisement::playing()->get();
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料)
|
||||
// 取得使用者有權限的機台列表 (已透過 Global Scope 過濾)
|
||||
@@ -54,6 +56,8 @@ class AdvertisementController extends AdminController
|
||||
$request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB
|
||||
],
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after_or_equal:start_at',
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
@@ -71,13 +75,15 @@ class AdvertisementController extends AdminController
|
||||
$companyId = $user->company_id;
|
||||
}
|
||||
|
||||
Advertisement::create([
|
||||
$advertisement = Advertisement::create([
|
||||
'company_id' => $companyId,
|
||||
'name' => $request->name,
|
||||
'type' => $request->type,
|
||||
'duration' => (int) $request->duration,
|
||||
'url' => Storage::disk('public')->url($path),
|
||||
'is_active' => true,
|
||||
'start_at' => $request->start_at,
|
||||
'end_at' => $request->end_at,
|
||||
]);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
@@ -99,6 +105,8 @@ class AdvertisementController extends AdminController
|
||||
'duration' => 'required|in:15,30,60',
|
||||
'is_active' => 'boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'start_at' => 'nullable|date',
|
||||
'end_at' => 'nullable|date|after_or_equal:start_at',
|
||||
];
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
@@ -111,7 +119,7 @@ class AdvertisementController extends AdminController
|
||||
|
||||
$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');
|
||||
|
||||
$user = auth()->user();
|
||||
@@ -150,7 +158,7 @@ class AdvertisementController extends AdminController
|
||||
return redirect()->back()->with('success', __('Advertisement updated successfully.'));
|
||||
}
|
||||
|
||||
public function destroy(Advertisement $advertisement)
|
||||
public function destroy(Request $request, Advertisement $advertisement)
|
||||
{
|
||||
// 檢查是否有機台正投放中
|
||||
if ($advertisement->machineAdvertisements()->exists()) {
|
||||
@@ -179,6 +187,7 @@ class AdvertisementController extends AdminController
|
||||
{
|
||||
$assignments = MachineAdvertisement::where('machine_id', $machine->id)
|
||||
->with('advertisement')
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->groupBy('position');
|
||||
|
||||
|
||||
@@ -105,7 +105,6 @@ class MachineSettingController extends AdminController
|
||||
}
|
||||
|
||||
$machine = Machine::create(array_merge($validated, [
|
||||
'status' => 'offline',
|
||||
'api_token' => \Illuminate\Support\Str::random(60),
|
||||
'creator_id' => auth()->id(),
|
||||
'updater_id' => auth()->id(),
|
||||
|
||||
@@ -14,7 +14,8 @@ class CompanyController extends Controller
|
||||
*/
|
||||
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')) {
|
||||
@@ -55,6 +56,10 @@ class CompanyController extends Controller
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'start_date' => 'required|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',
|
||||
'note' => 'nullable|string',
|
||||
'settings' => 'nullable|array',
|
||||
@@ -83,11 +88,28 @@ class CompanyController extends Controller
|
||||
'contact_email' => $validated['contact_email'] ?? null,
|
||||
'start_date' => $validated['start_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'],
|
||||
'note' => $validated['note'] ?? null,
|
||||
'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'])) {
|
||||
$user = \App\Models\System\User::create([
|
||||
@@ -143,6 +165,10 @@ class CompanyController extends Controller
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'start_date' => 'required|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',
|
||||
'note' => 'nullable|string',
|
||||
'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);
|
||||
}
|
||||
|
||||
$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) {
|
||||
|
||||
@@ -12,28 +12,31 @@ class DashboardController extends Controller
|
||||
{
|
||||
// 每頁顯示筆數限制 (預設為 10)
|
||||
$perPage = (int) request()->input('per_page', 10);
|
||||
if ($perPage <= 0) $perPage = 10;
|
||||
|
||||
if ($perPage <= 0)
|
||||
$perPage = 10;
|
||||
|
||||
// 從資料庫獲取真實統計數據
|
||||
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
|
||||
$activeMachines = Machine::where('status', 'online')->count();
|
||||
$alertsPending = Machine::where('status', 'error')->count();
|
||||
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
|
||||
$activeMachines = Machine::online()->count();
|
||||
$offlineMachines = Machine::offline()->count();
|
||||
$alertsPending = Machine::hasError()->count();
|
||||
$memberCount = \App\Models\Member\Member::count();
|
||||
|
||||
// 獲取機台列表 (分頁)
|
||||
$machines = Machine::when($request->search, function($query, $search) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
$machines = Machine::when($request->search, function ($query, $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->orderByDesc('last_heartbeat_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.dashboard', compact(
|
||||
'totalRevenue',
|
||||
'activeMachines',
|
||||
'offlineMachines',
|
||||
'alertsPending',
|
||||
'memberCount',
|
||||
'machines'
|
||||
|
||||
@@ -9,19 +9,21 @@ use Illuminate\View\View;
|
||||
|
||||
class MachineController extends AdminController
|
||||
{
|
||||
public function __construct(protected MachineService $machineService) {}
|
||||
public function __construct(protected MachineService $machineService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
|
||||
|
||||
$query = Machine::query();
|
||||
|
||||
// 搜尋:名稱或序號
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,14 +36,31 @@ class MachineController extends AdminController
|
||||
return view('admin.machines.index', compact('machines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新機台基本資訊 (目前僅名稱)
|
||||
*/
|
||||
public function update(Request $request, Machine $machine)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$machine->update($validated);
|
||||
|
||||
return redirect()->route('admin.machines.index')
|
||||
->with('success', __('Machine updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顯示特定機台的日誌與詳細資訊
|
||||
*/
|
||||
public function show(int $id): View
|
||||
{
|
||||
$machine = Machine::with(['logs' => function ($query) {
|
||||
$query->latest()->limit(50);
|
||||
}])->findOrFail($id);
|
||||
$machine = Machine::with([
|
||||
'logs' => function ($query) {
|
||||
$query->latest()->limit(50);
|
||||
}
|
||||
])->findOrFail($id);
|
||||
|
||||
return view('admin.machines.show', compact('machine'));
|
||||
}
|
||||
@@ -52,17 +71,21 @@ class MachineController extends AdminController
|
||||
*/
|
||||
public function logsAjax(Request $request, Machine $machine)
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
|
||||
$startDate = $request->get('start_date', now()->format('Y-m-d'));
|
||||
$endDate = $request->get('end_date', now()->format('Y-m-d'));
|
||||
$per_page = $request->input('per_page', 20);
|
||||
|
||||
$startDate = $request->get('start_date');
|
||||
$endDate = $request->get('end_date');
|
||||
|
||||
$logs = $machine->logs()
|
||||
->when($request->level, function ($query, $level) {
|
||||
return $query->where('level', $level);
|
||||
})
|
||||
->whereDate('created_at', '>=', $startDate)
|
||||
->whereDate('created_at', '<=', $endDate)
|
||||
->when($startDate, function ($query, $start) {
|
||||
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) {
|
||||
return $query->where('type', $type);
|
||||
})
|
||||
@@ -88,7 +111,7 @@ class MachineController extends AdminController
|
||||
{
|
||||
// 取得當前使用者有權限的所有機台 (已透過 Global Scope 過濾)
|
||||
$machines = Machine::all();
|
||||
|
||||
|
||||
$date = $request->get('date', now()->toDateString());
|
||||
$service = app(\App\Services\Machine\MachineService::class);
|
||||
$fleetStats = $service->getFleetStats($date);
|
||||
@@ -111,7 +134,7 @@ class MachineController extends AdminController
|
||||
public function slotsAjax(Machine $machine)
|
||||
{
|
||||
$slots = $machine->slots()->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')->get();
|
||||
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'machine' => $machine->only(['id', 'name', 'serial_no']),
|
||||
@@ -131,7 +154,9 @@ class MachineController extends AdminController
|
||||
'batch_no' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$this->machineService->updateSlot($machine, $validated);
|
||||
$this->machineService->updateSlot($machine, $validated, auth()->id());
|
||||
|
||||
session()->flash('success', __('Slot updated successfully.'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
@@ -318,7 +318,11 @@ class PermissionController extends Controller
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,81 +4,131 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class RemoteController extends Controller
|
||||
{
|
||||
// 機台庫存
|
||||
/**
|
||||
* 遠端管理指揮中心
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$machines = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
|
||||
$selectedMachine = null;
|
||||
$history = RemoteCommand::where('command_type', '!=', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get();
|
||||
|
||||
if ($request->has('machine_id')) {
|
||||
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
|
||||
$query->where('command_type', '!=', 'reload_stock')
|
||||
->latest()
|
||||
->limit(5);
|
||||
}])->find($request->machine_id);
|
||||
}
|
||||
|
||||
if ($request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'machine' => $selectedMachine,
|
||||
'commands' => $selectedMachine ? $selectedMachine->commands : []
|
||||
]);
|
||||
}
|
||||
|
||||
return view('admin.remote.index', [
|
||||
'machines' => $machines,
|
||||
'selectedMachine' => $selectedMachine,
|
||||
'history' => $history,
|
||||
'title' => __('Remote Command Center'),
|
||||
'subtitle' => __('Execute maintenance and operational commands remotely')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存遠端指令
|
||||
*/
|
||||
public function storeCommand(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense',
|
||||
'amount' => 'nullable|integer|min:0',
|
||||
'slot_no' => 'nullable|string',
|
||||
'note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$payload = [];
|
||||
if ($validated['command_type'] === 'change') {
|
||||
$payload['amount'] = $validated['amount'];
|
||||
} elseif ($validated['command_type'] === 'dispense') {
|
||||
$payload['slot_no'] = $validated['slot_no'];
|
||||
}
|
||||
|
||||
// 指令去重:將同機台、同類型的 pending 指令標記為「已取代」
|
||||
RemoteCommand::where('machine_id', $validated['machine_id'])
|
||||
->where('command_type', $validated['command_type'])
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new command'),
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
RemoteCommand::create([
|
||||
'machine_id' => $validated['machine_id'],
|
||||
'user_id' => auth()->id(),
|
||||
'command_type' => $validated['command_type'],
|
||||
'payload' => $payload,
|
||||
'status' => 'pending',
|
||||
'note' => $validated['note'] ?? null,
|
||||
]);
|
||||
|
||||
session()->flash('success', __('Command has been queued successfully.'));
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Command has been queued successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台庫存管理 (現有功能保留)
|
||||
*/
|
||||
public function stock(Request $request)
|
||||
{
|
||||
$machines = \App\Models\Machine\Machine::withCount([
|
||||
$machines = Machine::withCount([
|
||||
'slots as slots_count',
|
||||
'slots as low_stock_count' => function ($query) {
|
||||
$query->where('stock', '<=', 5);
|
||||
},
|
||||
'slots as expiring_soon_count' => function ($query) {
|
||||
$query->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '<=', now()->addDays(7))
|
||||
->where('expiry_date', '>=', now()->startOfDay());
|
||||
}
|
||||
])->orderBy('name')->get();
|
||||
$selectedMachine = null;
|
||||
])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
|
||||
|
||||
$history = RemoteCommand::where('command_type', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get();
|
||||
|
||||
$selectedMachine = null;
|
||||
if ($request->has('machine_id')) {
|
||||
$selectedMachine = \App\Models\Machine\Machine::find($request->machine_id);
|
||||
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
|
||||
$query->where('command_type', 'reload_stock')
|
||||
->latest()
|
||||
->limit(50);
|
||||
}])->find($request->machine_id);
|
||||
}
|
||||
|
||||
return view('admin.remote.stock', [
|
||||
'machines' => $machines,
|
||||
'selectedMachine' => $selectedMachine,
|
||||
]);
|
||||
}
|
||||
|
||||
// 機台重啟
|
||||
public function restart()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端重啟機台',
|
||||
'description' => '遠端重啟機台系統',
|
||||
]);
|
||||
}
|
||||
|
||||
// 卡機重啟
|
||||
public function restartCardReader()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端重啟刷卡機',
|
||||
'description' => '遠端重啟刷卡機設備',
|
||||
]);
|
||||
}
|
||||
|
||||
// 遠端結帳
|
||||
public function checkout()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端結帳',
|
||||
'description' => '遠端執行結帳流程',
|
||||
]);
|
||||
}
|
||||
|
||||
// 遠端鎖定頁
|
||||
public function lock()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端鎖定頁',
|
||||
'description' => '遠端鎖定機台頁面',
|
||||
]);
|
||||
}
|
||||
|
||||
// 遠端找零
|
||||
public function change()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端找零',
|
||||
'description' => '遠端執行找零功能',
|
||||
]);
|
||||
}
|
||||
|
||||
// 遠端出貨
|
||||
public function dispense()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端出貨',
|
||||
'description' => '遠端控制商品出貨',
|
||||
'history' => $history,
|
||||
'title' => __('Stock & Expiry Management'),
|
||||
'subtitle' => __('Real-time monitoring and adjustment of cargo lane inventory and expiration dates')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
119
app/Http/Controllers/Api/V1/App/MachineAuthController.php
Normal file
119
app/Http/Controllers/Api/V1/App/MachineAuthController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Models\System\User;
|
||||
use App\Models\Machine\Machine;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Jobs\Machine\ProcessStateLog;
|
||||
|
||||
class MachineAuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* B000: 機台本機管理員/補貨員 離線同步登入驗證
|
||||
* 這個 API 僅用於機台的 Java App 登入畫面驗證帳密。不必進入 Queue。
|
||||
*/
|
||||
public function loginB000(Request $request): JsonResponse
|
||||
{
|
||||
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
|
||||
$validated = $request->validate([
|
||||
'machine' => 'required|string',
|
||||
'Su_Account' => 'required|string',
|
||||
'Su_Password' => 'required|string',
|
||||
'ip' => 'nullable|string',
|
||||
'type' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
|
||||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
|
||||
|
||||
if (!$machine) {
|
||||
Log::warning("B000 機台登入失敗: 伺服器找不到該機台", [
|
||||
'machine_serial' => $validated['machine']
|
||||
]);
|
||||
return response()->json(['message' => 'Failed']);
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
$isAuthorized = true;
|
||||
} elseif ($user->is_admin) {
|
||||
if ($machine->company_id === $user->company_id) {
|
||||
$isAuthorized = true;
|
||||
}
|
||||
} else {
|
||||
if ($user->machines()->where('machine_id', $machine->id)->exists()) {
|
||||
$isAuthorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 機台登入成功", [
|
||||
'account' => $user->username,
|
||||
'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([
|
||||
'message' => 'Success',
|
||||
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,15 @@ namespace App\Http\Controllers\Api\V1\App;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use App\Jobs\Machine\ProcessHeartbeat;
|
||||
use App\Jobs\Machine\ProcessTimerStatus;
|
||||
use App\Jobs\Machine\ProcessCoinInventory;
|
||||
use App\Jobs\Machine\ProcessMachineError;
|
||||
use App\Jobs\Machine\ProcessStateLog;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class MachineController extends Controller
|
||||
{
|
||||
@@ -18,16 +23,126 @@ class MachineController extends Controller
|
||||
public function heartbeat(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$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);
|
||||
|
||||
// 取出待處理指令
|
||||
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->pending()
|
||||
->first();
|
||||
|
||||
$status = '49'; // 預設 49 (OK / No Command)
|
||||
$message = 'OK';
|
||||
|
||||
if ($command) {
|
||||
switch ($command->command_type) {
|
||||
case 'reboot':
|
||||
$status = '51';
|
||||
$message = 'reboot';
|
||||
break;
|
||||
case 'reboot_card':
|
||||
$status = '60';
|
||||
$message = 'reboot card machine';
|
||||
break;
|
||||
case 'checkout':
|
||||
$status = '61';
|
||||
$message = 'checkout';
|
||||
break;
|
||||
case 'lock':
|
||||
$status = '71';
|
||||
$message = 'lock';
|
||||
break;
|
||||
case 'unlock':
|
||||
$status = '70';
|
||||
$message = 'unlock';
|
||||
break;
|
||||
case 'change':
|
||||
$status = '82';
|
||||
$message = $command->payload['amount'] ?? '0';
|
||||
break;
|
||||
case 'dispense':
|
||||
$status = '85';
|
||||
$message = $command->payload['slot_no'] ?? '';
|
||||
break;
|
||||
case 'reload_stock':
|
||||
$status = '49';
|
||||
$message = 'reload B017';
|
||||
break;
|
||||
}
|
||||
// 標記為已發送 (sent)
|
||||
$command->update(['status' => 'sent', 'executed_at' => now()]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'OK',
|
||||
'status' => '49' // 某些硬體可能需要的成功碼
|
||||
'message' => $message,
|
||||
'status' => $status
|
||||
], 202); // 202 Accepted
|
||||
}
|
||||
|
||||
@@ -37,7 +152,7 @@ class MachineController extends Controller
|
||||
public function recordRestock(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
\App\Jobs\Machine\ProcessRestockReport::dispatch($data);
|
||||
@@ -58,6 +173,12 @@ class MachineController extends Controller
|
||||
$machine = $request->get('machine');
|
||||
$slots = $machine->slots()->with('product')->get();
|
||||
|
||||
// 自動轉 Success: 若機台來撈 B017,代表之前的 reload_stock 指令已成功被機台響應
|
||||
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'reload_stock')
|
||||
->where('status', 'sent')
|
||||
->update(['status' => 'success', 'executed_at' => now()]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
@@ -69,6 +190,8 @@ class MachineController extends Controller
|
||||
'capacity' => $slot->capacity,
|
||||
'price' => $slot->price,
|
||||
'status' => $slot->status,
|
||||
'expiry_date' => $slot->expiry_date,
|
||||
'batch_no' => $slot->batch_no,
|
||||
];
|
||||
})
|
||||
]);
|
||||
@@ -80,7 +203,7 @@ class MachineController extends Controller
|
||||
public function syncTimer(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
|
||||
ProcessTimerStatus::dispatch($machine->serial_no, $data);
|
||||
|
||||
@@ -93,7 +216,7 @@ class MachineController extends Controller
|
||||
public function syncCoinInventory(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
|
||||
ProcessCoinInventory::dispatch($machine->serial_no, $data);
|
||||
|
||||
@@ -139,4 +262,280 @@ class MachineController extends Controller
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B005: Download Machine Advertisements (Synchronous)
|
||||
*/
|
||||
public function getAdvertisements(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
|
||||
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
|
||||
->with([
|
||||
'advertisement' => function ($query) {
|
||||
$query->playing();
|
||||
}
|
||||
])
|
||||
->get()
|
||||
->filter(fn($ma) => $ma->advertisement !== null)
|
||||
->map(function ($ma) {
|
||||
// 定義顯示順序權重 (待機 > 購物 > 成功禮)
|
||||
$posWeight = [
|
||||
'standby' => 1,
|
||||
'vending' => 2,
|
||||
'visit_gift' => 3
|
||||
];
|
||||
|
||||
// 為了相容現有機台 App 邏輯:
|
||||
// App 讀取 t070v03 作為位置標籤 (flag):
|
||||
// 1. HomeActivity (待機) 讀取 "3"
|
||||
// 2. FontendActivity (販賣頁) 讀取 "1"
|
||||
$posIdMap = [
|
||||
'standby' => '3',
|
||||
'vending' => '1',
|
||||
'visit_gift' => '2'
|
||||
];
|
||||
|
||||
return [
|
||||
't070v01' => $ma->advertisement->name,
|
||||
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
|
||||
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
|
||||
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
|
||||
't070v05' => (string) $ma->sort_order,
|
||||
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
|
||||
'raw_sort' => (int) $ma->sort_order
|
||||
];
|
||||
})
|
||||
->sortBy([
|
||||
['raw_pos_weight', 'asc'],
|
||||
['raw_sort', 'asc']
|
||||
])
|
||||
->values()
|
||||
->map(function ($item) {
|
||||
unset($item['raw_pos_weight'], $item['raw_sort']);
|
||||
return $item;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => $advertisements->values()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B009: Report Machine Slot List / Supplementary (Synchronous)
|
||||
*/
|
||||
public function reportSlotList(Request $request, \App\Services\Machine\MachineService $machineService)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$payload = $request->all();
|
||||
$account = $payload['account'] ?? null;
|
||||
|
||||
// 1. 驗證帳號是否存在 (驗證執行補貨的人員身分)
|
||||
$user = User::where('username', $account)
|
||||
->orWhere('email', $account)
|
||||
->first();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => 'Unauthorized: Account not found',
|
||||
'status' => ''
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. 階層式權限驗證 (遵循 RBAC 多租戶規範)
|
||||
$isAuthorized = false;
|
||||
if ($user->isSystemAdmin()) {
|
||||
// [系統層]:系統管理員可異動所有機台
|
||||
$isAuthorized = true;
|
||||
} elseif ($user->is_admin) {
|
||||
// [公司層]:公司管理員需驗證此機台是否隸屬於該公司
|
||||
$isAuthorized = ($machine->company_id === $user->company_id);
|
||||
} else {
|
||||
// [人員層]:一般人員需檢查 machine_user 授權表
|
||||
$isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists();
|
||||
}
|
||||
|
||||
if (!$isAuthorized) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => 'Unauthorized: Account not authorized for this machine',
|
||||
'status' => ''
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 3. 映射舊版機台回傳格式 (Map legacy machine format)
|
||||
// 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array)
|
||||
$legacyData = $payload['data'] ?? [];
|
||||
if (Arr::isAssoc($legacyData)) {
|
||||
$legacyData = [$legacyData];
|
||||
}
|
||||
|
||||
$mappedSlots = array_map(function ($item) {
|
||||
return [
|
||||
'slot_no' => $item['tid'] ?? null,
|
||||
'product_id' => $item['t060v00'] ?? null,
|
||||
'stock' => $item['num'] ?? 0,
|
||||
'type' => $item['type'] ?? null,
|
||||
];
|
||||
}, $legacyData);
|
||||
|
||||
// 過濾無效資料 (Filter invalid entries)
|
||||
$mappedSlots = array_filter($mappedSlots, fn($s) => $s['slot_no'] !== null);
|
||||
|
||||
// 同步處理更新庫存 (直接更新不進隊列)
|
||||
$machineService->syncSlots($machine, $mappedSlots);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Slot report synchronized success',
|
||||
'status' => '49'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B012_new: Download Product Catalog (Synchronous)
|
||||
*/
|
||||
public function getProducts(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
|
||||
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
|
||||
->with(['translations'])
|
||||
->active()
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
// 提取多語系名稱 (Extract translations)
|
||||
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
|
||||
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
|
||||
|
||||
return [
|
||||
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
|
||||
't060v01' => $product->name,
|
||||
't060v01_en' => $nameEn,
|
||||
't060v01_jp' => $nameJp,
|
||||
't060v03' => $product->spec ?? '',
|
||||
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
|
||||
't060v09' => (float) $product->price,
|
||||
't060v11' => (int) ($product->track_limit ?? 10),
|
||||
't060v30' => (float) ($product->member_price ?? $product->price),
|
||||
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
|
||||
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
|
||||
'spring_limit' => (int) ($product->spring_limit ?? 10),
|
||||
'track_limit' => (int) ($product->track_limit ?? 10),
|
||||
't063v03' => (float) $product->price,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'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 預期的是包含單一物件的陣列
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ class TransactionController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
ProcessTransaction::dispatch($data);
|
||||
@@ -34,7 +34,7 @@ class TransactionController extends Controller
|
||||
public function recordInvoice(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$data = $request->all();
|
||||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||||
$data['serial_no'] = $machine->serial_no;
|
||||
|
||||
ProcessInvoice::dispatch($data);
|
||||
|
||||
40
app/Jobs/Machine/ProcessMachineError.php
Normal file
40
app/Jobs/Machine/ProcessMachineError.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/Jobs/Machine/ProcessStateLog.php
Normal file
56
app/Jobs/Machine/ProcessStateLog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ class Machine extends Model
|
||||
'serial_no',
|
||||
'model',
|
||||
'location',
|
||||
'status',
|
||||
'current_page',
|
||||
'door_status',
|
||||
'temperature',
|
||||
@@ -69,7 +68,88 @@ class Machine extends Model
|
||||
'updater_id',
|
||||
];
|
||||
|
||||
protected $appends = ['image_urls'];
|
||||
protected $appends = ['image_urls', 'calculated_status'];
|
||||
|
||||
/**
|
||||
* 動態計算機台當前狀態
|
||||
* 1. 離線 (offline):超過 30 秒未收到心跳
|
||||
* 2. 異常 (error):在線但過去 15 分鐘內有錯誤/警告日誌
|
||||
* 3. 在線 (online):正常在線
|
||||
*/
|
||||
public function getCalculatedStatusAttribute(): string
|
||||
{
|
||||
// 判定離線
|
||||
if (!$this->last_heartbeat_at || $this->last_heartbeat_at->diffInSeconds(now()) >= 30) {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// 判定異常 (檢查過去 15 分鐘內是否有 error 或 warning 日誌)
|
||||
$hasRecentErrors = $this->logs()
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('created_at', '>=', now()->subMinutes(15))
|
||||
->exists();
|
||||
|
||||
if ($hasRecentErrors) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'online';
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定在線 (30 秒內有心跳)
|
||||
*/
|
||||
public function scopeOnline($query)
|
||||
{
|
||||
return $query->where('last_heartbeat_at', '>=', now()->subSeconds(30));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定離線 (超過 30 秒未收到心跳或從未收到心跳)
|
||||
*/
|
||||
public function scopeOffline($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('last_heartbeat_at')
|
||||
->orWhere('last_heartbeat_at', '<', now()->subSeconds(30));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定異常 (過去 15 分鐘內有錯誤或警告日誌)
|
||||
*/
|
||||
public function scopeHasError($query)
|
||||
{
|
||||
return $query->whereExists(function ($q) {
|
||||
$q->select(\Illuminate\Support\Facades\DB::raw(1))
|
||||
->from('machine_logs')
|
||||
->whereColumn('machine_logs.machine_id', 'machines.id')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('created_at', '>=', now()->subMinutes(15));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定運行中 (在線且無近期異常)
|
||||
*/
|
||||
public function scopeRunning($query)
|
||||
{
|
||||
return $query->online()->whereNotExists(function ($q) {
|
||||
$q->select(\Illuminate\Support\Facades\DB::raw(1))
|
||||
->from('machine_logs')
|
||||
->whereColumn('machine_logs.machine_id', 'machines.id')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('created_at', '>=', now()->subMinutes(15));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定異常在線 (在線且有近期異常)
|
||||
*/
|
||||
public function scopeErrorOnline($query)
|
||||
{
|
||||
return $query->online()->hasError();
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
@@ -106,6 +186,11 @@ class Machine extends Model
|
||||
return $this->hasMany(MachineSlot::class);
|
||||
}
|
||||
|
||||
public function commands()
|
||||
{
|
||||
return $this->hasMany(RemoteCommand::class);
|
||||
}
|
||||
|
||||
public function machineModel()
|
||||
{
|
||||
return $this->belongsTo(MachineModel::class);
|
||||
@@ -133,21 +218,20 @@ class Machine extends Model
|
||||
'3' => 'Admin Page',
|
||||
'4' => 'Replenishment Page',
|
||||
'5' => 'Tutorial Page',
|
||||
'60' => 'Purchasing',
|
||||
'61' => 'Locked Page',
|
||||
'62' => 'Dispense Failed',
|
||||
'301' => 'Slot Test',
|
||||
'302' => 'Slot Test',
|
||||
'401' => 'Payment Selection',
|
||||
'402' => 'Waiting for Payment',
|
||||
'403' => 'Dispensing',
|
||||
'404' => 'Receipt Printing',
|
||||
'601' => 'Pass Code',
|
||||
'602' => 'Pickup Code',
|
||||
'603' => 'Message Display',
|
||||
'604' => 'Cancel Purchase',
|
||||
'605' => 'Purchase Finished',
|
||||
'611' => 'Welcome Gift Status',
|
||||
'6' => 'Purchasing',
|
||||
'7' => 'Locked Page',
|
||||
'60' => 'Dispense Success',
|
||||
'61' => 'Slot Test',
|
||||
'62' => 'Payment Selection',
|
||||
'63' => 'Waiting for Payment',
|
||||
'64' => 'Dispensing',
|
||||
'65' => 'Receipt Printing',
|
||||
'66' => 'Pass Code',
|
||||
'67' => 'Pickup Code',
|
||||
'68' => 'Message Display',
|
||||
'69' => 'Cancel Purchase',
|
||||
'610' => 'Purchase Finished',
|
||||
'611' => 'Welcome Gift',
|
||||
'612' => 'Dispense Failed',
|
||||
];
|
||||
|
||||
|
||||
@@ -24,6 +24,33 @@ class MachineLog extends Model
|
||||
'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()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
|
||||
@@ -14,6 +14,7 @@ class MachineSlot extends Model
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'type',
|
||||
'max_stock',
|
||||
'stock',
|
||||
'expiry_date',
|
||||
|
||||
@@ -11,16 +11,16 @@ class RemoteCommand extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'command',
|
||||
'user_id',
|
||||
'command_type',
|
||||
'payload',
|
||||
'status',
|
||||
'response_payload',
|
||||
'ttl',
|
||||
'executed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'response_payload' => 'array',
|
||||
'executed_at' => 'datetime',
|
||||
];
|
||||
|
||||
@@ -28,4 +28,17 @@ class RemoteCommand extends Model
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for pending commands
|
||||
*/
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending')->orderBy('created_at', 'asc');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,20 @@ use App\Traits\TenantScoped;
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
|
||||
/**
|
||||
* Scope a query to only include active products.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'name_dictionary_key',
|
||||
'sku',
|
||||
'barcode',
|
||||
'spec',
|
||||
'manufacturer',
|
||||
|
||||
@@ -18,11 +18,15 @@ class Advertisement extends Model
|
||||
'duration',
|
||||
'url',
|
||||
'is_active',
|
||||
'start_at',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'duration' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -48,4 +52,21 @@ class Advertisement extends Model
|
||||
{
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,17 +25,33 @@ class Company extends Model
|
||||
'status',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'warranty_start_date',
|
||||
'warranty_end_date',
|
||||
'software_start_date',
|
||||
'software_end_date',
|
||||
'note',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'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',
|
||||
'status' => 'integer',
|
||||
'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.
|
||||
*/
|
||||
|
||||
50
app/Models/System/CompanyContract.php
Normal file
50
app/Models/System/CompanyContract.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class OrderItem extends Model
|
||||
'order_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'sku',
|
||||
'barcode',
|
||||
'price',
|
||||
'quantity',
|
||||
'subtotal',
|
||||
|
||||
@@ -4,11 +4,62 @@ namespace App\Services\Machine;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineLog;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -29,7 +80,6 @@ class MachineService
|
||||
$model = $data['model'] ?? $machine->model;
|
||||
|
||||
$updateData = [
|
||||
'status' => 'online',
|
||||
'temperature' => $temperature,
|
||||
'current_page' => $currentPage,
|
||||
'door_status' => $doorStatus,
|
||||
@@ -64,24 +114,50 @@ class MachineService
|
||||
public function syncSlots(Machine $machine, array $slotsData): void
|
||||
{
|
||||
DB::transaction(function () use ($machine, $slotsData) {
|
||||
// 蒐集所有傳入的商品 ID (可能是 SKU 或 實際 ID)
|
||||
$productCodes = collect($slotsData)->pluck('product_id')->filter()->unique()->toArray();
|
||||
|
||||
// 優先以 ID 查詢商品,若 ID 不存在則嘗試 Barcode (Prioritize ID lookup, fallback to Barcode)
|
||||
$products = \App\Models\Product\Product::whereIn('id', $productCodes)
|
||||
->orWhereIn('barcode', $productCodes)
|
||||
->get();
|
||||
|
||||
foreach ($slotsData as $slotData) {
|
||||
$slotNo = $slotData['slot_no'] ?? null;
|
||||
if (!$slotNo) continue;
|
||||
|
||||
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first();
|
||||
|
||||
// 查找對應的實體 ID (支援 ID 與 Barcode 比對)
|
||||
$productCode = $slotData['product_id'] ?? null;
|
||||
$actualProductId = null;
|
||||
if ($productCode) {
|
||||
$actualProductId = $products->first(function ($p) use ($productCode) {
|
||||
return (string)$p->id === (string)$productCode || $p->barcode === (string)$productCode;
|
||||
})?->id;
|
||||
}
|
||||
|
||||
// 根據貨道類型自動決定上限 (Auto-calculate max_stock based on slot type)
|
||||
// 若未提供 type,預設為 '1' (履帶/Track)
|
||||
$slotType = $slotData['type'] ?? $existingSlot->type ?? '1';
|
||||
if ($actualProductId) {
|
||||
$product = $products->find($actualProductId);
|
||||
if ($product) {
|
||||
// 1: 履帶, 2: 彈簧
|
||||
$calculatedMaxStock = ($slotType == '1') ? $product->track_limit : $product->spring_limit;
|
||||
$slotData['capacity'] = $calculatedMaxStock ?? $slotData['capacity'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'product_id' => $slotData['product_id'] ?? null,
|
||||
'product_id' => $actualProductId,
|
||||
'type' => $slotType,
|
||||
'stock' => $slotData['stock'] ?? 0,
|
||||
'capacity' => $slotData['capacity'] ?? ($existingSlot->capacity ?? 10),
|
||||
'price' => $slotData['price'] ?? ($existingSlot->price ?? 0),
|
||||
'last_restocked_at' => now(),
|
||||
'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
// 如果商品變了,或者這是一次明確的補貨回報,清空效期等待管理員更新
|
||||
// 這裡我們暫定只要有 report 進來,就需要重新確認效期
|
||||
$updateData['expiry_date'] = null;
|
||||
|
||||
// 如果這是一次明確的補貨回報,建議更新時間並記錄
|
||||
if ($existingSlot) {
|
||||
$existingSlot->update($updateData);
|
||||
} else {
|
||||
@@ -96,17 +172,25 @@ class MachineService
|
||||
*
|
||||
* @param Machine $machine
|
||||
* @param array $data
|
||||
* @param int|null $userId
|
||||
* @return void
|
||||
*/
|
||||
public function updateSlot(Machine $machine, array $data): void
|
||||
public function updateSlot(Machine $machine, array $data, ?int $userId = null): void
|
||||
{
|
||||
DB::transaction(function () use ($machine, $data) {
|
||||
DB::transaction(function () use ($machine, $data, $userId) {
|
||||
$slotNo = $data['slot_no'];
|
||||
$stock = $data['stock'] ?? null;
|
||||
$expiryDate = $data['expiry_date'] ?? null;
|
||||
$batchNo = $data['batch_no'] ?? null;
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail();
|
||||
|
||||
// 紀錄舊數據以供 Payload 使用
|
||||
$oldData = [
|
||||
'stock' => $slot->stock,
|
||||
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
|
||||
'batch_no' => $slot->batch_no,
|
||||
];
|
||||
|
||||
$updateData = [
|
||||
'expiry_date' => $expiryDate,
|
||||
'batch_no' => $batchNo,
|
||||
@@ -114,9 +198,66 @@ class MachineService
|
||||
if ($stock !== null) $updateData['stock'] = (int)$stock;
|
||||
|
||||
$slot->update($updateData);
|
||||
|
||||
// 指令去重:將該機台所有尚未領取的舊庫存同步指令標記為「已取代」
|
||||
RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'reload_stock')
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new adjustment'),
|
||||
'executed_at' => now(),
|
||||
]);
|
||||
|
||||
// 建立遠端指令紀錄 (Unified Command Concept)
|
||||
RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => $userId,
|
||||
'command_type' => 'reload_stock',
|
||||
'status' => 'pending',
|
||||
'payload' => [
|
||||
'slot_no' => $slotNo,
|
||||
'old' => $oldData,
|
||||
'new' => [
|
||||
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
|
||||
'expiry_date' => $expiryDate ?: null,
|
||||
'batch_no' => $batchNo ?: null,
|
||||
]
|
||||
]
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Legacy support for recordLog (Existing code).
|
||||
@@ -140,10 +281,10 @@ class MachineService
|
||||
$start = Carbon::parse($date)->startOfDay();
|
||||
$end = Carbon::parse($date)->endOfDay();
|
||||
|
||||
// 1. Online Count (Base on current status)
|
||||
// 1. Online Count (Base on new heartbeat logic)
|
||||
$machines = Machine::all(); // This is filtered by TenantScoped
|
||||
$totalMachines = $machines->count();
|
||||
$onlineCount = $machines->where('status', 'online')->count();
|
||||
$onlineCount = Machine::online()->count();
|
||||
|
||||
$machineIds = $machines->pluck('id')->toArray();
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ class TransactionService
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'product_name' => $item['product_name'] ?? 'Unknown',
|
||||
'sku' => $item['sku'] ?? null,
|
||||
'barcode' => $item['barcode'] ?? null,
|
||||
'price' => $item['price'],
|
||||
'quantity' => $item['quantity'],
|
||||
'subtotal' => $item['price'] * $item['quantity'],
|
||||
|
||||
@@ -8,6 +8,228 @@ return [
|
||||
[
|
||||
'name' => '機台核心通訊 (IoT Core)',
|
||||
'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)',
|
||||
'slug' => 'b005-ad-sync',
|
||||
'method' => 'GET',
|
||||
'path' => '/api/v1/app/machine/ad/B005',
|
||||
'description' => '用於機台端獲取目前應播放的廣告檔案 URL 清單。此介面無需 Request Body。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '請求是否成功',
|
||||
'example' => true
|
||||
],
|
||||
'code' => [
|
||||
'type' => 'integer',
|
||||
'description' => '內部業務狀態碼',
|
||||
'example' => 200
|
||||
],
|
||||
'data' => [
|
||||
'type' => 'array',
|
||||
'description' => '廣告物件陣列。內部欄位包含:t070v01 (名稱), t070v02 (秒數), t070v03 (位置:1:販賣頁, 2:來店禮, 3:待機廣告), t070v04 (URL), t070v05 (順位)',
|
||||
'example' => [
|
||||
[
|
||||
't070v01' => '測試機台廣告',
|
||||
't070v02' => 15,
|
||||
't070v03' => 3,
|
||||
't070v04' => 'https://example.com/ad1.mp4',
|
||||
't070v05' => 1
|
||||
]
|
||||
]
|
||||
],
|
||||
],
|
||||
'request' => [],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'OK',
|
||||
'data' => [
|
||||
[
|
||||
't070v01' => '測試機台廣告',
|
||||
't070v02' => 15,
|
||||
't070v03' => 3,
|
||||
't070v04' => 'https://example.com/ad1.mp4',
|
||||
't070v05' => 1
|
||||
]
|
||||
]
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'B009: 貨道庫存即時回報 (Inventory Report)',
|
||||
'slug' => 'b009-inventory-report',
|
||||
'method' => 'PUT',
|
||||
'path' => '/api/v1/app/products/supplementary/B009',
|
||||
'description' => '當人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。需進行 RBAC 權限核查。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'account' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '操作人員帳號',
|
||||
'example' => '0999123456'
|
||||
],
|
||||
'data' => [
|
||||
'type' => 'array',
|
||||
'required' => true,
|
||||
'description' => '貨道數據陣列。tid: 貨道號, t060v00: 商品 ID, num: 庫存量',
|
||||
'example' => [
|
||||
['tid' => '1', 't060v00' => '1', 'num' => '10']
|
||||
]
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '同步是否成功',
|
||||
'example' => true
|
||||
],
|
||||
'code' => [
|
||||
'type' => 'integer',
|
||||
'description' => '內部業務狀態碼',
|
||||
'example' => 200
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'string',
|
||||
'description' => '回應訊息',
|
||||
'example' => 'Slot report synchronized success'
|
||||
],
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'description' => '固定回傳 49 代表同步完成',
|
||||
'example' => '49'
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'account' => '0999123456',
|
||||
'data' => [
|
||||
['tid' => '1', 't060v00' => '1', 'num' => '10']
|
||||
]
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Slot report synchronized success',
|
||||
'status' => '49'
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)',
|
||||
'slug' => 'b010-heartbeat',
|
||||
@@ -91,11 +313,10 @@ return [
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'description' => '雲端指令代碼。對照表:
|
||||
49: reload B017 (貨道同步), 50: reload B005 (基礎參數), 51: reboot (重啟)
|
||||
60: reboot card machine (刷卡機重啟), 61: checkout (結帳)
|
||||
70: unlock (解鎖), 71: lock (鎖定), 72: sellCode reload B023 (即期品)
|
||||
75: exp reload B026 (效期), 79: read B050 (參數讀取)
|
||||
81: sync timer status (B710), 85: reload B0552 (出貨腳本)',
|
||||
49: reload B017 (貨道同步), 51: reboot (重啟系統)
|
||||
60: reboot card machine (刷卡機重啟), 61: checkout (觸發結帳)
|
||||
70: unlock (解鎖), 71: lock (鎖定), 85: reload B0552 (遠端出貨)
|
||||
待定義: change (遠端找零 - 目前 Java App 尚無對接)',
|
||||
'example' => '49'
|
||||
],
|
||||
],
|
||||
@@ -119,6 +340,125 @@ return [
|
||||
'status' => '49'
|
||||
],
|
||||
'notes' => '機台收到 B010 回應中的特定 `status` 代碼後,應根據對照表執行對應的指令動作或 API 呼叫 (如 B017)。若為空則代表無指令。'
|
||||
],
|
||||
[
|
||||
'name' => 'B012: 商品配置與商品主檔同步 (Unified Sync)',
|
||||
'slug' => 'b012-unified-sync',
|
||||
'method' => 'GET/PATCH',
|
||||
'path' => '/api/v1/app/machine/products/B012',
|
||||
'description' => '用於機台端獲取目前所有可販售商品的詳細配置。GET 為全量同步,PATCH 為增量更新。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '請求是否處理成功',
|
||||
'example' => true
|
||||
],
|
||||
'code' => [
|
||||
'type' => 'integer',
|
||||
'description' => '內部業務狀態碼',
|
||||
'example' => 200
|
||||
],
|
||||
'data' => [
|
||||
'type' => 'array',
|
||||
'description' => '商品明細物件陣列',
|
||||
'example' => [
|
||||
[
|
||||
't060v00' => '1',
|
||||
't060v01' => '可口可樂 330ml',
|
||||
't060v01_en' => 'Coca Cola',
|
||||
't060v01_jp' => 'コカコーラ',
|
||||
't060v03' => 'Cold Drink',
|
||||
't060v06' => 'https://.../coke.png',
|
||||
't060v09' => 25.0,
|
||||
't060v11' => 10,
|
||||
't060v30' => 20.0,
|
||||
't063v03' => 25.0,
|
||||
't060v40' => 'Buy 1 Get 1',
|
||||
't060v41' => 'SKU-001',
|
||||
'spring_limit' => 10,
|
||||
'track_limit' => 15
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'request' => [],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'OK',
|
||||
'data' => [
|
||||
[
|
||||
't060v00' => '1',
|
||||
't060v01' => '可口可樂 330ml',
|
||||
't060v01_en' => 'Coca Cola',
|
||||
't060v01_jp' => 'コカコーラ',
|
||||
't060v03' => 'Cold Drink',
|
||||
't060v06' => 'https://.../coke.png',
|
||||
't060v09' => 25.0,
|
||||
't060v11' => 10,
|
||||
't060v30' => 20.0,
|
||||
't063v03' => 25.0,
|
||||
't060v40' => 'Buy 1 Get 1',
|
||||
't060v41' => 'SKU-001',
|
||||
'spring_limit' => 10,
|
||||
'track_limit' => 15
|
||||
]
|
||||
]
|
||||
],
|
||||
'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步,App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新,App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。'
|
||||
],
|
||||
[
|
||||
'name' => '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: 取貨門異常...等。'
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
@@ -14,7 +14,6 @@ class MachineFactory extends Factory
|
||||
return [
|
||||
'name' => 'Machine-' . fake()->unique()->numberBetween(101, 999),
|
||||
'location' => fake()->address(),
|
||||
'status' => fake()->randomElement(['online', 'offline', 'error']),
|
||||
'temperature' => fake()->randomFloat(2, 2, 10),
|
||||
'firmware_version' => 'v' . fake()->randomElement(['1.0.0', '1.1.2', '2.0.1']),
|
||||
'serial_no' => 'SN-' . strtoupper(fake()->unique()->bothify('??###?')),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('remote_commands', function (Blueprint $table) {
|
||||
$table->string('note', 255)->nullable()->after('payload');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('remote_commands', function (Blueprint $table) {
|
||||
$table->dropColumn('note');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('remote_commands', function (Blueprint $table) {
|
||||
$table->foreignId('user_id')->nullable()->after('machine_id')->constrained()->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('remote_commands', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
DB::statement("ALTER TABLE remote_commands MODIFY COLUMN status ENUM('pending', 'sent', 'success', 'failed', 'superseded') DEFAULT 'pending'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
DB::statement("ALTER TABLE remote_commands MODIFY COLUMN status ENUM('pending', 'sent', 'success', 'failed') DEFAULT 'pending'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('machine_slots', function (Blueprint $group) {
|
||||
$group->string('type')->nullable()->after('slot_no')->comment('1: spring, 2: track');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machine_slots', function (Blueprint $group) {
|
||||
$group->dropColumn('type');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('machine_slots', function (Blueprint $group) {
|
||||
$group->string('type')->nullable()->comment('1: track, 2: spring')->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machine_slots', function (Blueprint $group) {
|
||||
$group->string('type')->nullable()->comment('1: spring, 2: track')->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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('machines', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('machines', 'status')) {
|
||||
$table->dropColumn('status');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machines', function (Blueprint $table) {
|
||||
$table->string('status')->default('offline')->after('location');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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
|
||||
{
|
||||
// 1. 從 products 表移除 sku
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// 因為公司外鍵正在使用這個複合索引,必須先補一個獨立索引給公司
|
||||
$table->index('company_id');
|
||||
// 現在可以安全移除含有 sku 的索引了
|
||||
$table->dropIndex(['company_id', 'sku']);
|
||||
$table->dropColumn('sku');
|
||||
});
|
||||
|
||||
// 2. 從 order_items 表移除 sku 並新增 barcode
|
||||
Schema::table('order_items', function (Blueprint $table) {
|
||||
$table->dropColumn('sku');
|
||||
$table->string('barcode')->nullable()->after('product_name')->comment('商品條碼 (備份)');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('order_items', function (Blueprint $table) {
|
||||
$table->dropColumn('barcode');
|
||||
$table->string('sku')->nullable()->after('product_name')->comment('商品編號 (備份)');
|
||||
});
|
||||
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->string('sku')->nullable()->after('name_dictionary_key')->comment('商品編號');
|
||||
$table->index(['company_id', 'sku']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
機台定時(建議每 5-10 秒)上送,用於確認連線狀態、溫度及門禁狀態。
|
||||
|
||||
|
||||
43
docs/future_todo.md
Normal file
43
docs/future_todo.md
Normal 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 週期內執行耗時匯出。
|
||||
1230
lang/en.json
1230
lang/en.json
File diff suppressed because it is too large
Load Diff
1224
lang/ja.json
1224
lang/ja.json
File diff suppressed because it is too large
Load Diff
1322
lang/zh_TW.json
1322
lang/zh_TW.json
File diff suppressed because it is too large
Load Diff
7
package-lock.json
generated
7
package-lock.json
generated
@@ -5,6 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"flatpickr": "^4.6.13",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1506,6 +1507,12 @@
|
||||
"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": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"flatpickr": "^4.6.13",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import 'flatpickr/dist/flatpickr.min.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -155,6 +157,17 @@
|
||||
[x-cloak] {
|
||||
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 {
|
||||
@@ -317,4 +330,204 @@
|
||||
.luxury-select-sm .hs-select-toggle {
|
||||
@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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,32 @@ import './bootstrap';
|
||||
import Alpine from 'alpinejs';
|
||||
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);
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
// 確保其他套件都初始化完成後再啟動 Alpine
|
||||
Alpine.start();
|
||||
|
||||
// 初始化 Preline UI
|
||||
import 'preline';
|
||||
|
||||
@@ -71,6 +71,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
@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">{{ __('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-right">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
@@ -104,15 +105,34 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
{{ __($ad->type) }}
|
||||
</span>
|
||||
</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
|
||||
</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">
|
||||
@if($ad->is_active)
|
||||
<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>
|
||||
@else
|
||||
<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>
|
||||
@endif
|
||||
@php
|
||||
$now = now();
|
||||
$isStarted = !$ad->start_at || $ad->start_at <= $now;
|
||||
$isExpired = $ad->end_at && $ad->end_at < $now;
|
||||
$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 class="px-6 py-4 text-right">
|
||||
<div class="flex justify-end items-center gap-2">
|
||||
@@ -211,7 +231,7 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
</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)">
|
||||
<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>
|
||||
<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>
|
||||
@@ -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="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="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 class="flex items-center gap-2">
|
||||
@@ -385,7 +405,9 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
type: 'image',
|
||||
duration: 15,
|
||||
is_active: true,
|
||||
url: ''
|
||||
url: '',
|
||||
start_at: '',
|
||||
end_at: ''
|
||||
},
|
||||
|
||||
// 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() {
|
||||
try {
|
||||
const response = await fetch(this.urls.assign, {
|
||||
@@ -650,6 +683,14 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
if (document.querySelector('#ad_company_select')) {
|
||||
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() {
|
||||
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.mediaPreview = null;
|
||||
this.isAdModalOpen = true;
|
||||
@@ -693,7 +734,11 @@ $baseRoute = 'admin.data-config.advertisements';
|
||||
|
||||
openEditModal(ad) {
|
||||
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.mediaPreview = ad.url; // Use existing URL as preview
|
||||
this.isAdModalOpen = true;
|
||||
|
||||
@@ -94,6 +94,51 @@
|
||||
</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) -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
|
||||
@@ -102,8 +102,8 @@
|
||||
<input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Serial Number') }}</label>
|
||||
<input type="text" value="{{ $machine->serial_no }}" class="luxury-input w-full bg-slate-50/50 dark:bg-slate-900/50 text-slate-400 cursor-not-allowed" readonly>
|
||||
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Serial No') }} <span class="text-rose-500">*</span></label>
|
||||
<input type="text" name="serial_no" value="{{ old('serial_no', $machine->serial_no) }}" class="luxury-input w-full" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label>
|
||||
@@ -231,7 +231,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Machine Photos') }}</h3>
|
||||
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Machine Images') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
|
||||
@@ -194,8 +194,7 @@
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Machine Settings') }}</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{
|
||||
__('Management of operational parameters and models') }}</p>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Management of operational parameters and models') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($tab === 'machines')
|
||||
@@ -499,8 +498,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2m16-10a4 4 0 11-8 0 4 4 0 018 0zM23 21v-2a4 4 0 00-3-3.87m-4-12a4 4 0 010 7.75" />
|
||||
</svg>
|
||||
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No
|
||||
accounts found') }}</p>
|
||||
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No accounts found') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -628,8 +626,7 @@
|
||||
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
|
||||
<div
|
||||
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add
|
||||
Machine') }}</h3>
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add Machine') }}</h3>
|
||||
<button @click="showCreateMachineModal = false"
|
||||
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -732,8 +729,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
|
||||
<button type="button" @click="showCreateMachineModal = false" class="btn-luxury-ghost">{{
|
||||
__('Cancel') }}</button>
|
||||
<button type="button" @click="showCreateMachineModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
|
||||
<button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -757,8 +753,7 @@
|
||||
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
|
||||
<div
|
||||
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add
|
||||
Machine Model') }}</h3>
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add Machine Model') }}</h3>
|
||||
<button @click="showCreateModelModal = false"
|
||||
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -774,16 +769,14 @@
|
||||
<div class="px-8 py-8 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
|
||||
__('Model Name') }}</label>
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Model Name') }}</label>
|
||||
<input type="text" name="name" required class="luxury-input w-full"
|
||||
placeholder="{{ __('Enter model name') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
|
||||
<button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{
|
||||
__('Cancel') }}</button>
|
||||
<button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
|
||||
<button type="submit" class="btn-luxury-primary px-8">{{ __('Create') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -807,8 +800,7 @@
|
||||
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
|
||||
<div
|
||||
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{
|
||||
__('Edit Machine Model') }}</h3>
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Edit Machine Model') }}</h3>
|
||||
<button @click="showEditModelModal = false"
|
||||
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -824,16 +816,14 @@
|
||||
<div class="px-8 py-8 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
|
||||
__('Model Name') }}</label>
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Model Name') }}</label>
|
||||
<input type="text" name="name" x-model="currentModel.name" required
|
||||
class="luxury-input w-full" placeholder="{{ __('Enter model name') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
|
||||
<button type="button" @click="showEditModelModal = false" class="btn-luxury-ghost">{{
|
||||
__('Cancel') }}</button>
|
||||
<button type="button" @click="showEditModelModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
|
||||
<button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -943,7 +933,7 @@
|
||||
</div>
|
||||
<p
|
||||
class="text-xs font-bold text-amber-700 dark:text-amber-300 leading-relaxed text-left flex-1">
|
||||
{{ __('Optimized for display. Supported formats: JPG, PNG, WebP (Max 10MB).') }}
|
||||
{{ __('PNG, JPG, WEBP up to 10MB') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1103,8 +1093,7 @@
|
||||
</section>
|
||||
</template>
|
||||
<section class="space-y-6">
|
||||
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware
|
||||
& Network') }}</h3>
|
||||
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware & Network') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
|
||||
@@ -1291,9 +1280,7 @@
|
||||
<div
|
||||
class='w-10 h-10 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin'>
|
||||
</div>
|
||||
<span
|
||||
class='text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] animate-pulse'>{{
|
||||
__('Syncing Permissions...') }}</span>
|
||||
<span class='text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] animate-pulse'>{{ __('Syncing Permissions...') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6" x-data="{
|
||||
|
||||
showModal: false,
|
||||
showHistoryModal: false,
|
||||
editing: false,
|
||||
sidebarView: 'detail',
|
||||
currentCompany: {
|
||||
id: '',
|
||||
name: '',
|
||||
@@ -16,6 +19,10 @@
|
||||
contact_email: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
warranty_start_date: '',
|
||||
warranty_end_date: '',
|
||||
software_start_date: '',
|
||||
software_end_date: '',
|
||||
status: 1,
|
||||
note: '',
|
||||
settings: {
|
||||
@@ -28,7 +35,10 @@
|
||||
this.currentCompany = {
|
||||
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
|
||||
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 }
|
||||
};
|
||||
this.showModal = true;
|
||||
@@ -39,6 +49,10 @@
|
||||
...company,
|
||||
start_date: company.start_date ? company.start_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: {
|
||||
enable_material_code: company.settings?.enable_material_code || false,
|
||||
enable_points: company.settings?.enable_points || false
|
||||
@@ -57,9 +71,13 @@
|
||||
detailCompany: {
|
||||
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
|
||||
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 },
|
||||
users_count: 0, machines_count: 0
|
||||
users_count: 0, machines_count: 0,
|
||||
contracts: []
|
||||
},
|
||||
openDetailSidebar(company) {
|
||||
this.detailCompany = {
|
||||
@@ -69,15 +87,40 @@
|
||||
enable_points: company.settings?.enable_points || false
|
||||
}
|
||||
};
|
||||
this.sidebarView = 'detail';
|
||||
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() {
|
||||
if (this.statusToggleSource === 'list') {
|
||||
this.$refs.statusToggleForm.submit();
|
||||
} else {
|
||||
this.$refs.companyForm.submit();
|
||||
}
|
||||
}
|
||||
},
|
||||
}">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
@@ -164,7 +207,7 @@
|
||||
{{ __('Accounts / Machines') }}</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">
|
||||
{{ __('Contract Period') }}</th>
|
||||
{{ __('Service Terms') }}</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">
|
||||
{{ __('Actions') }}</th>
|
||||
@@ -235,20 +278,53 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center text-slate-500 dark:text-slate-400">
|
||||
<div class="flex flex-col items-center gap-1.5 font-mono">
|
||||
<div class="flex items-center gap-2 min-w-[130px] justify-center">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{{ __('From:') }}</span>
|
||||
<span class="text-[13px] font-bold tracking-tighter text-slate-600 dark:text-slate-300">
|
||||
{{ $company->start_date ? $company->start_date->format('Y-m-d') : '--' }}
|
||||
<td class="px-6 py-6 border-b border-slate-50 dark:border-slate-800/50">
|
||||
<div class="flex flex-col gap-2.5 min-w-[200px] mx-auto w-fit">
|
||||
<!-- Contract Period (Only for Lease) -->
|
||||
@if($company->current_type === 'lease')
|
||||
<div class="flex items-center gap-3 group/term">
|
||||
<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>
|
||||
<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 class="flex items-center gap-2 min-w-[130px] justify-center">
|
||||
<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' }}">
|
||||
{{ $company->end_date ? $company->end_date->format('Y-m-d') : __('Permanent') }}
|
||||
@endif
|
||||
|
||||
@if($company->current_type === 'buyout')
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
@@ -447,11 +523,11 @@
|
||||
<input type="text" name="tax_id" x-model="currentCompany.tax_id"
|
||||
class="luxury-input w-full">
|
||||
</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">
|
||||
<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>
|
||||
<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">
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -462,6 +538,38 @@
|
||||
</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>
|
||||
|
||||
<!-- Admin Account Section (Account Creation) - Only show when creating -->
|
||||
@@ -652,7 +760,7 @@
|
||||
<!-- 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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -666,7 +774,19 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Account Status') }}</h3>
|
||||
@@ -690,38 +810,56 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] mb-4">{{ __('Business Type') }}</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<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 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">
|
||||
<div class="flex justify-between items-start pb-3 border-b border-slate-100/50 dark:border-slate-800/50">
|
||||
<div>
|
||||
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em]">{{ __('Contract Model') }}</h4>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -815,7 +1029,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -11,15 +11,17 @@
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Connectivity Status') }}</h3>
|
||||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time status monitoring') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1.5 px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 border border-cyan-500/20">
|
||||
<div
|
||||
class="flex items-center gap-x-1.5 px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 border border-cyan-500/20">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span>
|
||||
</span>
|
||||
<span class="text-[10px] font-black uppercase tracking-wider">{{ __('LIVE') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex-1 flex items-center">
|
||||
<!-- Left: Stats List -->
|
||||
<div class="flex-1 space-y-6">
|
||||
@@ -35,24 +37,27 @@
|
||||
<div class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.6)]"></div>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Offline Machines') }}</span>
|
||||
</div>
|
||||
<span class="text-2xl font-black text-rose-500">{{ $alertsPending }}</span>
|
||||
<span class="text-2xl font-black text-rose-500">{{ $offlineMachines }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pr-10">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="w-2 h-2 rounded-full bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.6)]"></div>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Alerts Pending') }}</span>
|
||||
</div>
|
||||
<span class="text-2xl font-black text-slate-900 dark:text-white">0</span>
|
||||
<span class="text-2xl font-black text-slate-900 dark:text-white">{{ $alertsPending }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-32 bg-slate-100 dark:bg-slate-800 mx-2"></div>
|
||||
|
||||
|
||||
<!-- Right: Big Total -->
|
||||
<div class="w-40 text-center">
|
||||
<p class="text-7xl font-black text-cyan-500 drop-shadow-[0_0_20px_rgba(6,182,212,0.3)] leading-none">{{ $activeMachines }}</p>
|
||||
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] mt-4">{{ __('Total Connected') }}</p>
|
||||
<p
|
||||
class="text-7xl font-black text-cyan-500 drop-shadow-[0_0_20px_rgba(6,182,212,0.3)] leading-none">
|
||||
{{ $activeMachines }}</p>
|
||||
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] mt-4">
|
||||
{{ __('Total Connected') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,25 +69,38 @@
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Monthly Transactions') }}</h3>
|
||||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Monthly cumulative revenue overview') }}</p>
|
||||
</div>
|
||||
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800/80 text-slate-400 dark:text-slate-500 border border-transparent dark:border-slate-700/50">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<div
|
||||
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800/80 text-slate-400 dark:text-slate-500 border border-transparent dark:border-slate-700/50">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex-1 flex flex-col space-y-4 justify-center">
|
||||
<!-- Today Stat Card -->
|
||||
<div class="group flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:shadow-none border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/30">
|
||||
<div
|
||||
class="group flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:shadow-none border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/30">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 flex items-center justify-center text-cyan-600 dark:text-cyan-400 shadow-sm transition-transform group-hover:scale-110">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.5 4.5L21.75 7.5M21.75 7.5V12m0-4.5H17.25"/></svg>
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 flex items-center justify-center text-cyan-600 dark:text-cyan-400 shadow-sm transition-transform group-hover:scale-110">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.25 18L9 11.25l4.5 4.5L21.75 7.5M21.75 7.5V12m0-4.5H17.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Today's Transactions") }}</p>
|
||||
<p class="text-4xl font-black text-slate-900 dark:text-white mt-1 tracking-tight drop-shadow-sm">${{ number_format($totalRevenue / 30, 0) }}</p>
|
||||
<p
|
||||
class="text-4xl font-black text-slate-900 dark:text-white mt-1 tracking-tight drop-shadow-sm">
|
||||
${{ number_format($totalRevenue / 30, 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-y-1">
|
||||
<span class="text-[10px] font-black text-emerald-500 bg-emerald-500/10 px-2.5 py-0.5 rounded-full">+12.5%</span>
|
||||
<span
|
||||
class="text-[10px] font-black text-emerald-500 bg-emerald-500/10 px-2.5 py-0.5 rounded-full">+12.5%</span>
|
||||
<p class="text-[9px] font-bold text-slate-300 dark:text-slate-500 uppercase tracking-tighter">{{ __('vs Yesterday') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,25 +108,39 @@
|
||||
<!-- Previous Days Stats Row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Yesterday Card -->
|
||||
<div class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
|
||||
<div
|
||||
class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Yesterday") }}</p>
|
||||
<div class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<div
|
||||
class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue / 25, 0) }}</p>
|
||||
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue
|
||||
/ 25, 0) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Before Yesterday Card -->
|
||||
<div class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
|
||||
<div
|
||||
class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Day Before") }}</p>
|
||||
<div class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
|
||||
<div
|
||||
class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue / 40, 0) }}</p>
|
||||
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue
|
||||
/ 40, 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,22 +153,27 @@
|
||||
<div>
|
||||
<div class="flex items-center gap-x-3">
|
||||
<h2 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Status List') }}</h2>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-tighter">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-tighter">
|
||||
{{ __('Total items', ['count' => $machines->total()]) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time monitoring across all machines') }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<form action="{{ route('admin.dashboard') }}" method="GET" class="flex flex-wrap items-center gap-4">
|
||||
<div class="relative group">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
||||
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" name="search" value="{{ request('search') }}" class="py-3 pl-12 pr-6 block w-64 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 rounded-2xl text-sm font-bold text-slate-700 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 transition-all outline-none" placeholder="{{ __('Quick search...') }}">
|
||||
<input type="text" name="search" value="{{ request('search') }}"
|
||||
class="py-3 pl-12 pr-6 block w-64 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 rounded-2xl text-sm font-bold text-slate-700 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 transition-all outline-none"
|
||||
placeholder="{{ __('Quick search...') }}">
|
||||
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
|
||||
</div>
|
||||
</form>
|
||||
@@ -146,63 +183,115 @@
|
||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/30">
|
||||
<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">{{ __('Machine Info') }}</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">{{ __('Running Status') }}</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">{{ __('Today Cumulative Sales') }}</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">{{ __('Current Stock') }}</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">{{ __('Last Signal') }}</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">{{ __('Alert Summary') }}</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">
|
||||
{{ __('Machine Info') }}</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">
|
||||
{{ __('Running Status') }}</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">
|
||||
{{ __('Today Cumulative Sales') }}</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">
|
||||
{{ __('Current Stock') }}</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">
|
||||
{{ __('Last Signal') }}</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">
|
||||
{{ __('Alert Summary') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/50">
|
||||
@forelse($machines as $machine)
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<div class="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-300 group-hover:bg-cyan-500 group-hover:text-white transition-all shadow-sm">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $machine->name }}</span>
|
||||
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN: {{ $machine->serial_no }})</span>
|
||||
</div>
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<div
|
||||
class="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-300 group-hover:bg-cyan-500 group-hover:text-white transition-all shadow-sm">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
stroke-width="2">
|
||||
<path
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
@if($machine->status === 'online')
|
||||
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">
|
||||
{{ __('Online') }}
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">
|
||||
{{ __('Offline') }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<span class="text-base font-extrabold text-slate-900 dark:text-slate-100">$ 0</span>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex flex-col items-center gap-y-2.5">
|
||||
<div class="w-32 h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
|
||||
<div class="h-full bg-rose-500 rounded-full shadow-[0_0_8px_rgba(244,63,94,0.4)]" style="width: 15.5%"></div>
|
||||
</div>
|
||||
<span class="text-[11px] font-black text-rose-500 uppercase tracking-[0.2em]">15.5% {{ __('Low Stock') }}</span>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{
|
||||
$machine->name }}</span>
|
||||
<span
|
||||
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN:
|
||||
{{ $machine->serial_no }})</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="text-xs font-black text-slate-400 dark:text-slate-400/80 uppercase tracking-widest leading-none">
|
||||
{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->format('Y/m/d H:i') : '---' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
@php
|
||||
$cStatus = $machine->calculated_status;
|
||||
@endphp
|
||||
|
||||
@if($cStatus === 'online')
|
||||
<div
|
||||
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<span class="text-[11px] font-bold text-slate-400/30 dark:text-slate-500 uppercase tracking-widest group-hover:text-slate-400 transition-colors">{{ __('No alert summary') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<span
|
||||
class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-widest uppercase">{{
|
||||
__('Online') }}</span>
|
||||
</div>
|
||||
@elseif($cStatus === 'error')
|
||||
<div
|
||||
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
|
||||
<div class="h-2 w-2 rounded-full bg-rose-500 animate-pulse"></div>
|
||||
<span
|
||||
class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-widest uppercase">{{
|
||||
__('Error') }}</span>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
|
||||
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
|
||||
<span
|
||||
class="text-[10px] font-black text-slate-600 dark:text-slate-400 tracking-widest uppercase">{{
|
||||
__('Offline') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<span class="text-base font-extrabold text-slate-900 dark:text-slate-100">$ 0</span>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex flex-col items-center gap-y-2.5">
|
||||
<div
|
||||
class="w-32 h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
|
||||
<div class="h-full bg-rose-500 rounded-full shadow-[0_0_8px_rgba(244,63,94,0.4)]"
|
||||
style="width: 15.5%"></div>
|
||||
</div>
|
||||
<span class="text-[11px] font-black text-rose-500 uppercase tracking-[0.2em]">15.5% {{
|
||||
__('Low Stock') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div
|
||||
class="text-xs font-black text-slate-400 dark:text-slate-400/80 uppercase tracking-widest leading-none">
|
||||
{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->format('Y/m/d H:i') :
|
||||
'---' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<span
|
||||
class="text-[11px] font-bold text-slate-400/30 dark:text-slate-500 uppercase tracking-widest group-hover:text-slate-400 transition-colors">{{
|
||||
__('No alert summary') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-32 text-center text-slate-400">{{ __('No data available') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-32 text-center text-slate-400">{{ __('No data available') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -213,4 +302,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@endsection
|
||||
@@ -95,7 +95,10 @@ $roleSelectConfig = [
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search 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>
|
||||
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
@@ -106,6 +109,7 @@ $roleSelectConfig = [
|
||||
</div>
|
||||
@endif
|
||||
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
|
||||
<button type="submit" class="hidden"></button>
|
||||
</form>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
@@ -211,7 +215,8 @@ $roleSelectConfig = [
|
||||
</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 users...') }}">
|
||||
placeholder="{{ __('Search users...') }}"
|
||||
@keydown.enter="$el.form.submit()">
|
||||
</div>
|
||||
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
@@ -222,6 +227,7 @@ $roleSelectConfig = [
|
||||
</div>
|
||||
@endif
|
||||
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
|
||||
<button type="submit" class="hidden"></button>
|
||||
</form>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
|
||||
@@ -3,84 +3,142 @@
|
||||
|
||||
@section('content')
|
||||
<script>
|
||||
window.machineApp = function() {
|
||||
return {
|
||||
showLogPanel: false,
|
||||
activeTab: 'status',
|
||||
currentMachineId: '',
|
||||
currentMachineSn: '',
|
||||
currentMachineName: '',
|
||||
logs: [],
|
||||
loading: false,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
tab: 'list',
|
||||
viewMode: 'fleet',
|
||||
selectedMachine: null,
|
||||
slots: [],
|
||||
window.machineApp = function () {
|
||||
return {
|
||||
showLogPanel: false,
|
||||
showEditModal: false,
|
||||
showInventoryPanel: false,
|
||||
editMachineId: '',
|
||||
editMachineName: '',
|
||||
activeTab: 'status',
|
||||
currentMachineId: '',
|
||||
currentMachineSn: '',
|
||||
currentMachineName: '',
|
||||
logs: [],
|
||||
loading: false,
|
||||
inventoryLoading: false,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
tab: 'list',
|
||||
viewMode: 'fleet',
|
||||
selectedMachine: null,
|
||||
slots: [],
|
||||
inventorySlots: [],
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
|
||||
init() {
|
||||
const d = new Date();
|
||||
const today = [
|
||||
d.getFullYear(),
|
||||
String(d.getMonth() + 1).padStart(2, '0'),
|
||||
String(d.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
this.startDate = today;
|
||||
this.endDate = today;
|
||||
this.$watch('activeTab', () => this.fetchLogs());
|
||||
},
|
||||
init() {
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const formatDate = (date, time) => `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${time}`;
|
||||
|
||||
this.startDate = formatDate(now, '00:00');
|
||||
this.endDate = formatDate(now, '23:59');
|
||||
this.$watch('activeTab', () => this.fetchLogs(1));
|
||||
},
|
||||
|
||||
async openLogPanel(id, sn, name) {
|
||||
this.currentMachineId = id;
|
||||
this.currentMachineSn = sn;
|
||||
this.currentMachineName = name;
|
||||
this.slots = [];
|
||||
this.showLogPanel = true;
|
||||
this.activeTab = 'status';
|
||||
await this.fetchLogs();
|
||||
},
|
||||
async openLogPanel(id, sn, name) {
|
||||
this.currentMachineId = id;
|
||||
this.currentMachineSn = sn;
|
||||
this.currentMachineName = name;
|
||||
this.slots = [];
|
||||
this.showLogPanel = true;
|
||||
this.activeTab = 'status';
|
||||
await this.fetchLogs();
|
||||
},
|
||||
|
||||
openEditModal(id, name) {
|
||||
this.editMachineId = id;
|
||||
this.editMachineName = name;
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
|
||||
async fetchLogs() {
|
||||
this.loading = true;
|
||||
try {
|
||||
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab;
|
||||
if (this.startDate) url += '&start_date=' + this.startDate;
|
||||
if (this.endDate) url += '&end_date=' + this.endDate;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.success) this.logs = data.data.data || data.data || [];
|
||||
} catch(e) { console.error('fetchLogs error:', e); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
async fetchLogs(page = 1) {
|
||||
this.loading = true;
|
||||
this.currentPage = page;
|
||||
try {
|
||||
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab + '&page=' + page;
|
||||
if (this.startDate) url += '&start_date=' + this.startDate;
|
||||
if (this.endDate) url += '&end_date=' + this.endDate;
|
||||
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
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); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async openCabinet(id) {
|
||||
this.loading = true;
|
||||
this.viewMode = 'cabinet';
|
||||
try {
|
||||
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.selectedMachine = data.machine;
|
||||
this.slots = data.slots;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
async openCabinet(id) {
|
||||
this.loading = true;
|
||||
this.viewMode = 'cabinet';
|
||||
try {
|
||||
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.selectedMachine = data.machine;
|
||||
this.slots = data.slots;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
} catch (e) { console.error('openCabinet error:', e); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
// 庫存一覽面板 (唯讀)
|
||||
async openInventoryPanel(id, sn, name) {
|
||||
this.currentMachineId = id;
|
||||
this.currentMachineSn = sn;
|
||||
this.currentMachineName = name;
|
||||
this.inventorySlots = [];
|
||||
this.showInventoryPanel = true;
|
||||
this.inventoryLoading = true;
|
||||
try {
|
||||
const res = await fetch('/admin/machines/' + id + '/slots-ajax');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.inventorySlots = data.slots;
|
||||
}
|
||||
} catch (e) { console.error('openInventoryPanel error:', e); }
|
||||
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) {
|
||||
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 expiryStr = slot.expiry_date;
|
||||
if (expiryStr < todayStr) {
|
||||
return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30 shadow-sm shadow-rose-500/5';
|
||||
}
|
||||
} catch(e) { console.error('openCabinet error:', e); }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
const diffDays = Math.round((new Date(expiryStr) - new Date(todayStr)) / 86400000);
|
||||
if (diffDays <= 7) {
|
||||
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30 shadow-sm shadow-amber-500/5';
|
||||
}
|
||||
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30 shadow-sm shadow-emerald-500/5';
|
||||
},
|
||||
|
||||
};
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 pb-20 mt-4" x-data="machineApp()"
|
||||
@keydown.escape.window="showLogPanel = false">
|
||||
@keydown.escape.window="showLogPanel = false; showInventoryPanel = false">
|
||||
<!-- Top Header & Actions -->
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display transition-all duration-300">
|
||||
<h1
|
||||
class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display transition-all duration-300">
|
||||
{{ __('Machine List') }}
|
||||
</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
|
||||
@@ -167,9 +225,13 @@ window.machineApp = function() {
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if($machine->status === 'online')
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||
@php
|
||||
$cStatus = $machine->calculated_status;
|
||||
@endphp
|
||||
|
||||
@if($cStatus === 'online')
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 tooltip"
|
||||
title="{{ __('Machine is heartbeat normal') }}">
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
@@ -179,17 +241,17 @@ window.machineApp = function() {
|
||||
class="text-xs font-black text-emerald-600 dark:text-emerald-400 tracking-widest uppercase">{{
|
||||
__('Online') }}</span>
|
||||
</div>
|
||||
@elseif($machine->status === 'error')
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
|
||||
@elseif($cStatus === 'error')
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20 tooltip"
|
||||
title="{{ __('Recently reported errors or warnings in logs') }}">
|
||||
<div class="h-2 w-2 rounded-full bg-rose-500 animate-pulse"></div>
|
||||
<span
|
||||
class="text-xs font-black text-rose-600 dark:text-rose-400 tracking-widest uppercase">{{
|
||||
__('Error') }}</span>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20 tooltip"
|
||||
title="{{ __('No heartbeat for over 30 seconds') }}">
|
||||
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
|
||||
<span
|
||||
class="text-xs font-black text-slate-500 dark:text-slate-400 tracking-widest uppercase">{{
|
||||
@@ -230,15 +292,26 @@ window.machineApp = function() {
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ route('admin.machines.edit', $machine->id) }}"
|
||||
<button type="button"
|
||||
@click="openInventoryPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
|
||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
|
||||
title="{{ __('Edit') }}">
|
||||
title="{{ __('View Inventory') }}">
|
||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="openEditModal('{{ $machine->id }}', '{{ addslashes($machine->name) }}')"
|
||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
|
||||
title="{{ __('Edit Name') }}">
|
||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="openLogPanel('{{ $machine->id }}', '{{ $machine->serial_no }}', '{{ addslashes($machine->name) }}')"
|
||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn tooltip"
|
||||
@@ -246,7 +319,9 @@ window.machineApp = function() {
|
||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -328,19 +403,35 @@ window.machineApp = function() {
|
||||
<label
|
||||
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
|
||||
__('From') }}</label>
|
||||
<input type="date" x-model="startDate" @change="fetchLogs()"
|
||||
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">
|
||||
<input type="text" x-ref="startDatePicker" x-model="startDate"
|
||||
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 class="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2">
|
||||
<label
|
||||
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.1em] whitespace-nowrap">{{
|
||||
__('To') }}</label>
|
||||
<input type="date" x-model="endDate" @change="fetchLogs()"
|
||||
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">
|
||||
<input type="text" x-ref="endDatePicker" x-model="endDate"
|
||||
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 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">
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5">
|
||||
@@ -357,7 +448,7 @@ window.machineApp = function() {
|
||||
<!-- Body / Navigation Tabs -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<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">
|
||||
<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'}"
|
||||
@@ -424,7 +515,7 @@ window.machineApp = function() {
|
||||
class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-all">
|
||||
<td class="px-6 py-4">
|
||||
<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>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
@@ -439,7 +530,7 @@ window.machineApp = function() {
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<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>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -455,7 +546,7 @@ window.machineApp = function() {
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span
|
||||
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="{
|
||||
'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',
|
||||
@@ -466,7 +557,7 @@ window.machineApp = function() {
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -492,6 +583,33 @@ window.machineApp = function() {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination Footer -->
|
||||
<div x-show="logs.length > 0" class="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
||||
<div 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>
|
||||
@@ -504,4 +622,287 @@ window.machineApp = function() {
|
||||
</div>
|
||||
</div><!-- /Offcanvas -->
|
||||
|
||||
@endsection
|
||||
<!-- Edit Machine Name Modal -->
|
||||
<div x-show="showEditModal" class="fixed inset-0 z-[100] overflow-y-auto" style="display: none;" role="dialog"
|
||||
aria-modal="true">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<!-- Background Backdrop -->
|
||||
<div x-show="showEditModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
|
||||
@click="showEditModal = false">
|
||||
</div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:min-h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<!-- Modal Panel -->
|
||||
<div x-show="showEditModal" x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-[2.5rem] p-10 text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-100 dark:border-slate-800">
|
||||
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none mb-2">
|
||||
{{ __('Edit Machine Name') }}</h3>
|
||||
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
|
||||
__('Update identification for your asset') }}</p>
|
||||
|
||||
<form :action="'/admin/machines/' + editMachineId" method="POST" class="mt-8 space-y-6">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="space-y-4">
|
||||
<label
|
||||
class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.1em]">{{
|
||||
__('New Machine Name') }}</label>
|
||||
<input type="text" name="name" x-model="editMachineName" required
|
||||
class="luxury-input block w-full px-6 py-4 text-base font-bold text-slate-800 dark:text-white bg-slate-50/50 dark:bg-slate-900/50"
|
||||
placeholder="{{ __('Enter machine name...') }}">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 pt-4">
|
||||
<button type="button" @click="showEditModal = false"
|
||||
class="px-8 py-4 bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black rounded-2xl border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700 transition-all">
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="flex-1 bg-cyan-500 hover:bg-cyan-600 text-white font-black py-4 rounded-2xl shadow-lg shadow-cyan-500/30 transition-all duration-300 transform hover:-translate-y-0.5 active:scale-95">
|
||||
{{ __('Save Changes') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Edit Modal -->
|
||||
|
||||
<!-- Inventory Offcanvas Panel (唯讀庫存一覽) -->
|
||||
<div x-show="showInventoryPanel" class="fixed inset-0 z-[100] overflow-hidden" style="display: none;"
|
||||
aria-labelledby="inventory-panel-title" role="dialog" aria-modal="true">
|
||||
|
||||
<!-- Background backdrop -->
|
||||
<div x-show="showInventoryPanel" x-transition:enter="ease-in-out duration-300"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in-out duration-300" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
|
||||
@click="showInventoryPanel = false">
|
||||
</div>
|
||||
|
||||
<div class="fixed inset-y-0 right-0 max-w-full flex">
|
||||
<!-- Sliding panel -->
|
||||
<div x-show="showInventoryPanel"
|
||||
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
||||
class="w-screen max-w-4xl">
|
||||
|
||||
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="px-5 py-6 sm:px-8 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 id="inventory-panel-title"
|
||||
class="text-xl sm:text-2xl font-black text-slate-800 dark:text-white font-display flex items-center gap-2 sm:gap-3">
|
||||
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-cyan-500 flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<span class="truncate">{{ __('Stock & Expiry Overview') }}</span>
|
||||
</h2>
|
||||
<div
|
||||
class="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-[10px] sm:text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
|
||||
<span x-text="currentMachineSn"
|
||||
class="font-mono text-cyan-600 dark:text-cyan-400 truncate"></span>
|
||||
<span class="hidden sm:inline opacity-50">—</span>
|
||||
<span x-text="currentMachineName" class="truncate"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 h-7 flex items-center">
|
||||
<button type="button" @click="showInventoryPanel = false"
|
||||
class="bg-white dark:bg-slate-800 rounded-full p-2 text-slate-400 hover:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 transition duration-300 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<span class="sr-only">{{ __('Close Panel') }}</span>
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 統計摘要 -->
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<div
|
||||
class="px-5 py-3 rounded-2xl bg-white dark:bg-slate-800/50 flex flex-col items-center min-w-[100px] border border-slate-100 dark:border-slate-800/50">
|
||||
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{
|
||||
__('Total Slots') }}</span>
|
||||
<span class="text-2xl font-black text-slate-700 dark:text-slate-200"
|
||||
x-text="inventorySlots.length"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-5 py-3 rounded-2xl bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[100px]">
|
||||
<span class="text-[9px] font-black text-rose-500 uppercase tracking-widest mb-0.5">{{
|
||||
__('Low Stock') }}</span>
|
||||
<span class="text-2xl font-black text-rose-600"
|
||||
x-text="inventorySlots.filter(s => s != null && s.stock <= 5).length"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-5 py-3 rounded-2xl bg-amber-500/5 border border-amber-500/10 flex flex-col items-center min-w-[100px]">
|
||||
<span class="text-[9px] font-black text-amber-500 uppercase tracking-widest mb-0.5">{{
|
||||
__('Expiring') }}</span>
|
||||
<span class="text-2xl font-black text-amber-600"
|
||||
x-text="inventorySlots.filter(s => { if (!s || !s.expiry_date) return false; const diff = Math.round((new Date(s.expiry_date) - new Date()) / 86400000); return diff >= 0 && diff <= 7; }).length"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Header -->
|
||||
|
||||
<!-- Body / Cabinet Grid -->
|
||||
<div class="flex-1 overflow-y-auto p-6 sm:p-8">
|
||||
<div class="relative min-h-[400px]">
|
||||
<!-- Loading State -->
|
||||
<div x-show="inventoryLoading"
|
||||
class="absolute inset-0 bg-white/50 dark:bg-slate-900/50 backdrop-blur-[1px] flex items-center justify-center z-20">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin">
|
||||
</div>
|
||||
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{
|
||||
__('Loading...') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Legend -->
|
||||
<div class="flex items-center gap-6 mb-6" x-show="!inventoryLoading">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-rose-500 shadow-lg shadow-rose-500/30"></span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
|
||||
__('Expired') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-3 h-3 rounded-full bg-amber-500 shadow-lg shadow-amber-500/30"></span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
|
||||
__('Warning') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-3 h-3 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/30"></span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.15em]">{{
|
||||
__('Normal') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slots Grid (唯讀) -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-5"
|
||||
x-show="!inventoryLoading">
|
||||
<template x-for="slot in inventorySlots" :key="slot.id">
|
||||
<div :class="getSlotColorClass(slot)"
|
||||
class="min-h-[260px] rounded-[2rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-300 relative">
|
||||
|
||||
<!-- Slot Header -->
|
||||
<div
|
||||
class="absolute top-3.5 left-4 right-4 flex justify-between items-center z-10">
|
||||
<div
|
||||
class="px-2.5 py-1 rounded-xl bg-slate-900/10 dark:bg-white/10 backdrop-blur-md border border-slate-900/5 dark:border-white/10 flex-shrink-0">
|
||||
<span
|
||||
class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white"
|
||||
x-text="slot.slot_no"></span>
|
||||
</div>
|
||||
<template x-if="slot.stock <= 2">
|
||||
<div
|
||||
class="px-2 py-1 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
|
||||
{{ __('Low') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Product Image -->
|
||||
<div class="relative w-16 h-16 mb-3 mt-2">
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 dark:border-white/5 shadow-inner overflow-hidden">
|
||||
<template x-if="slot.product && slot.product.image_url">
|
||||
<img :src="slot.product.image_url"
|
||||
class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!slot.product || !slot.product.image_url">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-7 h-7 opacity-20" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2.5"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot Info -->
|
||||
<div class="text-center w-full space-y-2">
|
||||
<template x-if="slot.product">
|
||||
<div class="text-sm font-black truncate w-full opacity-90 tracking-tight"
|
||||
x-text="slot.product.name"></div>
|
||||
</template>
|
||||
<template x-if="!slot.product">
|
||||
<div
|
||||
class="text-sm font-bold text-slate-300 dark:text-slate-600 tracking-tight">
|
||||
{{ __('Empty') }}</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Stock Level -->
|
||||
<div class="flex items-baseline justify-center gap-1">
|
||||
<span class="text-xl font-black tracking-tighter leading-none"
|
||||
x-text="slot.stock"></span>
|
||||
<span class="text-xs font-black opacity-30">/</span>
|
||||
<span class="text-sm font-bold opacity-50"
|
||||
x-text="slot.max_stock || 10"></span>
|
||||
</div>
|
||||
|
||||
<!-- Expiry Date -->
|
||||
<div class="text-sm font-black tracking-tight leading-none opacity-80"
|
||||
x-text="slot.expiry_date ? slot.expiry_date.replace(/-/g, '/') : '----/--/--'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="inventorySlots.length === 0 && !inventoryLoading">
|
||||
<div class="px-6 py-20 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="p-4 rounded-full bg-slate-50 dark:bg-slate-800/50 mb-4 border border-slate-100 dark:border-slate-800/50">
|
||||
<svg class="w-8 h-8 text-slate-300 dark:text-slate-600" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="text-[11px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{
|
||||
__('No slot data available') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div><!-- /Body -->
|
||||
|
||||
</div>
|
||||
</div><!-- /Sliding panel -->
|
||||
</div>
|
||||
</div><!-- /Inventory Offcanvas -->
|
||||
|
||||
@endsection
|
||||
@@ -61,7 +61,10 @@
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search 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>
|
||||
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
@@ -79,6 +82,7 @@
|
||||
</div>
|
||||
@endif
|
||||
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
|
||||
<button type="submit" class="hidden"></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
947
resources/views/admin/remote/index.blade.php
Normal file
947
resources/views/admin/remote/index.blade.php
Normal file
@@ -0,0 +1,947 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('content')
|
||||
<script>
|
||||
window.remoteControlApp = function(initialMachineId) {
|
||||
return {
|
||||
machines: @js($machines),
|
||||
searchQuery: '',
|
||||
selectedMachine: null,
|
||||
commands: [],
|
||||
viewMode: initialMachineId ? 'control' : 'history',
|
||||
history: @js($history),
|
||||
loading: false,
|
||||
submitting: false,
|
||||
|
||||
// App Config & Meta
|
||||
appConfig: {
|
||||
storeUrl: @js(route('admin.remote.store-command')),
|
||||
indexUrl: @js(route('admin.remote.index')),
|
||||
csrfToken: document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
},
|
||||
|
||||
// Localized Strings
|
||||
translations: @js([
|
||||
'Please select a slot' => __('Please select a slot'),
|
||||
'Search cargo lane' => __('Search cargo lane'),
|
||||
'Stock:' => __('Stock:'),
|
||||
'Loading...' => __('Loading...'),
|
||||
'No active cargo lanes found' => __('No active cargo lanes found'),
|
||||
'Empty' => __('Empty'),
|
||||
'Machine Reboot' => __('Machine Reboot'),
|
||||
'Card Reader Reboot' => __('Card Reader Reboot'),
|
||||
'Remote Reboot' => __('Remote Reboot'),
|
||||
'Lock Page Lock' => __('Lock Page Lock'),
|
||||
'Lock Page Unlock' => __('Lock Page Unlock'),
|
||||
'Remote Change' => __('Remote Change'),
|
||||
'Remote Dispense' => __('Remote Dispense'),
|
||||
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
|
||||
'Pending' => __('Pending'),
|
||||
'Sent' => __('Sent'),
|
||||
'Success' => __('Success'),
|
||||
'Failed' => __('Failed'),
|
||||
'Superseded' => __('Superseded'),
|
||||
'System' => __('System'),
|
||||
'Superseded by new adjustment' => __('Superseded by new adjustment'),
|
||||
'Superseded by new command' => __('Superseded by new command'),
|
||||
'Slot' => __('Slot'),
|
||||
'Stock' => __('Stock'),
|
||||
'Expiry' => __('Expiry'),
|
||||
'Batch' => __('Batch'),
|
||||
'Amount' => __('Amount'),
|
||||
'Command error:' => __('Command error:'),
|
||||
'Command has been queued successfully.' => __('Command has been queued successfully.'),
|
||||
'Just now' => __('Just now'),
|
||||
'mins ago' => __('mins ago'),
|
||||
'hours ago' => __('hours ago'),
|
||||
]),
|
||||
|
||||
// Form States
|
||||
lockStatus: false,
|
||||
changeAmount: 100,
|
||||
selectedSlot: '',
|
||||
note: '',
|
||||
|
||||
async init() {
|
||||
if (initialMachineId) {
|
||||
const machine = this.machines.find(m => m.id == initialMachineId);
|
||||
if (machine) {
|
||||
await this.selectMachine(machine);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for machine data changes to rebuild slot select
|
||||
this.$watch('selectedMachine.slots', () => {
|
||||
this.$nextTick(() => this.updateSlotSelect());
|
||||
});
|
||||
},
|
||||
|
||||
updateSlotSelect() {
|
||||
const wrapper = document.getElementById('slot-select-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
// Clear previous and reset
|
||||
const oldSelect = wrapper.querySelector('select');
|
||||
if (oldSelect) {
|
||||
try {
|
||||
const instance = window.HSSelect.getInstance(oldSelect);
|
||||
if (instance) instance.destroy();
|
||||
} catch (e) {}
|
||||
}
|
||||
wrapper.innerHTML = '';
|
||||
|
||||
// If loading, show a skeleton or simple text
|
||||
if (this.loading) {
|
||||
wrapper.innerHTML = `<div class="py-5 px-6 rounded-xl bg-slate-50/50 dark:bg-slate-950/30 border border-slate-100 dark:border-slate-800 text-slate-400 text-sm font-bold animate-pulse">${this.translations['Loading...']}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedMachine || !this.selectedMachine.slots || this.selectedMachine.slots.length === 0) {
|
||||
wrapper.innerHTML = `
|
||||
<div class="p-6 rounded-[1.5rem] bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-[0.1em] text-center border border-rose-500/10">
|
||||
${this.translations['No active cargo lanes found']}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const selectEl = document.createElement('select');
|
||||
selectEl.className = 'hidden';
|
||||
selectEl.id = 'dynamic-slot-select-' + Date.now();
|
||||
|
||||
const config = {
|
||||
"placeholder": this.translations['Please select a slot'] + "...",
|
||||
"hasSearch": true,
|
||||
"searchPlaceholder": this.translations['Search cargo lane'] + "...",
|
||||
"isHidePlaceholder": false,
|
||||
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
|
||||
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
|
||||
"toggleClasses": "hs-select-toggle luxury-select-toggle",
|
||||
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[100] animate-luxury-in",
|
||||
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
|
||||
"optionTemplate": '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>'
|
||||
};
|
||||
selectEl.setAttribute('data-hs-select', JSON.stringify(config));
|
||||
|
||||
const placeholderOpt = document.createElement('option');
|
||||
placeholderOpt.value = '';
|
||||
placeholderOpt.textContent = this.translations['Please select a slot'] + "...";
|
||||
placeholderOpt.dataset.title = this.translations['Please select a slot'] + "...";
|
||||
selectEl.appendChild(placeholderOpt);
|
||||
|
||||
const sortedSlots = [...this.selectedMachine.slots].sort((a, b) => {
|
||||
const aNo = parseInt(a.slot_no);
|
||||
const bNo = parseInt(b.slot_no);
|
||||
return isNaN(aNo) || isNaN(bNo) ? a.slot_no.localeCompare(b.slot_no) : aNo - bNo;
|
||||
});
|
||||
|
||||
sortedSlots.forEach(slot => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = slot.slot_no;
|
||||
const productName = slot.product ? slot.product.name : this.translations['Empty'];
|
||||
const label = `[${slot.slot_no}] ${productName} (${this.translations['Stock:']} ${slot.stock})`;
|
||||
opt.textContent = label;
|
||||
opt.dataset.title = label;
|
||||
if (slot.slot_no === this.selectedSlot) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
|
||||
wrapper.appendChild(selectEl);
|
||||
selectEl.addEventListener('change', (e) => { this.selectedSlot = e.target.value; });
|
||||
|
||||
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
|
||||
window.HSStaticMethods.autoInit(['select']);
|
||||
}
|
||||
},
|
||||
|
||||
confirmModal: {
|
||||
show: false,
|
||||
type: '',
|
||||
params: {}
|
||||
},
|
||||
|
||||
async selectMachine(machine) {
|
||||
this.selectedMachine = machine;
|
||||
this.viewMode = 'control';
|
||||
this.loading = true;
|
||||
this.commands = [];
|
||||
|
||||
// Update URL without refresh
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('machine_id', machine.id);
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/admin/remote?machine_id=${machine.id}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
this.commands = data.commands || [];
|
||||
if (data.machine) {
|
||||
this.selectedMachine = data.machine;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch error:', e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.$nextTick(() => this.updateSlotSelect());
|
||||
}
|
||||
},
|
||||
|
||||
backToList() {
|
||||
this.viewMode = 'list';
|
||||
this.selectedMachine = null;
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete('machine_id');
|
||||
window.history.pushState({}, '', url);
|
||||
},
|
||||
|
||||
backToHistory() {
|
||||
this.viewMode = 'history';
|
||||
this.selectedMachine = null;
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete('machine_id');
|
||||
window.history.pushState({}, '', url);
|
||||
},
|
||||
|
||||
sendCommand(type, params = {}) {
|
||||
this.note = ''; // Reset note for new command
|
||||
this.confirmModal.type = type;
|
||||
this.confirmModal.params = params;
|
||||
this.confirmModal.show = true;
|
||||
},
|
||||
|
||||
async executeCommand() {
|
||||
const type = this.confirmModal.type;
|
||||
const params = this.confirmModal.params;
|
||||
this.confirmModal.show = false;
|
||||
this.submitting = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('machine_id', this.selectedMachine.id);
|
||||
formData.append('command_type', type);
|
||||
formData.append('note', this.note);
|
||||
|
||||
if (params.amount) formData.append('amount', params.amount);
|
||||
if (params.slot_no) formData.append('slot_no', params.slot_no);
|
||||
|
||||
const res = await fetch(this.appConfig.storeUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': this.appConfig.csrfToken,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = this.appConfig.indexUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: {
|
||||
message: this.translations['Command error:'] + ' ' + e.message,
|
||||
type: 'error'
|
||||
}
|
||||
}));
|
||||
console.error('Command error:', e);
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatTime(dateStr) {
|
||||
if (!dateStr) return '--';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - date) / 1000); // seconds
|
||||
|
||||
if (diff < 60) return this.translations['Just now'];
|
||||
if (diff < 3600) return Math.floor(diff / 60) + ' ' + this.translations['mins ago'];
|
||||
if (diff < 7200) return '1 ' + this.translations['hours ago'];
|
||||
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
},
|
||||
|
||||
getCommandBadgeClass(status) {
|
||||
switch(status) {
|
||||
case 'pending': return 'bg-amber-100 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400 border-amber-200 dark:border-amber-500/20';
|
||||
case 'sent': return 'bg-cyan-100 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/20';
|
||||
case 'success': return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
|
||||
case 'failed': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
|
||||
case 'superseded': return 'bg-slate-100 text-slate-500 dark:bg-slate-500/10 dark:text-slate-400 border-slate-200 dark:border-slate-500/20 opacity-80';
|
||||
default: return 'bg-slate-100 text-slate-600 border-slate-200';
|
||||
}
|
||||
},
|
||||
|
||||
getCommandName(type) {
|
||||
const names = {
|
||||
'reboot': this.translations['Machine Reboot'],
|
||||
'reboot_card': this.translations['Card Reader Reboot'],
|
||||
'checkout': this.translations['Remote Reboot'],
|
||||
'lock': this.translations['Lock Page Lock'],
|
||||
'unlock': this.translations['Lock Page Unlock'],
|
||||
'change': this.translations['Remote Change'],
|
||||
'dispense': this.translations['Remote Dispense'],
|
||||
'reload_stock': this.translations['Adjust Stock & Expiry']
|
||||
};
|
||||
return names[type] || type;
|
||||
},
|
||||
|
||||
getCommandStatus(status) {
|
||||
const statuses = {
|
||||
'pending': this.translations['Pending'],
|
||||
'sent': this.translations['Sent'],
|
||||
'success': this.translations['Success'],
|
||||
'failed': this.translations['Failed'],
|
||||
'superseded': this.translations['Superseded']
|
||||
};
|
||||
return statuses[status] || status;
|
||||
},
|
||||
|
||||
getOperatorName(user) {
|
||||
return user ? user.name : this.translations['System'];
|
||||
},
|
||||
|
||||
translateNote(note) {
|
||||
if (!note) return '';
|
||||
const translations = {
|
||||
'Superseded by new adjustment': this.translations['Superseded by new adjustment']
|
||||
};
|
||||
return translations[note] || note;
|
||||
},
|
||||
|
||||
getPayloadDetails(item) {
|
||||
if (item.command_type === 'reload_stock' && item.payload) {
|
||||
const p = item.payload;
|
||||
let details = `${this.translations['Slot']} ${p.slot_no}: `;
|
||||
|
||||
if (p.old.stock !== p.new.stock) {
|
||||
details += `${this.translations['Stock']} ${p.old.stock} → ${p.new.stock}`;
|
||||
}
|
||||
|
||||
if (p.old.expiry_date !== p.new.expiry_date) {
|
||||
if (p.old.stock !== p.new.stock) details += ', ';
|
||||
details += `${this.translations['Expiry']} ${p.old.expiry_date || 'N/A'} → ${p.new.expiry_date || 'N/A'}`;
|
||||
}
|
||||
|
||||
if (p.old.batch_no !== p.new.batch_no) {
|
||||
if (p.old.stock !== p.new.stock || p.old.expiry_date !== p.new.expiry_date) details += ', ';
|
||||
details += `${this.translations['Batch']} ${p.old.batch_no || 'N/A'} → ${p.new.batch_no || 'N/A'}`;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
if (item.command_type === 'change' && item.payload) {
|
||||
return `${this.translations['Amount']}: ${item.payload.amount}`;
|
||||
}
|
||||
|
||||
if (item.command_type === 'dispense' && item.payload) {
|
||||
return `${this.translations['Slot']}: ${item.payload.slot_no}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-2 pb-20"
|
||||
x-data="remoteControlApp({{ Js::from($selectedMachine ? $selectedMachine->id : '') }})">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Back Button for Detail/Control Mode -->
|
||||
<template x-if="viewMode === 'control'">
|
||||
<button @click="backToList()"
|
||||
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">
|
||||
{{ __($title ?? 'Remote Command Center') }}
|
||||
</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
|
||||
{{ __($subtitle ?? 'Execute maintenance and operational commands remotely') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation (Only visible when not in specific machine control) -->
|
||||
<template x-if="viewMode !== 'control'">
|
||||
<div class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50">
|
||||
<button @click="viewMode = 'history'"
|
||||
:class="viewMode === 'history' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
|
||||
{{ __('Operation Records') }}
|
||||
</button>
|
||||
<button @click="viewMode = 'list'"
|
||||
:class="viewMode === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
|
||||
{{ __('New Command') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mt-6">
|
||||
|
||||
<!-- History View: Operation Records -->
|
||||
<template x-if="viewMode === 'history'">
|
||||
<div class="space-y-6 animate-luxury-in">
|
||||
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0 text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</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 whitespace-nowrap">{{ __('Creation Time') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center whitespace-nowrap">{{ __('Picked up Time') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Command 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">{{ __('Operator') }}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
<template x-for="item in history" :key="item.id">
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(item.machine)">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm overflow-hidden">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="item.machine.name"></div>
|
||||
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="item.machine.serial_no"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
|
||||
<div class="flex flex-col">
|
||||
<span x-text="new Date(item.created_at).toLocaleDateString()"></span>
|
||||
<span class="text-[10px] opacity-70" x-text="new Date(item.created_at).toLocaleTimeString()"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
|
||||
<template x-if="item.executed_at">
|
||||
<div class="flex flex-col text-cyan-600/80 dark:text-cyan-400/60">
|
||||
<span x-text="new Date(item.executed_at).toLocaleDateString()"></span>
|
||||
<span class="text-[10px] opacity-70" x-text="new Date(item.executed_at).toLocaleTimeString()"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!item.executed_at">
|
||||
<span class="text-slate-300 dark:text-slate-700">-</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex flex-col min-w-[200px]">
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(item.command_type)"></span>
|
||||
<div class="flex flex-col gap-0.5 mt-1">
|
||||
<template x-if="getPayloadDetails(item)">
|
||||
<span class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(item)"></span>
|
||||
</template>
|
||||
<template x-if="item.note">
|
||||
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(item.note)"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-full bg-cyan-500/10 flex items-center justify-center text-[10px] font-black text-cyan-600 dark:text-cyan-400 border border-cyan-500/20"
|
||||
x-text="getOperatorName(item.user).substring(0,1)"></div>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300" x-text="getOperatorName(item.user)"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex flex-col items-center gap-1.5">
|
||||
<div class="inline-flex items-center px-4 py-1.5 rounded-full border text-[10px] font-black uppercase tracking-widest shadow-sm"
|
||||
:class="getCommandBadgeClass(item.status)">
|
||||
<div class="w-1.5 h-1.5 rounded-full mr-2"
|
||||
:class="{
|
||||
'bg-amber-500 animate-pulse': item.status === 'pending',
|
||||
'bg-cyan-500': item.status === 'sent',
|
||||
'bg-emerald-500': item.status === 'success',
|
||||
'bg-rose-500': item.status === 'failed',
|
||||
'bg-slate-400': item.status === 'superseded'
|
||||
}"></div>
|
||||
<span x-text="getCommandStatus(item.status)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="history.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-20 text-center">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-16 h-16 rounded-full bg-slate-50 dark:bg-slate-900/50 flex items-center justify-center text-slate-200 dark:text-slate-800">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-slate-400 font-bold tracking-widest uppercase text-xs">{{ __('No records found') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Master View: Machine List -->
|
||||
<template x-if="viewMode === 'list'">
|
||||
<div class="space-y-6 animate-luxury-in">
|
||||
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
|
||||
<!-- Filters Area -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="relative group">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
|
||||
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" x-model="searchQuery"
|
||||
placeholder="{{ __('Search...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-72">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto pb-4">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0 text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</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">{{ __('Last Communication') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
<template x-for="machine in machines.filter(m =>
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.serial_no.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)" :key="machine.id">
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(machine)">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
|
||||
<template x-if="machine.image_urls && machine.image_urls[0]">
|
||||
<img :src="machine.image_urls[0]" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!machine.image_urls || !machine.image_urls[0]">
|
||||
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
|
||||
</svg>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="machine.name"></div>
|
||||
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.serial_no"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex items-center justify-center">
|
||||
<template x-if="machine.status === 'online' || !machine.status">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-[0.1em] uppercase">{{ __('Online') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="machine.status === 'offline'">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
|
||||
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
|
||||
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 tracking-[0.1em] uppercase">{{ __('Offline') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="machine.status && machine.status !== 'online' && machine.status !== 'offline'">
|
||||
<div class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-[0.1em] uppercase">{{ __('Abnormal') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-200" x-text="formatTime(machine.last_heartbeat_at)"></span>
|
||||
<span class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.last_heartbeat_at ? machine.last_heartbeat_at.split('T')[0] : '--'"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<button @click="selectMachine(machine)"
|
||||
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
|
||||
title="{{ __('Manage') }}">
|
||||
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Detail View: Remote Control Dashboard -->
|
||||
<template x-if="viewMode === 'control'">
|
||||
<div class="space-y-6 animate-luxury-in">
|
||||
|
||||
<!-- Dashboard Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
<!-- Left: Control Actions (Spans 2 columns) -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
|
||||
<!-- Machine Status Card -->
|
||||
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between">
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="w-16 h-16 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 border border-cyan-500/20">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-slate-800 dark:text-white leading-tight" x-text="selectedMachine.name"></h2>
|
||||
<p class="text-xs font-mono font-bold text-slate-400 mt-1 uppercase tracking-widest" x-text="selectedMachine.serial_no"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="px-4 py-2 rounded-full bg-emerald-500/10 text-emerald-600 text-xs font-black uppercase tracking-widest border border-emerald-500/20 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
{{ __('Connected') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: System Control -->
|
||||
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
|
||||
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider mb-8 flex items-center gap-3">
|
||||
<span class="w-2 h-6 bg-cyan-500 rounded-full"></span>
|
||||
{{ __('Machine Information') }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Maintenance Operations -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 ml-1">
|
||||
<div class="w-1 h-3 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Maintenance Operations') }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Reboot System -->
|
||||
<button @click="sendCommand('reboot')" class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
|
||||
<div class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 dark:text-cyan-400 group-hover:scale-110 transition-transform duration-500 border border-cyan-500/20 dark:border-cyan-400/20">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ __('Machine Reboot') }}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Card Reader Reboot -->
|
||||
<button @click="sendCommand('reboot_card')" class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
|
||||
<div class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 dark:text-cyan-400 group-hover:scale-110 transition-transform duration-500 border border-cyan-500/20 dark:border-cyan-400/20">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3-3v8a3 3 0 003 3z" /></svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ __('Card Reader Reboot') }}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Remote Settlement -->
|
||||
<button @click="sendCommand('checkout')" class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-emerald-500/50 dark:hover:border-emerald-400/60 hover:bg-emerald-500/5 dark:hover:bg-emerald-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
|
||||
<div class="w-12 h-12 rounded-2xl bg-emerald-500/10 flex items-center justify-center text-emerald-500 dark:text-emerald-400 group-hover:scale-110 transition-transform duration-500 border border-emerald-500/20 dark:border-emerald-400/20">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">{{ __('Remote Reboot') }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Controls -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 ml-1">
|
||||
<div class="w-1 h-3 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Security Controls') }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Unlock -->
|
||||
<button @click="sendCommand('unlock')" class="p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 flex items-center justify-between hover:border-emerald-500/50 dark:hover:border-emerald-400/60 hover:bg-emerald-500/5 dark:hover:bg-emerald-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-emerald-500/10 flex items-center justify-center text-emerald-500 dark:text-emerald-400 group-hover:scale-110 transition-transform duration-500 border border-emerald-500/20 dark:border-emerald-400/20">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">{{ __('Lock Page Unlock') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-2 rounded-xl bg-emerald-500 text-white text-[10px] font-black uppercase tracking-widest shadow-lg shadow-emerald-500/20 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 font-sans">{{ __('Unlock Now') }}</div>
|
||||
</button>
|
||||
|
||||
<!-- Lock -->
|
||||
<button @click="sendCommand('lock')" class="p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 flex items-center justify-between hover:border-rose-500/50 dark:hover:border-rose-400/60 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-rose-500/10 flex items-center justify-center text-rose-500 dark:text-rose-400 group-hover:scale-110 transition-transform duration-500 border border-rose-500/20 dark:border-rose-400/20">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-rose-600 dark:group-hover:text-rose-400 transition-colors">{{ __('Lock Page Lock') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-2 rounded-xl bg-rose-500 text-white text-[10px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/20 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 font-sans">{{ __('Lock Now') }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parametric Actions -->
|
||||
<!-- Row 3: Parametric Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
|
||||
<!-- Remote Change -->
|
||||
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider flex items-center gap-3">
|
||||
<span class="w-2 h-6 bg-amber-500 rounded-full"></span>
|
||||
{{ __('Remote Change') }}
|
||||
</h3>
|
||||
<div class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">CASH</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div class="relative group">
|
||||
<input type="number" x-model="changeAmount" class="luxury-input w-full bg-slate-50/50 dark:bg-slate-950/40 border-slate-200 dark:border-slate-800/80 hover:border-amber-500/30 dark:hover:border-amber-400/40 pl-20 text-4xl font-display font-black text-slate-800 dark:text-white focus:ring-0 py-6 placeholder:text-slate-400 transition-all">
|
||||
<div class="absolute inset-y-0 left-6 flex items-center pointer-events-none z-10 transition-transform group-focus-within:translate-x-1">
|
||||
<div class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center text-amber-500 dark:text-amber-400 border border-amber-500/20 dark:border-amber-400/20 group-focus-within:scale-110 group-hover:scale-105 transition-transform duration-300">
|
||||
<span class="text-xl font-black">$</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<template x-for="amt in [10, 50, 100, 500]">
|
||||
<button @click="changeAmount = amt"
|
||||
class="py-3 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 text-[11px] font-black uppercase tracking-widest transition-all bg-white/50 dark:bg-slate-900/50 text-slate-600 dark:text-slate-400 shadow-sm hover:border-amber-500/50 dark:hover:border-amber-400/60 hover:text-amber-500 dark:hover:text-amber-400 hover:bg-amber-500/10 dark:hover:bg-amber-400/5 active:scale-95"
|
||||
x-text="amt"></button>
|
||||
</template>
|
||||
</div>
|
||||
<button @click="sendCommand('change', { amount: changeAmount })" :disabled="submitting" class="btn-luxury-primary w-full py-5 rounded-[1.5rem] text-sm shadow-lg shadow-cyan-500/20 group">
|
||||
<span class="relative z-10 flex items-center justify-center gap-2">
|
||||
{{ __('Execute Remote Change') }}
|
||||
<svg class="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote Dispense -->
|
||||
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider flex items-center gap-3">
|
||||
<span class="w-2 h-6 bg-violet-500 rounded-full"></span>
|
||||
{{ __('Remote Dispense') }}
|
||||
</h3>
|
||||
<div class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">ITEM</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">{{ __('Select Target Slot') }}</label>
|
||||
<div id="slot-select-wrapper" class="relative min-h-[60px]">
|
||||
<!-- Content Injected Dynamically by Alpine.js -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispense Button (Card Style - Mirroring Row 2) -->
|
||||
<button @click="sendCommand('dispense', { slot_no: selectedSlot })"
|
||||
:disabled="submitting || !selectedSlot"
|
||||
class="w-full p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 flex items-center justify-between hover:border-violet-500/50 dark:hover:border-violet-400/60 hover:bg-violet-500/5 dark:hover:bg-violet-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-violet-500/10 flex items-center justify-center text-violet-500 dark:text-violet-400 group-hover:scale-110 transition-transform duration-500 border border-violet-500/20 dark:border-violet-400/20 group-disabled:opacity-60">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors group-disabled:text-slate-500">{{ __('Remote Dispense') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-2 rounded-xl bg-violet-500 text-white text-[10px] font-black uppercase tracking-widest shadow-lg shadow-violet-500/20 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 font-sans group-disabled:hidden">
|
||||
{{ __('Trigger') }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Command History (Sidebar) -->
|
||||
<div class="space-y-4 mt-2">
|
||||
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider flex items-center gap-3">
|
||||
<span class="w-1.5 h-6 bg-slate-300 dark:bg-slate-700 rounded-full"></span>
|
||||
{{ __('Recent Commands') }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3.5 max-h-[1000px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<template x-for="cmd in commands" :key="cmd.id">
|
||||
<div class="luxury-card p-4 px-5 rounded-2xl border border-slate-100 dark:border-slate-800 transition-all hover:bg-slate-50 dark:hover:bg-slate-900/40 relative group">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-black text-slate-800 dark:text-white" x-text="getCommandName(cmd.command_type)"></span>
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-[10px] font-black text-slate-500"
|
||||
x-text="getOperatorName(cmd.user).substring(0,1)"></div>
|
||||
<span class="text-xs font-bold text-slate-400" x-text="getOperatorName(cmd.user)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span :class="getCommandBadgeClass(cmd.status)" class="px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border" x-text="getCommandStatus(cmd.status)"></span>
|
||||
</div>
|
||||
<div class="text-[10px] font-bold text-slate-400 flex items-center gap-2 mb-3">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span x-text="new Date(cmd.created_at).toLocaleString()"></span>
|
||||
</div>
|
||||
<template x-if="getPayloadDetails(cmd)">
|
||||
<div class="px-3 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-sm font-bold text-cyan-600 dark:text-cyan-400 break-words leading-relaxed" x-text="getPayloadDetails(cmd)">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="cmd.note">
|
||||
<div class="mt-2 text-xs font-bold text-slate-400 italic" x-text="'\"' + cmd.note + '\"'"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="commands.length === 0">
|
||||
<div class="py-20 text-center flex flex-col items-center opacity-30">
|
||||
<svg class="w-12 h-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">{{ __('No command history') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Custom Confirmation Modal -->
|
||||
<template x-teleport="body">
|
||||
<div x-show="confirmModal.show"
|
||||
class="fixed inset-0 z-[100] overflow-y-auto"
|
||||
x-cloak>
|
||||
<div class="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
|
||||
<!-- Background Backdrop -->
|
||||
<div x-show="confirmModal.show"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
|
||||
@click="confirmModal.show = false"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="confirmModal.show"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
class="relative transform overflow-hidden rounded-[2.5rem] bg-white dark:bg-slate-900 p-8 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-slate-200 dark:border-slate-800">
|
||||
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20">
|
||||
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{ __('Command Confirmation') }}</h3>
|
||||
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ __('Please confirm the details below') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 bg-slate-50 dark:bg-slate-950/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800/50 mb-8">
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Command Type') }}</span>
|
||||
<span class="text-sm font-black text-slate-800 dark:text-slate-200" x-text="getCommandName(confirmModal.type)"></span>
|
||||
</div>
|
||||
<div class="space-y-2 px-1">
|
||||
<label class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">{{ __('Operation Note') }}</label>
|
||||
<textarea x-model="note"
|
||||
class="luxury-input w-full min-h-[100px] text-sm py-3 px-4 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus:border-cyan-500/50"
|
||||
placeholder="{{ __('Reason for this command...') }}"></textarea>
|
||||
</div>
|
||||
<template x-if="confirmModal.params.amount">
|
||||
<div class="flex justify-between items-center pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
|
||||
<span class="text-[10px] font-black text-amber-500 uppercase tracking-widest">{{ __('Amount') }}</span>
|
||||
<span class="text-lg font-black text-slate-800 dark:text-slate-200" x-text="'$' + confirmModal.params.amount"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="confirmModal.params.slot_no">
|
||||
<div class="flex justify-between items-center pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
|
||||
<span class="text-[10px] font-black text-violet-500 uppercase tracking-widest">{{ __('Slot No') }}</span>
|
||||
<span class="text-sm font-black text-slate-800 dark:text-slate-200" x-text="confirmModal.params.slot_no"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button @click="confirmModal.show = false"
|
||||
class="flex-1 px-6 py-4 rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest hover:bg-slate-200 dark:hover:bg-slate-700 transition-all">
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
<button @click="executeCommand()"
|
||||
class="flex-1 px-6 py-4 rounded-2xl bg-cyan-600 text-white text-xs font-black uppercase tracking-widest hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 active:scale-[0.98] transition-all">
|
||||
{{ __('Execute') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Custom Scrollbar for Luxury UI */
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e133; border-radius: 10px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #cbd5e166; }
|
||||
|
||||
/* Hide default number spinners */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: none;
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
@@ -8,7 +8,8 @@ window.stockApp = function(initialMachineId) {
|
||||
searchQuery: '',
|
||||
selectedMachine: null,
|
||||
slots: [],
|
||||
viewMode: initialMachineId ? 'detail' : 'list',
|
||||
viewMode: initialMachineId ? 'detail' : 'history',
|
||||
history: @js($history),
|
||||
loading: false,
|
||||
updating: false,
|
||||
|
||||
@@ -66,6 +67,17 @@ window.stockApp = function(initialMachineId) {
|
||||
window.history.pushState({}, '', url);
|
||||
},
|
||||
|
||||
backToHistory() {
|
||||
this.viewMode = 'history';
|
||||
this.selectedMachine = null;
|
||||
this.selectedSlot = null;
|
||||
this.slots = [];
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete('machine_id');
|
||||
window.history.pushState({}, '', url);
|
||||
},
|
||||
|
||||
openEdit(slot) {
|
||||
this.selectedSlot = slot;
|
||||
this.formData = {
|
||||
@@ -91,10 +103,18 @@ window.stockApp = function(initialMachineId) {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showEditModal = false;
|
||||
// Refresh cabinet
|
||||
await this.selectMachine(this.selectedMachine);
|
||||
|
||||
// Redirect instantly to history tab.
|
||||
// The success toast will be handled by the session() flash in the controller.
|
||||
window.location.href = "{{ route('admin.remote.stock') }}";
|
||||
}
|
||||
} catch (e) {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: {
|
||||
message: '{{ __("Save error:") }} ' + e.message,
|
||||
type: 'error'
|
||||
}
|
||||
}));
|
||||
console.error('Save error:', e);
|
||||
} finally {
|
||||
this.updating = false;
|
||||
@@ -113,117 +133,381 @@ window.stockApp = function(initialMachineId) {
|
||||
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30 shadow-sm shadow-amber-500/5';
|
||||
}
|
||||
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30 shadow-sm shadow-emerald-500/5';
|
||||
},
|
||||
|
||||
getCommandBadgeClass(status) {
|
||||
switch(status) {
|
||||
case 'pending': return 'bg-amber-100 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400 border-amber-200 dark:border-amber-500/20';
|
||||
case 'sent': return 'bg-cyan-100 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/20';
|
||||
case 'success': return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
|
||||
case 'failed': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
|
||||
case 'superseded': return 'bg-slate-100 text-slate-500 dark:bg-slate-500/10 dark:text-slate-400 border-slate-200 dark:border-slate-500/20 opacity-80';
|
||||
default: return 'bg-slate-100 text-slate-600 border-slate-200';
|
||||
}
|
||||
},
|
||||
|
||||
getCommandName(type) {
|
||||
const names = {
|
||||
'reboot': {{ Js::from(__('Machine Reboot')) }},
|
||||
'reboot_card': {{ Js::from(__('Card Reader Reboot')) }},
|
||||
'checkout': {{ Js::from(__('Remote Reboot')) }},
|
||||
'lock': {{ Js::from(__('Lock Page Lock')) }},
|
||||
'unlock': {{ Js::from(__('Lock Page Unlock')) }},
|
||||
'change': {{ Js::from(__('Remote Change')) }},
|
||||
'dispense': {{ Js::from(__('Remote Dispense')) }},
|
||||
'reload_stock': {{ Js::from(__('Adjust Stock & Expiry')) }}
|
||||
};
|
||||
return names[type] || type;
|
||||
},
|
||||
|
||||
getCommandStatus(status) {
|
||||
const statuses = {
|
||||
'pending': {{ Js::from(__('Pending')) }},
|
||||
'sent': {{ Js::from(__('Sent')) }},
|
||||
'success': {{ Js::from(__('Success')) }},
|
||||
'failed': {{ Js::from(__('Failed')) }},
|
||||
'superseded': {{ Js::from(__('Superseded')) }}
|
||||
};
|
||||
return statuses[status] || status;
|
||||
},
|
||||
|
||||
getOperatorName(user) {
|
||||
return user ? user.name : {{ Js::from(__('System')) }};
|
||||
},
|
||||
|
||||
getPayloadDetails(item) {
|
||||
if (item.command_type === 'reload_stock' && item.payload) {
|
||||
const p = item.payload;
|
||||
let details = `{{ __('Slot') }} ${p.slot_no}: `;
|
||||
|
||||
if (p.old.stock !== p.new.stock) {
|
||||
details += `{{ __('Stock') }} ${p.old.stock} → ${p.new.stock}`;
|
||||
}
|
||||
|
||||
if (p.old.expiry_date !== p.new.expiry_date) {
|
||||
if (p.old.stock !== p.new.stock) details += ', ';
|
||||
details += `{{ __('Expiry') }} ${p.old.expiry_date || 'N/A'} → ${p.new.expiry_date || 'N/A'}`;
|
||||
}
|
||||
|
||||
if (p.old.batch_no !== p.new.batch_no) {
|
||||
if (p.old.stock !== p.new.stock || p.old.expiry_date !== p.new.expiry_date) details += ', ';
|
||||
details += `{{ __('Batch') }} ${p.old.batch_no || 'N/A'} → ${p.new.batch_no || 'N/A'}`;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
formatTime(dateStr) {
|
||||
if (!dateStr) return '--';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffSeconds < 0) return date.toISOString().split('T')[0];
|
||||
if (diffSeconds < 60) return "{{ __('Just now') }}";
|
||||
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
if (diffMinutes < 60) return diffMinutes + " {{ __('mins ago') }}";
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return diffHours + " {{ __('hours ago') }}";
|
||||
|
||||
return date.toISOString().split('T')[0] + ' ' + date.toTimeString().split(' ')[0].substring(0, 5);
|
||||
},
|
||||
|
||||
translateNote(note) {
|
||||
if (!note) return '';
|
||||
const translations = {
|
||||
'Superseded by new adjustment': {{ Js::from(__('Superseded by new adjustment')) }}
|
||||
};
|
||||
return translations[note] || note;
|
||||
}
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 pb-20 mt-4"
|
||||
<div class="space-y-2 pb-20"
|
||||
x-data="stockApp('{{ $selectedMachine ? $selectedMachine->id : '' }}')"
|
||||
@keydown.escape.window="showEditModal = false">
|
||||
|
||||
<!-- Master View: Machine List -->
|
||||
<template x-if="viewMode === 'list'">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Back Button for Detail Mode -->
|
||||
<template x-if="viewMode === 'detail'">
|
||||
<button @click="backToList()"
|
||||
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">
|
||||
{{ __($title ?? 'Stock & Expiry Management') }}
|
||||
</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
|
||||
{{ __($subtitle ?? 'Manage inventory and monitor expiry dates across all machines') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation (Only visible when not in specific machine detail) -->
|
||||
<template x-if="viewMode !== 'detail'">
|
||||
<div class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50">
|
||||
<button @click="viewMode = 'history'"
|
||||
:class="viewMode === 'history' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
|
||||
{{ __('Operation Records') }}
|
||||
</button>
|
||||
<button @click="viewMode = 'list'"
|
||||
:class="viewMode === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
|
||||
{{ __('Adjust Stock & Expiry') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mt-6">
|
||||
|
||||
<!-- History View: Operation Records -->
|
||||
<template x-if="viewMode === 'history'">
|
||||
<div class="space-y-6 animate-luxury-in">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display transition-all duration-300">
|
||||
{{ __('Machine Stock') }}
|
||||
</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
|
||||
{{ __('Monitor and manage stock levels across your fleet') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative group max-w-md">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 transition-transform duration-300 group-focus-within:scale-110">
|
||||
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0 text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</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 whitespace-nowrap">{{ __('Creation Time') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center whitespace-nowrap">{{ __('Picked up Time') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Command 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">{{ __('Operator') }}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
<template x-for="item in history" :key="item.id">
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(item.machine)">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm overflow-hidden">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="item.machine.name"></div>
|
||||
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="item.machine.serial_no"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
|
||||
<div class="flex flex-col">
|
||||
<span x-text="new Date(item.created_at).toLocaleDateString()"></span>
|
||||
<span class="text-[10px] opacity-70" x-text="new Date(item.created_at).toLocaleTimeString()"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
|
||||
<template x-if="item.executed_at">
|
||||
<div class="flex flex-col text-cyan-600/80 dark:text-cyan-400/60">
|
||||
<span x-text="new Date(item.executed_at).toLocaleDateString()"></span>
|
||||
<span class="text-[10px] opacity-70" x-text="new Date(item.executed_at).toLocaleTimeString()"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!item.executed_at">
|
||||
<span class="text-slate-300 dark:text-slate-700">-</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex flex-col min-w-[200px]">
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(item.command_type)"></span>
|
||||
<div class="flex flex-col gap-0.5 mt-1">
|
||||
<template x-if="getPayloadDetails(item)">
|
||||
<span class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(item)"></span>
|
||||
</template>
|
||||
<template x-if="item.note">
|
||||
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(item.note)"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-full bg-cyan-500/10 flex items-center justify-center text-[10px] font-black text-cyan-600 dark:text-cyan-400 border border-cyan-500/20"
|
||||
x-text="getOperatorName(item.user).substring(0,1)"></div>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-300" x-text="getOperatorName(item.user)"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex flex-col items-center gap-1.5">
|
||||
<div class="inline-flex items-center px-4 py-1.5 rounded-full border text-[10px] font-black uppercase tracking-widest shadow-sm"
|
||||
:class="getCommandBadgeClass(item.status)">
|
||||
<div class="w-1.5 h-1.5 rounded-full mr-2"
|
||||
:class="{
|
||||
'bg-amber-500 animate-pulse': item.status === 'pending',
|
||||
'bg-cyan-500': item.status === 'sent',
|
||||
'bg-emerald-500': item.status === 'success',
|
||||
'bg-rose-500': item.status === 'failed',
|
||||
'bg-slate-400': item.status === 'superseded'
|
||||
}"></div>
|
||||
<span x-text="getCommandStatus(item.status)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="history.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-20 text-center">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-16 h-16 rounded-full bg-slate-50 dark:bg-slate-900/50 flex items-center justify-center text-slate-200 dark:text-slate-800">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-slate-400 font-bold tracking-widest uppercase text-xs">{{ __('No records found') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Master View: Machine List -->
|
||||
<template x-if="viewMode === 'list'">
|
||||
<div class="space-y-6 animate-luxury-in">
|
||||
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
|
||||
<!-- Filters Area -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="relative group">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
|
||||
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" x-model="searchQuery"
|
||||
class="luxury-input w-full pl-11 py-3 text-sm focus:ring-cyan-500/20"
|
||||
placeholder="{{ __('Search by name or S/N...') }}">
|
||||
<input type="text" x-model="searchQuery"
|
||||
placeholder="{{ __('Search...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-72">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<template x-for="machine in machines.filter(m =>
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.serial_no.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)" :key="machine.id">
|
||||
<div @click="selectMachine(machine)"
|
||||
class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60 hover:border-cyan-500/50 hover:shadow-2xl hover:shadow-cyan-500/10 transition-all duration-500 cursor-pointer group flex flex-col justify-between h-full relative overflow-hidden">
|
||||
|
||||
<!-- Background Glow -->
|
||||
<div class="absolute -right-10 -top-10 w-32 h-32 bg-cyan-500/5 rounded-full blur-3xl group-hover:bg-cyan-500/10 transition-colors"></div>
|
||||
|
||||
<div class="flex items-start gap-5 relative z-10">
|
||||
<div class="w-20 h-20 rounded-2xl bg-slate-50 dark:bg-slate-900 flex items-center justify-center text-slate-400 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-inner group-hover:scale-110 transition-transform duration-500 shrink-0">
|
||||
<template x-if="machine.image_urls && machine.image_urls[0]">
|
||||
<img :src="machine.image_urls[0]" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!machine.image_urls || !machine.image_urls[0]">
|
||||
<svg class="w-8 h-8 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
|
||||
</svg>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 x-text="machine.name" class="text-2xl font-black text-slate-800 dark:text-white truncate"></h3>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span x-text="machine.serial_no" class="text-xs font-mono font-bold text-cyan-600 dark:text-cyan-400 tracking-widest uppercase"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-slate-300 dark:bg-slate-700"></span>
|
||||
<span x-text="machine.location || '{{ __('No Location') }}'" class="text-xs font-bold text-slate-400 uppercase tracking-widest truncate"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center justify-between relative z-10">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-black text-slate-400 uppercase tracking-widest">{{ __('Total Slots') }}</span>
|
||||
<span class="text-xl font-black text-slate-700 dark:text-slate-300" x-text="machine.slots_count || '--'"></span>
|
||||
</div>
|
||||
<div class="w-px h-6 bg-slate-100 dark:bg-slate-800"></div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-black text-rose-500 uppercase tracking-widest">{{ __('Low Stock') }}</span>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse"></div>
|
||||
</div>
|
||||
<span class="text-xl font-black text-slate-700 dark:text-slate-300" x-text="machine.low_stock_count || '0'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-12 h-12 rounded-full bg-white dark:bg-slate-900 flex items-center justify-center text-slate-400 dark:text-slate-500 border border-slate-200/60 dark:border-slate-700/50 shadow-sm group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 transition-all duration-300 transform group-hover:translate-x-1 group-hover:shadow-lg group-hover:shadow-cyan-500/30">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="overflow-x-auto pb-4">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0 text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</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">{{ __('Alerts') }}</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">{{ __('Last Sync') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
<template x-for="machine in machines.filter(m =>
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.serial_no.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)" :key="machine.id">
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(machine)">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm">
|
||||
<template x-if="machine.image_urls && machine.image_urls[0]">
|
||||
<img :src="machine.image_urls[0]" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!machine.image_urls || !machine.image_urls[0]">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
|
||||
</svg>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="machine.name"></div>
|
||||
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.serial_no"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex justify-center">
|
||||
<template x-if="machine.status === 'online' || !machine.status">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-[0.1em] uppercase">{{ __('Online') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="machine.status === 'offline'">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
|
||||
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
|
||||
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 tracking-[0.1em] uppercase">{{ __('Offline') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="machine.status && machine.status !== 'online' && machine.status !== 'offline'">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
|
||||
<div class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-[0.1em] uppercase">{{ __('Abnormal') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex flex-col items-center gap-1.5">
|
||||
<template x-if="machine.low_stock_count > 0">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-rose-500/10 text-rose-500 text-[10px] font-black border border-rose-500/20 uppercase tracking-widest leading-none shadow-sm shadow-rose-500/5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse"></span>
|
||||
<span x-text="machine.low_stock_count"></span> {{ __('Low') }}
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="machine.expiring_soon_count > 0">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-500 text-[10px] font-black border border-amber-500/20 uppercase tracking-widest leading-none shadow-sm shadow-amber-500/5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
<span x-text="machine.expiring_soon_count"></span> {{ __('Expiring') }}
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!machine.low_stock_count && !machine.expiring_soon_count">
|
||||
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-600 uppercase tracking-[0.1em]">{{ __('All Stable') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-200" x-text="formatTime(machine.last_heartbeat_at)"></span>
|
||||
<span class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.last_heartbeat_at ? machine.last_heartbeat_at.split('T')[0] : '--'"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<button @click="selectMachine(machine)"
|
||||
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
|
||||
title="{{ __('Manage') }}">
|
||||
<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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Detail View: Cabinet Management -->
|
||||
<template x-if="viewMode === 'detail'">
|
||||
<div class="space-y-6 animate-luxury-in">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center gap-4 mb-2 px-1">
|
||||
<button @click="backToList()"
|
||||
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">
|
||||
{{ __('Machine Stock') }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-8 animate-luxury-in">
|
||||
|
||||
<!-- Machine Header Info -->
|
||||
<div class="luxury-card rounded-[2.5rem] p-8 md:p-10 flex flex-col md:flex-row md:items-center justify-between gap-8 border border-slate-200/60 dark:border-slate-800/60">
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
$foundModule = null;
|
||||
foreach ($moduleMap as $prefix => $label) {
|
||||
if (str_starts_with($routeName, $prefix)) {
|
||||
$foundModule = [
|
||||
'label' => $label,
|
||||
'url' => '#',
|
||||
'active' => false
|
||||
];
|
||||
$foundModule = [
|
||||
'label' => $label,
|
||||
'url' => '#',
|
||||
'active' => false
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -119,9 +119,11 @@
|
||||
'advertisements' => __('Advertisement Management'),
|
||||
default => null,
|
||||
},
|
||||
'remote' => __('Command Center'),
|
||||
'stock' => __('Stock & Expiry'),
|
||||
default => null,
|
||||
},
|
||||
'edit' => str_starts_with($routeName, 'profile') ? null : __('Edit'),
|
||||
'edit' => str_starts_with($routeName, 'profile') ? __('Profile') : __('Edit'),
|
||||
'create' => __('Create'),
|
||||
'show' => __('Detail'),
|
||||
'logs' => __('Machine Logs'),
|
||||
@@ -140,7 +142,7 @@
|
||||
'purchases' => __('Purchases'),
|
||||
'replenishments' => __('Replenishments'),
|
||||
'replenishment-records' => __('Replenishment Records'),
|
||||
'machine-stock' => __('Machine Stock'),
|
||||
'machine-stock' => __('Stock & Expiry'),
|
||||
'staff-stock' => __('Staff Stock'),
|
||||
'returns' => __('Returns'),
|
||||
'pickup-codes' => __('Pickup Codes'),
|
||||
@@ -184,9 +186,9 @@
|
||||
'warehouses' => __('Warehouse Permissions'),
|
||||
'analysis' => __('Analysis Permissions'),
|
||||
'audit' => __('Audit Permissions'),
|
||||
'remote' => __('Remote Permissions'),
|
||||
'remote' => __('Command Center'),
|
||||
'line' => __('Line Permissions'),
|
||||
'stock' => __('Machine Stock'),
|
||||
'stock' => __('Stock & Expiry'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -166,7 +166,12 @@
|
||||
<a href="#{{ $api['slug'] }}" class="luxury-nav-link"
|
||||
:class="{ 'active': activeSection === '{{ $api['slug'] }}' }"
|
||||
@click="activeSection = '{{ $api['slug'] }}'">
|
||||
<span>{{ $api['name'] }}</span>
|
||||
<div class="flex items-center gap-2 overflow-hidden w-full">
|
||||
<span class="flex-shrink-0 text-[10px] w-10 text-center font-black px-1.5 py-0.5 rounded-md uppercase tracking-tighter {{ $api['method'] === 'GET' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : ($api['method'] === 'POST' ? 'bg-cyan-50 text-cyan-600 border border-cyan-200' : 'bg-amber-50 text-amber-600 border border-amber-200') }}">
|
||||
{{ $api['method'] }}
|
||||
</span>
|
||||
<span class="truncate">{{ $api['name'] }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
@endforeach
|
||||
@@ -187,7 +192,12 @@
|
||||
<div id="{{ $api['slug'] }}" class="mb-16 animate-luxury-in" x-intersect="activeSection = '{{ $api['slug'] }}'">
|
||||
<div class="mb-4"></div>
|
||||
|
||||
<h3 class="font-display text-3xl font-black text-slate-900 mb-6 tracking-tight">{{ $api['name'] }}</h3>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<span class="px-3 py-1 text-sm font-black rounded-lg uppercase tracking-widest {{ $api['method'] === 'GET' ? 'bg-emerald-100 text-emerald-700' : ($api['method'] === 'POST' ? 'bg-cyan-100 text-cyan-700' : 'bg-amber-100 text-amber-700') }}">
|
||||
{{ $api['method'] }}
|
||||
</span>
|
||||
<h3 class="font-display text-3xl font-black text-slate-900 tracking-tight">{{ $api['name'] }}</h3>
|
||||
</div>
|
||||
<p class="text-slate-600 mb-8 text-lg font-medium">{{ $api['description'] }}</p>
|
||||
|
||||
<!-- Headers & URL -->
|
||||
@@ -264,7 +274,7 @@
|
||||
<span
|
||||
class="text-[11px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
|
||||
<code
|
||||
class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example']) : $param['example'] }}</code>
|
||||
class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
@@ -280,7 +290,7 @@
|
||||
<!-- Request Example -->
|
||||
<div>
|
||||
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">請求範例 (Request Body)</h4>
|
||||
<pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre>
|
||||
<pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Response Examples -->
|
||||
@@ -310,7 +320,7 @@
|
||||
@if(isset($param['example']))
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
|
||||
<code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ $param['example'] }}</code>
|
||||
<code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
@@ -322,7 +332,7 @@
|
||||
@endif
|
||||
|
||||
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">回應範例 (Response Body)</h4>
|
||||
<pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre>
|
||||
<pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
|
||||
</div>
|
||||
|
||||
@if(isset($api['notes']))
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
<!-- End Sidebar -->
|
||||
|
||||
<!-- Content -->
|
||||
<div class="w-full pt-6 lg:pt-10 pb-12 px-4 sm:px-6 md:px-8 lg:pl-72">
|
||||
<div class="w-full pt-4 lg:pt-5 pb-12 px-4 sm:px-6 md:px-8 lg:pl-72">
|
||||
<x-breadcrumbs class="mb-4 hidden lg:flex" />
|
||||
<main class="animate-fade-up">
|
||||
@yield('content')
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.purchases') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.purchases') }}">{{ __('Purchases') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.replenishments') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.replenishments') }}">{{ __('Replenishments') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.replenishment-records') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.replenishment-records') }}">{{ __('Replenishment Records') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.machine-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.machine-stock') }}">{{ __('Machine Stock') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.machine-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.machine-stock') }}">{{ __('Stock & Expiry') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.staff-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.staff-stock') }}">{{ __('Staff Stock') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.returns') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.returns') }}">{{ __('Returns') }}</a></li>
|
||||
</ul>
|
||||
@@ -259,13 +259,14 @@
|
||||
</button>
|
||||
<div x-show="open" x-collapse>
|
||||
<ul class="luxury-submenu" data-sidebar-sub>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.stock') }}">{{ __('Machine Stock') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.restart') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.restart') }}">{{ __('Machine Restart') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.restart-card-reader') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.restart-card-reader') }}">{{ __('Card Reader Restart') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.checkout') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.checkout') }}">{{ __('Remote Checkout') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.lock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.lock') }}">{{ __('Remote Lock') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.change') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.change') }}">{{ __('Remote Change') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.dispense') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.dispense') }}">{{ __('Remote Dispense') }}</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.stock') }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
{{ __('Stock & Expiry') }}
|
||||
</a></li>
|
||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.index') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.index') }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
{{ __('Command Center') }}
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -47,6 +47,15 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|
||||
|--------------------------------------------------------------------------
|
||||
| 專門用於機台通訊,頻率較高,建議搭配異步處理。
|
||||
*/
|
||||
|
||||
// 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth)
|
||||
Route::prefix('app')->group(function () {
|
||||
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 () {
|
||||
// 心跳與狀態 (B010, B017, B710, B220)
|
||||
Route::post('machine/status/B010', [App\Http\Controllers\Api\V1\App\MachineController::class, 'heartbeat']);
|
||||
@@ -55,6 +64,16 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|
||||
Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']);
|
||||
Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']);
|
||||
|
||||
// 廣告與貨道清單 (B005, B009, B012)
|
||||
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);
|
||||
Route::put('products/supplementary/B009', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportSlotList']);
|
||||
|
||||
// 統一商品主檔 API (B012 整合版)
|
||||
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)
|
||||
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']);
|
||||
|
||||
@@ -144,13 +144,9 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
|
||||
|
||||
// 10. 遠端管理
|
||||
Route::prefix('remote')->name('remote.')->group(function () {
|
||||
Route::get('/', [App\Http\Controllers\Admin\RemoteController::class, 'index'])->name('index');
|
||||
Route::post('/command', [App\Http\Controllers\Admin\RemoteController::class, 'storeCommand'])->name('store-command');
|
||||
Route::get('/stock', [App\Http\Controllers\Admin\RemoteController::class, 'stock'])->name('stock');
|
||||
Route::get('/restart', [App\Http\Controllers\Admin\RemoteController::class, 'restart'])->name('restart');
|
||||
Route::get('/restart-card-reader', [App\Http\Controllers\Admin\RemoteController::class, 'restartCardReader'])->name('restart-card-reader');
|
||||
Route::get('/checkout', [App\Http\Controllers\Admin\RemoteController::class, 'checkout'])->name('checkout');
|
||||
Route::get('/lock', [App\Http\Controllers\Admin\RemoteController::class, 'lock'])->name('lock');
|
||||
Route::get('/change', [App\Http\Controllers\Admin\RemoteController::class, 'change'])->name('change');
|
||||
Route::get('/dispense', [App\Http\Controllers\Admin\RemoteController::class, 'dispense'])->name('dispense');
|
||||
});
|
||||
|
||||
// 11. Line管理
|
||||
|
||||
86
tests/Feature/MachineStatusTest.php
Normal file
86
tests/Feature/MachineStatusTest.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineLog;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MachineStatusTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test machine is online with recent heartbeat and no errors.
|
||||
*/
|
||||
public function test_machine_is_online_with_recent_heartbeat(): void
|
||||
{
|
||||
$machine = Machine::factory()->create([
|
||||
'last_heartbeat_at' => now()->subSeconds(10),
|
||||
]);
|
||||
|
||||
$this->assertEquals('online', $machine->calculated_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test machine is error with recent heartbeat but recent errors.
|
||||
*/
|
||||
public function test_machine_is_error_with_recent_logs(): void
|
||||
{
|
||||
$machine = Machine::factory()->create([
|
||||
'last_heartbeat_at' => now()->subSeconds(10),
|
||||
]);
|
||||
|
||||
// Add an error log 10 mins ago
|
||||
MachineLog::create([
|
||||
'machine_id' => $machine->id,
|
||||
'level' => 'error',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'message' => 'Test error',
|
||||
]);
|
||||
|
||||
$this->assertEquals('error', $machine->calculated_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test machine is offline with old heartbeat (30s+).
|
||||
*/
|
||||
public function test_machine_is_offline_with_old_heartbeat(): void
|
||||
{
|
||||
$machine = Machine::factory()->create([
|
||||
'last_heartbeat_at' => now()->subSeconds(35),
|
||||
]);
|
||||
|
||||
$this->assertEquals('offline', $machine->calculated_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test machine scopes (online, offline, hasError).
|
||||
*/
|
||||
public function test_machine_scopes(): void
|
||||
{
|
||||
// 1. Online machine
|
||||
$onlineMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(10)]);
|
||||
|
||||
// 2. Offline machine
|
||||
$offlineMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(40)]);
|
||||
|
||||
// 3. Online machine with error
|
||||
$errorMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(5)]);
|
||||
MachineLog::create([
|
||||
'machine_id' => $errorMachine->id,
|
||||
'level' => 'error',
|
||||
'created_at' => now()->subMinutes(5),
|
||||
'message' => 'Error log',
|
||||
]);
|
||||
|
||||
$this->assertEquals(2, Machine::online()->count());
|
||||
$this->assertEquals(1, Machine::offline()->count());
|
||||
$this->assertEquals(1, Machine::online()->hasError()->count());
|
||||
|
||||
$this->assertTrue(Machine::online()->get()->contains($onlineMachine));
|
||||
$this->assertTrue(Machine::offline()->get()->contains($offlineMachine));
|
||||
$this->assertTrue(Machine::online()->hasError()->get()->contains($errorMachine));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user