Compare commits
45 Commits
37ef6f1c10
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| daf8b1ebcc | |||
| 8f008ffb61 | |||
| 729890d7c7 | |||
| ad256d3d3b | |||
| 5415b14a53 | |||
| c97776892e | |||
| a599b14df1 | |||
| c343df34ee | |||
| 253ae8afd4 | |||
| f2147ae6c4 | |||
| b60afc3abe | |||
| bbdc5bad9f | |||
| 08b6c60d2e | |||
| e085058d63 | |||
| e7ad7e3dc3 | |||
| 3dbb394862 | |||
| 969e4df629 | |||
| 7c47ad67fa | |||
| 953d6a41f3 | |||
| 2e49129d77 | |||
| 08fc86d3f8 | |||
| e27eee78f5 | |||
| 759fae4380 | |||
| 54d62c5378 | |||
| d14eda7d69 | |||
| 9bbfaa39e6 | |||
| 2c9dc793d7 | |||
| f3b2c3e018 | |||
| 44ef355c54 | |||
| ea0333d77e | |||
| e780e195e2 | |||
| fdd3589d7b | |||
| c875ab7d29 | |||
| 740eaa30b7 | |||
| 8ec5473ec7 | |||
| ac51027dda | |||
| 17b5c1a316 | |||
| 7883a755d2 | |||
| 03f8fdb654 | |||
| f60e5a9c72 | |||
| 19076c363c | |||
| 675e285e8c | |||
| b7ff8ac01c | |||
| c015666f87 | |||
| 3629caebd0 |
@@ -72,7 +72,7 @@ trigger: always_on
|
||||
|
||||
### 5.1 初始角色建立
|
||||
當系統管理員為新客戶(該租戶尚未有任何角色)建立第一個帳號時,應遵循以下邏輯:
|
||||
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null` 且 `is_system = 0`)中選取一個作為基礎。
|
||||
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null` 且 `is_system = 1`)中選取一個作為基礎,但必須排除「超級管理員 (`super-admin`)」。
|
||||
2. **自動克隆**:系統會將該範本的權限內容複製一份至該租戶下。
|
||||
3. **統一命名**:克隆後的角色名稱在該租戶公司內應統一命名為**「管理員」**。
|
||||
4. **帳號綁定**:該新客戶帳號將被指派至此新建立的「管理員」角色。
|
||||
|
||||
@@ -15,6 +15,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|
||||
|---|---|---|
|
||||
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` |
|
||||
| B010, B017, B600, B055, API 規格, 通訊協議, 狀態碼, 頁面碼, 範例, JSON | **API 技術規格與通訊協議規範** | `.agents/skills/api-technical-specs/SKILL.md` |
|
||||
| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
|
||||
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
|
||||
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |
|
||||
|
||||
260
.agents/skills/api-technical-specs/SKILL.md
Normal file
260
.agents/skills/api-technical-specs/SKILL.md
Normal file
@@ -0,0 +1,260 @@
|
||||
---
|
||||
name: API 技術規格與通訊協議規範
|
||||
description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與後端 (Cloud) 通訊的 API 細節、參數命名規則、狀態代碼對照與認證機制,作為系統開發的唯一規格來源。
|
||||
---
|
||||
|
||||
# API 技術規格與通訊協議規範 (API Technical Specs)
|
||||
|
||||
本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。
|
||||
|
||||
## 1. 核心命名原則
|
||||
- **語意化優先**:捨棄舊版 M_ 前綴,統一使用 snake_case (如 firmware_version)。
|
||||
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
|
||||
|
||||
## 2. 身份認證 (Authentication)
|
||||
本系統採用兩階段認證模式:
|
||||
|
||||
### 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 B000: 機台本地管理員同步登入
|
||||
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。
|
||||
|
||||
- **URL**: POST /api/v1/app/admin/login/B000
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| 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。
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 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: 收據簽單
|
||||
- 66: 通行碼 / 67: 取貨碼 / 68: 訊息顯示
|
||||
- 69: 取消購買 / 610: 購買結束 / 611: 來店禮
|
||||
- 612: 出貨失敗
|
||||
|
||||
**雲端指令代碼 (status):**
|
||||
- 49: reload B017 (貨道同步)
|
||||
- 51: reboot (重啟系統)
|
||||
- 60: reboot card machine (刷卡機重啟)
|
||||
- 61: checkout (觸發結帳)
|
||||
- 70: unlock (解鎖)
|
||||
- 71: lock (鎖定)
|
||||
- 85: reload B0552 (遠端出貨)
|
||||
|
||||
---
|
||||
|
||||
### 3.5 B012: 商品配置與商品主檔同步 (Unified Sync)
|
||||
用於機台端獲取目前所有可販售商品的詳細配置。App 端應依據呼叫的方法決定數據處理方式。
|
||||
|
||||
- **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 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。
|
||||
@@ -60,4 +60,19 @@ public function handle(MachineService $service): void
|
||||
- [ ] 是否使用了 `ApiResponse` Trait?
|
||||
- [ ] 業務邏輯是否已封裝至 `App\Services`?
|
||||
- [ ] 是否使用了 Redis Queue 進行非同步處理?
|
||||
- [ ] 是否在 API 層級進行了基礎的參數驗證?
|
||||
|
||||
## 6. API 規格定義 (API Specifications)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **規格分離原則**:本技能僅規範「通訊處理邏輯」。關於具體的 API 欄位、參數命名、狀態代碼對照與範例,請務必參閱專屬技能規範:
|
||||
> - **[API 技術規格與通訊協議規範](file:///home/mama/projects/star-cloud/.agents/skills/api-technical-specs/SKILL.md)**
|
||||
|
||||
### 常見端點處理模式
|
||||
1. **B010 (心跳)**:高頻點,必須進入 Redis Queue。更新 `last_heartbeat_at` 與感測器快照。
|
||||
2. **B600 (交易)**:高價值點,必須進入任務隊列並支援重試。建立 `Transaction` 紀錄。
|
||||
3. **B017 (貨道)**:回覆較大資料量,應確保 Service 層具備緩存 (Cache) 機制。
|
||||
|
||||
---
|
||||
|
||||
> [!CAUTION]
|
||||
> **身份識別機制**:禁止在 Body 傳輸 `machine` 或 `key`。系統強制透過 `Bearer Token` 識別並自動關聯資料。
|
||||
|
||||
@@ -181,7 +181,7 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
|
||||
|
||||
### 搜尋式下拉選單 (Searchable Select) - 【進階推薦】
|
||||
- **組件**: `<x-searchable-select />`
|
||||
- **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:所屬單位、機台編號)。
|
||||
- **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:公司名稱、機台編號)。
|
||||
- **奢華特徵**:
|
||||
- **動態旋轉箭頭**: 透過 `::after` 偽元素實作,選單展開時箭頭執行 `300ms` 的 180 度旋轉動畫。
|
||||
- **即時過濾**: 輸入關鍵字即時隱藏不匹配項。
|
||||
|
||||
@@ -96,6 +96,7 @@ jobs:
|
||||
php artisan optimize:clear &&
|
||||
php artisan optimize &&
|
||||
php artisan view:cache &&
|
||||
php artisan queue:restart &&
|
||||
php artisan db:seed --class=RoleSeeder --force &&
|
||||
php artisan db:seed --class=AdminUserSeeder --force
|
||||
"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,5 +19,6 @@ yarn-error.log
|
||||
/.vscode
|
||||
/docs/API
|
||||
/docs/*.xlsx
|
||||
/docs/pptx
|
||||
|
||||
|
||||
|
||||
270
app/Http/Controllers/Admin/AdvertisementController.php
Normal file
270
app/Http/Controllers/Admin/AdvertisementController.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineAdvertisement;
|
||||
use App\Models\System\Advertisement;
|
||||
use App\Models\System\Company;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AdvertisementController extends AdminController
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tab = $request->input('tab', 'list');
|
||||
|
||||
// Tab 1: 廣告列表
|
||||
$advertisements = Advertisement::with('company')->latest()->paginate(10);
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
|
||||
$allAds = Advertisement::playing()->get();
|
||||
|
||||
// Tab 2: 機台廣告設置 (所需資料)
|
||||
// 取得使用者有權限的機台列表 (已透過 Global Scope 過濾)
|
||||
$machines = Machine::select('id', 'name', 'serial_no', 'company_id')->get();
|
||||
|
||||
$companies = $user->isSystemAdmin() ? Company::orderBy('name')->get() : collect();
|
||||
|
||||
return view('admin.ads.index', [
|
||||
'advertisements' => $advertisements,
|
||||
'machines' => $machines,
|
||||
'tab' => $tab,
|
||||
'allAds' => $allAds,
|
||||
'companies' => $companies,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材 CRUD: 儲存廣告
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:image,video',
|
||||
'duration' => 'required|in:15,30,60',
|
||||
'file' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi',
|
||||
$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();
|
||||
$file = $request->file('file');
|
||||
|
||||
if ($request->type === 'image') {
|
||||
$path = $this->storeAsWebp($file, 'ads');
|
||||
} else {
|
||||
$path = $file->store('ads', 'public');
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin()) {
|
||||
$companyId = $request->filled('company_id') ? $request->company_id : null;
|
||||
} else {
|
||||
$companyId = $user->company_id;
|
||||
}
|
||||
|
||||
$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()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement created successfully.'),
|
||||
'data' => $advertisement
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement created successfully.'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Advertisement $advertisement)
|
||||
{
|
||||
$rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:image,video',
|
||||
'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')) {
|
||||
$rules['file'] = [
|
||||
'file',
|
||||
'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi',
|
||||
$request->type === 'image' ? 'max:10240' : 'max:51200',
|
||||
];
|
||||
}
|
||||
|
||||
$request->validate($rules);
|
||||
|
||||
$data = $request->only(['name', 'type', 'duration', 'start_at', 'end_at']);
|
||||
$data['is_active'] = $request->has('is_active');
|
||||
|
||||
$user = auth()->user();
|
||||
if ($user->isSystemAdmin()) {
|
||||
$data['company_id'] = $request->filled('company_id') ? $request->company_id : null;
|
||||
}
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
// 刪除舊檔案
|
||||
// 處理 URL 可能包含 storage 或原始路徑的情況
|
||||
$oldPath = str_replace(Storage::disk('public')->url(''), '', $advertisement->url);
|
||||
// 去除開頭可能的斜線
|
||||
$oldPath = ltrim($oldPath, '/');
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
|
||||
// 存入新檔案
|
||||
$file = $request->file('file');
|
||||
if ($request->type === 'image') {
|
||||
$path = $this->storeAsWebp($file, 'ads');
|
||||
} else {
|
||||
$path = $file->store('ads', 'public');
|
||||
}
|
||||
$data['url'] = Storage::disk('public')->url($path);
|
||||
}
|
||||
|
||||
$advertisement->update($data);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement updated successfully.'),
|
||||
'data' => $advertisement
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement updated successfully.'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Advertisement $advertisement)
|
||||
{
|
||||
// 檢查是否有機台正投放中
|
||||
if ($advertisement->machineAdvertisements()->exists()) {
|
||||
return redirect()->back()->with('error', __('Cannot delete advertisement being used by machines.'));
|
||||
}
|
||||
|
||||
// 刪除實體檔案
|
||||
$path = str_replace(Storage::disk('public')->url(''), '', $advertisement->url);
|
||||
Storage::disk('public')->delete($path);
|
||||
|
||||
$advertisement->delete();
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement deleted successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Advertisement deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 取得特定機台的廣告投放清單
|
||||
*/
|
||||
public function getMachineAds(Machine $machine)
|
||||
{
|
||||
$assignments = MachineAdvertisement::where('machine_id', $machine->id)
|
||||
->with('advertisement')
|
||||
->orderBy('sort_order', 'asc')
|
||||
->get()
|
||||
->groupBy('position');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $assignments
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 投放廣告至機台
|
||||
*/
|
||||
public function assign(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'machine_id' => 'required|exists:machines,id',
|
||||
'advertisement_id' => 'required|exists:advertisements,id',
|
||||
'position' => 'required|in:vending,visit_gift,standby',
|
||||
'sort_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
// If sort_order is not provided, append to the end of the current position list
|
||||
$newSortOrder = $request->sort_order;
|
||||
if (is_null($newSortOrder)) {
|
||||
$newSortOrder = MachineAdvertisement::where('machine_id', $request->machine_id)
|
||||
->where('position', $request->position)
|
||||
->max('sort_order') + 1;
|
||||
}
|
||||
|
||||
MachineAdvertisement::updateOrCreate(
|
||||
[
|
||||
'machine_id' => $request->machine_id,
|
||||
'position' => $request->position,
|
||||
'advertisement_id' => $request->advertisement_id,
|
||||
],
|
||||
[
|
||||
'sort_order' => $newSortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Advertisement assigned successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新排序廣告播放順序
|
||||
*/
|
||||
public function reorderAssignments(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'assignment_ids' => 'required|array',
|
||||
'assignment_ids.*' => 'exists:machine_advertisements,id'
|
||||
]);
|
||||
|
||||
foreach ($request->assignment_ids as $index => $id) {
|
||||
MachineAdvertisement::where('id', $id)->update(['sort_order' => $index]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Order updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除廣告投放
|
||||
*/
|
||||
public function removeAssignment($id)
|
||||
{
|
||||
$assignment = MachineAdvertisement::findOrFail($id);
|
||||
$assignment->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Assignment removed successfully.')
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@ class MachinePhotoController extends Controller
|
||||
'machine_id' => $machine->id,
|
||||
'files' => $request->allFiles()
|
||||
]);
|
||||
|
||||
$request->validate([
|
||||
'machine_image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'machine_image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'machine_image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$images = $machine->images ?? [];
|
||||
|
||||
@@ -25,7 +25,7 @@ class MachineSettingController extends AdminController
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$tab = $request->input('tab', 'machines');
|
||||
$per_page = $request->input('per_page', 20);
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$search = $request->input('search');
|
||||
|
||||
// 1. 處理機台清單 (Machines Tab)
|
||||
@@ -36,16 +36,37 @@ class MachineSettingController extends AdminController
|
||||
->orWhere('serial_no', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
$machines = $machineQuery->latest()->paginate($per_page, ['*'], 'machines_page')->withQueryString();
|
||||
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 2. 處理型號清單 (Models Tab)
|
||||
$modelQuery = MachineModel::query()->withCount('machines');
|
||||
if ($tab === 'models' && $search) {
|
||||
$modelQuery->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
$models_list = $modelQuery->latest()->paginate($per_page, ['*'], 'models_page')->withQueryString();
|
||||
$models_list = $modelQuery->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 3. 基礎下拉資料 (用於新增/編輯機台的彈窗)
|
||||
// 3. 處理機台權限 (Permissions Tab) - 僅顯示 is_admin 帳號
|
||||
$users_list = null;
|
||||
if ($tab === 'permissions') {
|
||||
$userQuery = \App\Models\System\User::query()
|
||||
->where('is_admin', true)
|
||||
->with(['company', 'machines']);
|
||||
|
||||
if ($search) {
|
||||
$userQuery->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
$userQuery->where('company_id', $request->company_id);
|
||||
}
|
||||
|
||||
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
||||
}
|
||||
|
||||
// 4. 基礎下拉資料 (用於新增/編輯機台的彈窗)
|
||||
$models = MachineModel::select('id', 'name')->get();
|
||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||
@@ -53,6 +74,7 @@ class MachineSettingController extends AdminController
|
||||
return view('admin.basic-settings.machines.index', compact(
|
||||
'machines',
|
||||
'models_list',
|
||||
'users_list',
|
||||
'models',
|
||||
'paymentConfigs',
|
||||
'companies',
|
||||
@@ -68,11 +90,11 @@ class MachineSettingController extends AdminController
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'serial_no' => 'required|string|unique:machines,serial_no',
|
||||
'company_id' => 'required|exists:companies,id',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
'images.*' => 'image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
]);
|
||||
|
||||
$imagePaths = [];
|
||||
@@ -83,7 +105,7 @@ 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(),
|
||||
'card_reader_seconds' => 30, // 預設值
|
||||
@@ -119,6 +141,7 @@ class MachineSettingController extends AdminController
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'serial_no' => 'sometimes|required|string|unique:machines,serial_no,' . $machine->id,
|
||||
'card_reader_seconds' => 'required|integer|min:0',
|
||||
'payment_buffer_seconds' => 'required|integer|min:0',
|
||||
'card_reader_checkout_time_1' => 'nullable|string',
|
||||
@@ -139,6 +162,12 @@ class MachineSettingController extends AdminController
|
||||
'machine_model_id' => 'required|exists:machine_models,id',
|
||||
'payment_config_id' => 'nullable|exists:payment_configs,id',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
|
||||
'remove_image_0' => 'nullable|boolean',
|
||||
'remove_image_1' => 'nullable|boolean',
|
||||
'remove_image_2' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 僅限系統管理員可修改公司
|
||||
@@ -154,37 +183,71 @@ class MachineSettingController extends AdminController
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$machine->update(array_merge($validated, [
|
||||
// 排除虛擬欄位 (圖片上傳、移除標記),這些欄位不在資料表內
|
||||
$dataToUpdate = \Illuminate\Support\Arr::except($validated, [
|
||||
'image_0', 'image_1', 'image_2',
|
||||
'remove_image_0', 'remove_image_1', 'remove_image_2'
|
||||
]);
|
||||
|
||||
$machine->update(array_merge($dataToUpdate, [
|
||||
'updater_id' => auth()->id(),
|
||||
]));
|
||||
|
||||
// 處理圖片更新 (支援 3 個獨立槽位)
|
||||
if ($request->hasFile('images')) {
|
||||
$currentImages = $machine->images ?? [];
|
||||
$newImages = $request->file('images');
|
||||
$updated = false;
|
||||
// 處理圖片更新 (支援 3 個獨立槽位: image_0, image_1, image_2)
|
||||
$currentImages = $machine->images ?? [];
|
||||
$updated = false;
|
||||
|
||||
foreach ($newImages as $index => $file) {
|
||||
// 限制 3 個槽位 (0, 1, 2)
|
||||
if ($index < 0 || $index > 2) continue;
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$inputName = "image_$i";
|
||||
$removeName = "remove_image_$i";
|
||||
|
||||
// 刪除該槽位的舊圖
|
||||
if (isset($currentImages[$index]) && !empty($currentImages[$index])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$index]);
|
||||
// 如果有新圖片上傳
|
||||
if ($request->hasFile($inputName)) {
|
||||
// 刪除舊圖
|
||||
if (isset($currentImages[$i]) && !empty($currentImages[$i])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]);
|
||||
}
|
||||
|
||||
// 處理並儲存新圖
|
||||
$currentImages[$index] = $this->storeAsWebp($file, 'machines');
|
||||
// 儲存新圖
|
||||
$currentImages[$i] = $this->storeAsWebp($request->file($inputName), 'machines');
|
||||
$updated = true;
|
||||
}
|
||||
// 否則,如果有刪除標記
|
||||
elseif ($request->input($removeName) === '1') {
|
||||
if (isset($currentImages[$i]) && !empty($currentImages[$i])) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]);
|
||||
unset($currentImages[$i]);
|
||||
$updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
ksort($currentImages);
|
||||
$machine->update(['images' => array_values($currentImages)]);
|
||||
}
|
||||
if ($updated) {
|
||||
ksort($currentImages);
|
||||
$machine->update(['images' => array_values($currentImages)]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.basic-settings.machines.index')
|
||||
->with('success', __('Machine settings updated successfully.'));
|
||||
}
|
||||
|
||||
public function regenerateToken(Request $request, $serial): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$machine = Machine::where('serial_no', $serial)->firstOrFail();
|
||||
$newToken = \Illuminate\Support\Str::random(60);
|
||||
$machine->update(['api_token' => $newToken]);
|
||||
|
||||
Log::info('Machine API Token Regenerated', [
|
||||
'machine_id' => $machine->id,
|
||||
'serial_no' => $machine->serial_no,
|
||||
'user_id' => auth()->id()
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('API Token regenerated successfully.'),
|
||||
'api_token' => $newToken
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
@@ -48,13 +49,20 @@ class CompanyController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code',
|
||||
'original_type' => 'required|string|in:buyout,lease',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'valid_until' => 'nullable|date',
|
||||
'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',
|
||||
// 帳號相關欄位 (可選)
|
||||
'admin_username' => 'nullable|string|max:255|unique:users,username',
|
||||
'admin_password' => 'nullable|string|min:8',
|
||||
@@ -62,17 +70,44 @@ class CompanyController extends Controller
|
||||
'admin_role' => 'nullable|string|exists:roles,name',
|
||||
]);
|
||||
|
||||
// 確保 settings 中的值為布林值
|
||||
if (isset($validated['settings'])) {
|
||||
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($validated) {
|
||||
$company = Company::create([
|
||||
'name' => $validated['name'],
|
||||
'code' => $validated['code'],
|
||||
'original_type' => $validated['original_type'],
|
||||
'current_type' => $validated['original_type'], // 新增時同步
|
||||
'tax_id' => $validated['tax_id'] ?? null,
|
||||
'contact_name' => $validated['contact_name'] ?? null,
|
||||
'contact_phone' => $validated['contact_phone'] ?? null,
|
||||
'contact_email' => $validated['contact_email'] ?? null,
|
||||
'valid_until' => $validated['valid_until'] ?? 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(),
|
||||
]);
|
||||
|
||||
// 如果有填寫帳號資訊,則建立管理員帳號
|
||||
@@ -123,20 +158,70 @@ class CompanyController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
|
||||
'current_type' => 'required|string|in:buyout,lease',
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'contact_phone' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
'valid_until' => 'nullable|date',
|
||||
'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',
|
||||
]);
|
||||
|
||||
$company->update($validated);
|
||||
// 確保 settings 中的值為布林值,避免 JSON 存儲為字串導致前端判斷錯誤
|
||||
if (isset($validated['settings'])) {
|
||||
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
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) {
|
||||
$company->users()->update(['status' => 0]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Customer updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換客戶狀態
|
||||
*/
|
||||
public function toggleStatus(Company $company)
|
||||
{
|
||||
$newStatus = $company->status == 1 ? 0 : 1;
|
||||
$company->update(['status' => $newStatus]);
|
||||
|
||||
// 若切換為停用,同步更新所有旗下帳號
|
||||
if ($newStatus == 0) {
|
||||
$company->users()->update(['status' => 0]);
|
||||
return redirect()->back()->with('success', __('Customer and associated accounts disabled successfully.'));
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Customer enabled successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
@@ -146,6 +231,11 @@ class CompanyController extends Controller
|
||||
return redirect()->back()->with('error', __('Cannot delete company with active accounts.'));
|
||||
}
|
||||
|
||||
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重新命名唯一欄位
|
||||
$timestamp = now()->getTimestamp();
|
||||
$company->code = $company->code . '.deleted.' . $timestamp;
|
||||
$company->save();
|
||||
|
||||
$company->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Customer deleted successfully.'));
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -25,15 +25,6 @@ class DataConfigController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// 管理者可賣商品
|
||||
public function adminProducts()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '商品狀態',
|
||||
'description' => '管理者商品銷售權限',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
// 子帳號管理
|
||||
public function subAccounts()
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Machine;
|
||||
|
||||
use App\Http\Controllers\Admin\AdminController;
|
||||
use App\Models\System\Company;
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\System\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MachinePermissionController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示機台權限管理列表
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$search = $request->input('search');
|
||||
$company_id = $request->input('company_id');
|
||||
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 僅列出租戶中具有「is_admin」標記的角色帳號以供分配
|
||||
$userQuery = User::query()
|
||||
->with(['machines' => function($query) {
|
||||
$query->withoutGlobalScope('machine_access')
|
||||
->select('machines.id', 'machines.name', 'machines.serial_no');
|
||||
}])
|
||||
->whereNotNull('company_id');
|
||||
|
||||
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入)
|
||||
if (!$currentUser->isSystemAdmin()) {
|
||||
$userQuery->where('company_id', $currentUser->company_id);
|
||||
} elseif ($company_id) {
|
||||
// 系統管理員的篩選邏輯
|
||||
$userQuery->where('company_id', $company_id);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$userQuery->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
||||
$companies = $currentUser->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
return view('admin.machines.permissions', compact('users_list', 'companies'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 取得特定帳號的機台分配狀態
|
||||
*/
|
||||
public function getAccountMachines(User $user): JsonResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示)
|
||||
$machines = Machine::withoutGlobalScope('machine_access')
|
||||
->where('company_id', $user->company_id)
|
||||
->get(['id', 'name', 'serial_no']);
|
||||
|
||||
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
'machines' => $machines,
|
||||
'assigned_ids' => $assignedIds
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 儲存特定帳號的機台分配
|
||||
*/
|
||||
public function syncAccountMachines(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'machine_ids' => 'nullable|array',
|
||||
'machine_ids.*' => 'exists:machines,id'
|
||||
]);
|
||||
|
||||
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司 (使用 withoutGlobalScope 避免管理員自身權限影響驗證邏輯)
|
||||
if ($request->has('machine_ids')) {
|
||||
$machineIds = array_unique($request->machine_ids);
|
||||
$validCount = Machine::withoutGlobalScope('machine_access')
|
||||
->where('company_id', $user->company_id)
|
||||
->whereIn('id', $machineIds)
|
||||
->count();
|
||||
|
||||
if ($validCount !== count($machineIds)) {
|
||||
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$user->machines()->sync($request->machine_ids ?? []);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Permissions updated successfully'),
|
||||
'assigned_machines' => $user->machines()->select('machines.id', 'machines.name', 'machines.serial_no')->get()
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,56 +3,52 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Services\Machine\MachineService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MachineController extends AdminController
|
||||
{
|
||||
/**
|
||||
* 顯示所有機台列表或效期管理
|
||||
*/
|
||||
public function __construct(protected MachineService $machineService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$tab = $request->input('tab', 'list');
|
||||
$per_page = $tab === 'list' ? $request->input('per_page', 10) : $request->input('per_page', 12);
|
||||
|
||||
$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}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($tab === 'list') {
|
||||
$machines = $query->when($request->status, function ($query, $status) {
|
||||
return $query->where('status', $status);
|
||||
})
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
// 預加載統計資料
|
||||
$machines = $query->orderBy("last_heartbeat_at", "desc")
|
||||
->orderBy("id", "desc")
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.machines.index', compact('machines', 'tab'));
|
||||
} else {
|
||||
// 效期管理模式:獲取機台及其貨道統計
|
||||
$machines = $query->withCount(['slots as total_slots'])
|
||||
->withCount(['slots as expired_count' => function ($q) {
|
||||
$q->where('expiry_date', '<', now()->toDateString());
|
||||
}])
|
||||
->withCount(['slots as pending_count' => function ($q) {
|
||||
$q->whereNull('expiry_date');
|
||||
}])
|
||||
->withCount(['slots as warning_count' => function ($q) {
|
||||
$q->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(7)->toDateString()]);
|
||||
}])
|
||||
->latest()
|
||||
->paginate($per_page)
|
||||
->withQueryString();
|
||||
return view('admin.machines.index', compact('machines'));
|
||||
}
|
||||
|
||||
return view('admin.machines.index', compact('machines', 'tab'));
|
||||
}
|
||||
/**
|
||||
* 更新機台基本資訊 (目前僅名稱)
|
||||
*/
|
||||
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.'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,9 +56,11 @@ class MachineController extends AdminController
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
@@ -74,16 +72,20 @@ class MachineController extends AdminController
|
||||
public function logsAjax(Request $request, Machine $machine)
|
||||
{
|
||||
$per_page = $request->input('per_page', 20);
|
||||
|
||||
$startDate = $request->get('start_date', now()->format('Y-m-d'));
|
||||
$endDate = $request->get('end_date', now()->format('Y-m-d'));
|
||||
|
||||
$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);
|
||||
})
|
||||
@@ -102,69 +104,6 @@ class MachineController extends AdminController
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* AJAX: 取得特定帳號的機台分配狀態
|
||||
*/
|
||||
public function getAccountMachines(\App\Models\System\User $user)
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// 取得該公司所有機台 (限定 company_id 以實作資料隔離)
|
||||
$machines = Machine::where('company_id', $user->company_id)
|
||||
->get(['id', 'name', 'serial_no']);
|
||||
|
||||
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
'machines' => $machines,
|
||||
'assigned_ids' => $assignedIds
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 儲存特定帳號的機台分配
|
||||
*/
|
||||
public function syncAccountMachines(Request $request, \App\Models\System\User $user)
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 安全檢查
|
||||
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'machine_ids' => 'nullable|array',
|
||||
'machine_ids.*' => 'exists:machines,id'
|
||||
]);
|
||||
|
||||
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司
|
||||
if ($request->has('machine_ids')) {
|
||||
$machineIds = array_unique($request->machine_ids);
|
||||
$validCount = Machine::where('company_id', $user->company_id)
|
||||
->whereIn('id', $machineIds)
|
||||
->count();
|
||||
|
||||
if ($validCount !== count($machineIds)) {
|
||||
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$user->machines()->sync($request->machine_ids ?? []);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Permissions updated successfully.'),
|
||||
'assigned_machines' => $user->machines()->select('machines.id', 'machines.name', 'machines.serial_no')->get()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 機台使用率統計
|
||||
*/
|
||||
@@ -172,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);
|
||||
@@ -194,8 +133,8 @@ class MachineController extends AdminController
|
||||
*/
|
||||
public function slotsAjax(Machine $machine)
|
||||
{
|
||||
$slots = $machine->slots()->with('product:id,name,image')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')->get();
|
||||
|
||||
$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']),
|
||||
@@ -204,32 +143,24 @@ class MachineController extends AdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 更新貨道效期
|
||||
* AJAX: 更新貨道資訊 (庫存、效期、批號)
|
||||
*/
|
||||
public function updateSlotExpiry(Request $request, Machine $machine)
|
||||
{
|
||||
$request->validate([
|
||||
$validated = $request->validate([
|
||||
'slot_no' => 'required|integer',
|
||||
'stock' => 'nullable|integer|min:0',
|
||||
'expiry_date' => 'nullable|date',
|
||||
'apply_all_same_product' => 'boolean'
|
||||
'batch_no' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$slotNo = $request->slot_no;
|
||||
$expiryDate = $request->expiry_date;
|
||||
$applyAll = $request->apply_all_same_product ?? false;
|
||||
$this->machineService->updateSlot($machine, $validated, auth()->id());
|
||||
|
||||
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail();
|
||||
$slot->update(['expiry_date' => $expiryDate]);
|
||||
|
||||
if ($applyAll && $slot->product_id) {
|
||||
$machine->slots()
|
||||
->where('product_id', $slot->product_id)
|
||||
->update(['expiry_date' => $expiryDate]);
|
||||
}
|
||||
session()->flash('success', __('Slot updated successfully.'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Expiry updated successfully.')
|
||||
'message' => __('Slot updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class MaintenanceController extends Controller
|
||||
$this->authorize('viewAny', MaintenanceRecord::class);
|
||||
|
||||
$query = MaintenanceRecord::with(['machine', 'user', 'company'])
|
||||
->whereHas('machine') // 確保僅顯示該帳號「看得見」的機台紀錄,避開因權限隔離導致的 null 報錯
|
||||
->latest('maintenance_at');
|
||||
|
||||
// 搜尋邏輯
|
||||
@@ -73,6 +74,7 @@ class MaintenanceController extends Controller
|
||||
'content' => 'nullable|string',
|
||||
'maintenance_at' => 'required|date',
|
||||
'photos.*' => 'nullable|image|max:5120', // 每張上限 5MB
|
||||
'is_confirmed' => 'required|accepted',
|
||||
]);
|
||||
|
||||
$machine = Machine::findOrFail($validated['machine_id']);
|
||||
@@ -97,6 +99,7 @@ class MaintenanceController extends Controller
|
||||
'content' => $validated['content'],
|
||||
'photos' => $photoPaths,
|
||||
'maintenance_at' => $validated['maintenance_at'],
|
||||
'is_confirmed' => true, // 既然通過驗證(accepted),則存為 true
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.maintenance.index')
|
||||
|
||||
@@ -24,10 +24,10 @@ class PermissionController extends Controller
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// 篩選:所屬單位 (僅限系統管理員)
|
||||
// 篩選:公司名稱 (僅限系統管理員)
|
||||
if ($user->isSystemAdmin() && request()->filled('company_id')) {
|
||||
if (request()->company_id === 'system') {
|
||||
$query->where('is_system', true);
|
||||
$query->whereNull('company_id');
|
||||
} else {
|
||||
$query->where('company_id', request()->company_id);
|
||||
}
|
||||
@@ -36,7 +36,9 @@ class PermissionController extends Controller
|
||||
$roles = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
|
||||
// 權限遞迴約束:租戶管理員只能看到並指派自己擁有的權限
|
||||
// 權限分組邏輯中的標題與過濾
|
||||
$isSubAccountRoles = request()->routeIs('*.sub-account-roles');
|
||||
$title = $isSubAccountRoles ? __('Sub Account Roles') : __('Role Settings');
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
@@ -44,17 +46,13 @@ class PermissionController extends Controller
|
||||
|
||||
// 權限分組邏輯
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
// 主選單權限:menu.xxx (兩段)
|
||||
// 子選單權限:menu.xxx.yyy (三段)
|
||||
return 'menu';
|
||||
}
|
||||
return 'other';
|
||||
});
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-account-roles') ? __('Sub Account Roles') : __('Role Settings');
|
||||
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
return view('admin.permission.roles', compact('roles', 'all_permissions', 'title', 'currentUserRoleIds', 'companies'));
|
||||
@@ -77,7 +75,9 @@ class PermissionController extends Controller
|
||||
$all_permissions = $permissionQuery->get()->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
|
||||
|
||||
$title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role');
|
||||
$back_url = request()->routeIs('*.sub-account-roles.create') ? route('admin.data-config.sub-account-roles') : route('admin.permission.roles');
|
||||
$back_url = request()->routeIs('*.sub-account-roles.create')
|
||||
? route('admin.data-config.sub-accounts', ['tab' => 'roles'])
|
||||
: route('admin.permission.roles');
|
||||
|
||||
return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url'));
|
||||
}
|
||||
@@ -98,6 +98,7 @@ class PermissionController extends Controller
|
||||
|
||||
// 權限分組邏輯
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(function($perm) {
|
||||
if (str_starts_with($perm->name, 'menu.')) {
|
||||
return 'menu';
|
||||
@@ -109,7 +110,9 @@ class PermissionController extends Controller
|
||||
$title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions');
|
||||
|
||||
// 麵包屑/返回路徑
|
||||
$back_url = request()->routeIs('*.sub-account-roles.edit') ? route('admin.data-config.sub-account-roles') : route('admin.permission.roles');
|
||||
$back_url = request()->routeIs('*.sub-account-roles.edit')
|
||||
? route('admin.data-config.sub-accounts', ['tab' => 'roles'])
|
||||
: route('admin.permission.roles');
|
||||
|
||||
return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url'));
|
||||
}
|
||||
@@ -158,8 +161,8 @@ class PermissionController extends Controller
|
||||
$role->syncPermissions($perms);
|
||||
}
|
||||
|
||||
$target_route = request()->routeIs('*.sub-account-roles.*') ? 'admin.data-config.sub-account-roles' : 'admin.permission.roles';
|
||||
return redirect()->route($target_route)->with('success', __('Role created successfully.'));
|
||||
$target_route = request()->routeIs('*.sub-account-roles.*') ? route('admin.data-config.sub-accounts', ['tab' => 'roles']) : route('admin.permission.roles');
|
||||
return redirect()->to($target_route)->with('success', __('Role created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,11 +198,13 @@ class PermissionController extends Controller
|
||||
|
||||
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
|
||||
|
||||
$role->update([
|
||||
$updateData = [
|
||||
'name' => $validated['name'],
|
||||
'is_system' => $is_system,
|
||||
'company_id' => $is_system ? null : $role->company_id,
|
||||
]);
|
||||
];
|
||||
|
||||
$role->update($updateData);
|
||||
|
||||
$perms = $validated['permissions'] ?? [];
|
||||
|
||||
@@ -217,8 +222,8 @@ class PermissionController extends Controller
|
||||
}
|
||||
$role->syncPermissions($perms);
|
||||
|
||||
$target_route = request()->routeIs('*.sub-account-roles.*') ? 'admin.data-config.sub-account-roles' : 'admin.permission.roles';
|
||||
return redirect()->route($target_route)->with('success', __('Role updated successfully.'));
|
||||
$target_route = request()->routeIs('*.sub-account-roles.*') ? route('admin.data-config.sub-accounts', ['tab' => 'roles']) : route('admin.permission.roles');
|
||||
return redirect()->to($target_route)->with('success', __('Role updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,46 +247,99 @@ class PermissionController extends Controller
|
||||
|
||||
$role->delete();
|
||||
|
||||
if (request()->routeIs('*.sub-account-roles.*')) {
|
||||
return redirect()->route('admin.data-config.sub-accounts', ['tab' => 'roles'])->with('success', __('Role deleted successfully.'));
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Role deleted successfully.'));
|
||||
}
|
||||
|
||||
// 帳號管理
|
||||
public function accounts(Request $request)
|
||||
{
|
||||
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
|
||||
$user = auth()->user();
|
||||
$isSubAccountRoute = $request->routeIs('admin.data-config.sub-accounts');
|
||||
$tab = $request->input('tab', 'accounts');
|
||||
|
||||
// 租戶隔離:如果不是系統管理員,則只看自己公司的成員
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$query->where('company_id', auth()->user()->company_id);
|
||||
// 初始化變數
|
||||
$users = collect();
|
||||
$roles = collect();
|
||||
$paginated_roles = collect();
|
||||
$all_permissions = collect();
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
|
||||
if ($isSubAccountRoute && $tab === 'roles') {
|
||||
// 處理角色分頁邏輯 (移植自 roles())
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$roles_query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$roles_query->where('company_id', $user->company_id);
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$roles_query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('company_id')) {
|
||||
if ($request->company_id === 'system') {
|
||||
$roles_query->where('is_system', true);
|
||||
} else {
|
||||
$roles_query->where('company_id', $request->company_id);
|
||||
}
|
||||
}
|
||||
|
||||
$paginated_roles = $roles_query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
// 權限分組邏輯
|
||||
$permissionQuery = \Spatie\Permission\Models\Permission::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
|
||||
}
|
||||
|
||||
$all_permissions = $permissionQuery->get()
|
||||
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
|
||||
->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
|
||||
} else {
|
||||
// 處理帳號名單邏輯
|
||||
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
|
||||
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$query->where('company_id', $user->company_id);
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('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);
|
||||
$users = $query->latest()->paginate($per_page)->withQueryString();
|
||||
|
||||
$roles_query = \App\Models\System\Role::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$roles_query->forCompany($user->company_id);
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
}
|
||||
|
||||
// 搜尋
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
$title = $isSubAccountRoute ? __('Sub Account Management') : __('Account Management');
|
||||
|
||||
// 公司篩選 (僅限 super-admin)
|
||||
if (auth()->user()->isSystemAdmin() && $request->filled('company_id')) {
|
||||
$query->where('company_id', $request->company_id);
|
||||
}
|
||||
|
||||
$per_page = $request->input('per_page', 10);
|
||||
$users = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$companies = auth()->user()->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
|
||||
$roles_query = \App\Models\System\Role::query();
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$roles_query->forCompany(auth()->user()->company_id);
|
||||
}
|
||||
$roles = $roles_query->get();
|
||||
|
||||
// 根據路由決定標題
|
||||
$title = request()->routeIs('*.sub-accounts') ? __('Sub Account Management') : __('Account Management');
|
||||
|
||||
return view('admin.data-config.accounts', compact('users', 'companies', 'roles', 'title'));
|
||||
return view('admin.data-config.accounts', compact(
|
||||
'users', 'companies', 'roles', 'paginated_roles', 'all_permissions', 'title', 'tab', 'currentUserRoleIds'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,8 +373,8 @@ class PermissionController extends Controller
|
||||
|
||||
// 驗證角色與公司的匹配性 (RBAC Safeguard)
|
||||
if ($company_id !== null) {
|
||||
// 如果是租戶帳號,不能選超級管理員角色
|
||||
if ($role->is_system && $role->name === 'super-admin') {
|
||||
// 如果是租戶帳號,絕對不能指派超級管理員角色 (super-admin)
|
||||
if ($role->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.'));
|
||||
}
|
||||
// 如果角色有特定的 company_id,必須匹配
|
||||
@@ -324,7 +382,7 @@ class PermissionController extends Controller
|
||||
return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.'));
|
||||
}
|
||||
} else {
|
||||
// 如果是系統層級帳號,只能選系統角色 (is_system = 1)
|
||||
// 如果是系統層級帳號,只能選全域系統角色 (is_system = 1)
|
||||
if (!$role->is_system) {
|
||||
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
|
||||
}
|
||||
@@ -363,6 +421,7 @@ class PermissionController extends Controller
|
||||
'status' => $validated['status'],
|
||||
'company_id' => $company_id,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
|
||||
]);
|
||||
|
||||
$user->assignRole($role);
|
||||
@@ -377,8 +436,8 @@ class PermissionController extends Controller
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts cannot be modified via this interface.'));
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts can only be modified by other super admins.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
@@ -408,7 +467,8 @@ class PermissionController extends Controller
|
||||
// 驗證角色與公司的匹配性 (RBAC Safeguard)
|
||||
if ($user->id !== auth()->id()) { // 排除編輯自己 (super-admin 有特殊邏輯)
|
||||
if ($target_company_id !== null) {
|
||||
if ($roleObj->is_system && $roleObj->name === 'super-admin') {
|
||||
// 租戶層級排除 super-admin
|
||||
if ($roleObj->name === 'super-admin') {
|
||||
return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.'));
|
||||
}
|
||||
if ($roleObj->company_id !== null && $roleObj->company_id != $target_company_id) {
|
||||
@@ -416,7 +476,7 @@ class PermissionController extends Controller
|
||||
}
|
||||
} else {
|
||||
if (!$roleObj->is_system) {
|
||||
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
|
||||
return redirect()->back()->with('error', __('Only global system roles can be assigned to platform administrative accounts.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,6 +489,18 @@ class PermissionController extends Controller
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
];
|
||||
|
||||
// 只有系統管理員在編輯租戶帳號時,且該帳號原本不是管理員,才可能觸發標記(視需求而定)
|
||||
// 這裡我們維持 storeAccount 的邏輯:如果是系統管理員幫公司「開站」或「首配」,才自動標記
|
||||
// 為求嚴謹,我們檢查該公司是否已經有 is_admin,如果沒有,當前這個人可以是第一個
|
||||
if (auth()->user()->isSystemAdmin() && !empty($validated['company_id']) && !$user->is_admin) {
|
||||
$hasAdmin = \App\Models\System\User::where('company_id', $validated['company_id'])
|
||||
->where('is_admin', true)
|
||||
->exists();
|
||||
if (!$hasAdmin) {
|
||||
$updateData['is_admin'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (auth()->user()->isSystemAdmin()) {
|
||||
// 防止超級管理員不小心把自己綁定到租客公司或降級
|
||||
if ($user->id === auth()->id()) {
|
||||
@@ -458,6 +530,7 @@ class PermissionController extends Controller
|
||||
'guard_name' => 'web',
|
||||
'company_id' => $target_company_id,
|
||||
'is_system' => false,
|
||||
'is_admin' => true,
|
||||
]);
|
||||
$newRole->syncPermissions($roleObj->getPermissionNames());
|
||||
$roleObj = $newRole;
|
||||
@@ -485,8 +558,8 @@ class PermissionController extends Controller
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
if ($user->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts cannot be deleted.'));
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.'));
|
||||
}
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
@@ -503,4 +576,20 @@ class PermissionController extends Controller
|
||||
|
||||
return redirect()->back()->with('success', __('Account deleted successfully.'));
|
||||
}
|
||||
|
||||
public function toggleAccountStatus($id)
|
||||
{
|
||||
$user = \App\Models\System\User::findOrFail($id);
|
||||
|
||||
// 非超級管理員禁止切換 Super Admin 狀態
|
||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
||||
return back()->with('error', __('Only Super Admins can change other Super Admin status.'));
|
||||
}
|
||||
|
||||
$user->status = $user->status ? 0 : 1;
|
||||
$user->save();
|
||||
|
||||
$statusText = $user->status ? __('Enabled') : __('Disabled');
|
||||
return back()->with('success', __('Account :name status has been changed to :status.', ['name' => $user->name, 'status' => $statusText]));
|
||||
}
|
||||
}
|
||||
|
||||
167
app/Http/Controllers/Admin/ProductCategoryController.php
Normal file
167
app/Http/Controllers/Admin/ProductCategoryController.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product\ProductCategory;
|
||||
use App\Models\System\Translation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示商品分類清單 (主要用於 AJAX 或內嵌在商品管理頁面)
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$query = ProductCategory::with(['translations']);
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('company_id')) {
|
||||
$query->where('company_id', $request->company_id);
|
||||
}
|
||||
|
||||
$categories = $query->latest()->get();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $categories
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.products.index', ['tab' => 'categories']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存新分類
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = Str::uuid()->toString();
|
||||
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
|
||||
? $request->company_id
|
||||
: auth()->user()->company_id;
|
||||
|
||||
// 儲存多語系翻譯
|
||||
foreach ($request->names as $locale => $value) {
|
||||
if (empty($value)) continue;
|
||||
Translation::withoutGlobalScopes()->create([
|
||||
'group' => 'category',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
'value' => $value,
|
||||
'company_id' => $company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
$category = ProductCategory::create([
|
||||
'company_id' => $company_id,
|
||||
'name' => $request->names['zh_TW'] ?? (collect($request->names)->first() ?? 'Untitled'),
|
||||
'name_dictionary_key' => $dictKey,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->back()->with('success', __('Category created successfully'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return redirect()->back()->with('error', $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分類
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$category = ProductCategory::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = $category->name_dictionary_key ?: Str::uuid()->toString();
|
||||
$company_id = $category->company_id;
|
||||
|
||||
foreach ($request->names as $locale => $value) {
|
||||
if (empty($value)) {
|
||||
Translation::withoutGlobalScopes()->where([
|
||||
'group' => 'category',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale
|
||||
])->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
Translation::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'group' => 'category',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
],
|
||||
[
|
||||
'value' => $value,
|
||||
'company_id' => $company_id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$category->update([
|
||||
'name' => $request->names['zh_TW'] ?? $category->name,
|
||||
'name_dictionary_key' => $dictKey,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->back()->with('success', __('Category updated successfully'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return redirect()->back()->with('error', $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除分類
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
try {
|
||||
$category = ProductCategory::findOrFail($id);
|
||||
|
||||
// 檢查是否已有商品使用此分類
|
||||
if ($category->products()->count() > 0) {
|
||||
return redirect()->back()->with('error', __('Cannot delete category that has products. Please move products first.'));
|
||||
}
|
||||
|
||||
if ($category->name_dictionary_key) {
|
||||
Translation::withoutGlobalScopes()->where('group', 'category')->where('key', $category->name_dictionary_key)->delete();
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Category deleted successfully'));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
345
app/Http/Controllers/Admin/ProductController.php
Normal file
345
app/Http/Controllers/Admin/ProductController.php
Normal file
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\Product\ProductCategory;
|
||||
use App\Models\System\Company;
|
||||
use App\Models\System\Translation;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
use \App\Traits\ImageHandler;
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$query = Product::with(['category.translations', 'translations', 'company']);
|
||||
|
||||
// 搜尋
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
->orWhere('spec', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 分類篩選
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$per_page = $request->input('per_page', 10);
|
||||
|
||||
$companyId = $user->company_id;
|
||||
if ($user->isSystemAdmin()) {
|
||||
if ($request->filled('company_id')) {
|
||||
$companyId = $request->company_id;
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
}
|
||||
|
||||
$products = $query->latest()->paginate($per_page)->withQueryString();
|
||||
$categories = ProductCategory::with('translations')->get();
|
||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
// 系統管理員在過濾特定公司時,應顯示該公司的功能開關 (如物料代碼、點數規則)
|
||||
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
|
||||
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
|
||||
|
||||
$routeName = 'admin.data-config.products.index';
|
||||
|
||||
return view('admin.products.index', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'companies' => $companies,
|
||||
'companySettings' => $companySettings,
|
||||
'routeName' => $routeName
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$categories = ProductCategory::with('translations')->get();
|
||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
// If system admin, check if company_id is provided in URL to get settings
|
||||
$companyId = $request->query('company_id') ?? $user->company_id;
|
||||
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
|
||||
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
|
||||
|
||||
return view('admin.products.create', [
|
||||
'categories' => $categories,
|
||||
'companies' => $companies,
|
||||
'companySettings' => $companySettings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$user = auth()->user();
|
||||
// 繞過 TenantScoped 載入翻譯,確保系統管理員能看到租戶公司的翻譯資料
|
||||
$product = Product::with(['company'])->findOrFail($id);
|
||||
$product->setRelation('translations',
|
||||
Translation::withoutGlobalScopes()
|
||||
->where('group', 'product')
|
||||
->where('key', $product->name_dictionary_key)
|
||||
->get()
|
||||
);
|
||||
$categories = ProductCategory::with('translations')->get();
|
||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||
|
||||
// Use the product's company settings for editing
|
||||
$companySettings = $product->company ? ($product->company->settings ?? []) : [];
|
||||
|
||||
return view('admin.products.edit', [
|
||||
'product' => $product,
|
||||
'categories' => $categories,
|
||||
'companies' => $companies,
|
||||
'companySettings' => $companySettings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'spec' => 'nullable|string|max:255',
|
||||
'category_id' => 'nullable|exists:product_categories,id',
|
||||
'manufacturer' => 'nullable|string|max:255',
|
||||
'track_limit' => 'required|integer|min:1',
|
||||
'spring_limit' => 'required|integer|min:1',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'cost' => 'required|numeric|min:0',
|
||||
'member_price' => 'required|numeric|min:0',
|
||||
'metadata' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = \Illuminate\Support\Str::uuid()->toString();
|
||||
// Determine company_id: prioritized from request (for sys admin) then from user
|
||||
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
|
||||
? $request->company_id
|
||||
: auth()->user()->company_id;
|
||||
|
||||
// 儲存多語系翻譯(繞過 TenantScoped,避免系統管理員操作租戶資料時被過濾)
|
||||
foreach ($request->names as $locale => $name) {
|
||||
if (empty($name)) continue;
|
||||
Translation::withoutGlobalScopes()->create([
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
'value' => $name,
|
||||
'company_id' => $company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
$imageUrl = null;
|
||||
if ($request->hasFile('image')) {
|
||||
$path = $this->storeAsWebp($request->file('image'), 'products');
|
||||
$imageUrl = Storage::url($path);
|
||||
}
|
||||
|
||||
$product = Product::create([
|
||||
'company_id' => $company_id,
|
||||
'category_id' => $request->category_id,
|
||||
'name' => $request->names['zh_TW'] ?? (collect($request->names)->first() ?? 'Untitled'), // Fallback if zh_TW is missing
|
||||
'name_dictionary_key' => $dictKey,
|
||||
'image_url' => $imageUrl,
|
||||
'barcode' => $request->barcode,
|
||||
'spec' => $request->spec,
|
||||
'manufacturer' => $request->manufacturer,
|
||||
'track_limit' => $request->track_limit,
|
||||
'spring_limit' => $request->spring_limit,
|
||||
'price' => $request->price,
|
||||
'cost' => $request->cost,
|
||||
'member_price' => $request->member_price,
|
||||
'metadata' => $request->metadata ?? [],
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Product created successfully'),
|
||||
'data' => $product
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.products.index')->with('success', __('Product created successfully'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
return redirect()->back()->with('error', $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$product = Product::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'names.zh_TW' => 'required|string|max:255',
|
||||
'names.en' => 'nullable|string|max:255',
|
||||
'names.ja' => 'nullable|string|max:255',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'spec' => 'nullable|string|max:255',
|
||||
'category_id' => 'nullable|exists:product_categories,id',
|
||||
'manufacturer' => 'nullable|string|max:255',
|
||||
'track_limit' => 'required|integer|min:1',
|
||||
'spring_limit' => 'required|integer|min:1',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'cost' => 'required|numeric|min:0',
|
||||
'member_price' => 'required|numeric|min:0',
|
||||
'metadata' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
|
||||
'remove_image' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$dictKey = $product->name_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
|
||||
$company_id = $product->company_id;
|
||||
|
||||
// 更新或建立多語系翻譯(繞過 TenantScoped,避免系統管理員操作租戶資料時被過濾)
|
||||
foreach ($request->names as $locale => $name) {
|
||||
if (empty($name)) {
|
||||
Translation::withoutGlobalScopes()->where([
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale
|
||||
])->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
Translation::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
],
|
||||
[
|
||||
'value' => $name,
|
||||
'company_id' => $company_id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'category_id' => $request->category_id,
|
||||
'name' => $request->names['zh_TW'] ?? ($product->name ?? 'Untitled'),
|
||||
'name_dictionary_key' => $dictKey,
|
||||
'barcode' => $request->barcode,
|
||||
'spec' => $request->spec,
|
||||
'manufacturer' => $request->manufacturer,
|
||||
'track_limit' => $request->track_limit,
|
||||
'spring_limit' => $request->spring_limit,
|
||||
'price' => $request->price,
|
||||
'cost' => $request->cost,
|
||||
'member_price' => $request->member_price,
|
||||
'metadata' => $request->metadata ?? [],
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
];
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
// Delete old image
|
||||
if ($product->image_url) {
|
||||
$oldPath = str_replace('/storage/', '', $product->image_url);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
$path = $this->storeAsWebp($request->file('image'), 'products');
|
||||
$data['image_url'] = Storage::url($path);
|
||||
} elseif ($request->boolean('remove_image')) {
|
||||
if ($product->image_url) {
|
||||
$oldPath = str_replace('/storage/', '', $product->image_url);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
$data['image_url'] = null;
|
||||
}
|
||||
|
||||
$product->update($data);
|
||||
|
||||
DB::commit();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Product updated successfully'),
|
||||
'data' => $product
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.data-config.products.index')->with('success', __('Product updated successfully'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
return redirect()->back()->with('error', $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleStatus($id)
|
||||
{
|
||||
try {
|
||||
$product = Product::findOrFail($id);
|
||||
$product->is_active = !$product->is_active;
|
||||
$product->save();
|
||||
|
||||
$status = $product->is_active ? __('Enabled') : __('Disabled');
|
||||
return redirect()->back()->with('success', __('Product status updated to :status', ['status' => $status]));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
try {
|
||||
$product = Product::findOrFail($id);
|
||||
|
||||
// 刪除與此商品關聯的翻譯資料(繞過 TenantScoped)
|
||||
if ($product->name_dictionary_key) {
|
||||
Translation::withoutGlobalScopes()->where('key', $product->name_dictionary_key)->delete();
|
||||
}
|
||||
|
||||
// Delete image
|
||||
if ($product->image_url) {
|
||||
$oldPath = str_replace('/storage/', '', $product->image_url);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
|
||||
$product->delete();
|
||||
|
||||
return redirect()->back()->with('success', __('Product deleted successfully'));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Admin/QrCodeController.php
Normal file
34
app/Http/Controllers/Admin/QrCodeController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
class QrCodeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Generate a QR Code image.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function generate(Request $request)
|
||||
{
|
||||
$data = $request->query('data');
|
||||
$size = $request->query('size', 250);
|
||||
|
||||
if (!$data) {
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
// Generate SVG QR Code
|
||||
$qrCode = QrCode::size($size)
|
||||
->format('svg')
|
||||
->margin(1)
|
||||
->generate($data);
|
||||
|
||||
return response($qrCode)->header('Content-Type', 'image/svg+xml');
|
||||
}
|
||||
}
|
||||
@@ -4,69 +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 stock()
|
||||
/**
|
||||
* 遠端管理指揮中心
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端修改機台庫存',
|
||||
'description' => '遠端修改機台庫存數量',
|
||||
$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 restart()
|
||||
/**
|
||||
* 儲存遠端指令
|
||||
*/
|
||||
public function storeCommand(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端重啟機台',
|
||||
'description' => '遠端重啟機台系統',
|
||||
$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 restartCardReader()
|
||||
/**
|
||||
* 機台庫存管理 (現有功能保留)
|
||||
*/
|
||||
public function stock(Request $request)
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端重啟刷卡機',
|
||||
'description' => '遠端重啟刷卡機設備',
|
||||
]);
|
||||
}
|
||||
$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('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
|
||||
|
||||
$history = RemoteCommand::where('command_type', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get();
|
||||
|
||||
// 遠端結帳
|
||||
public function checkout()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => '遠端結帳',
|
||||
'description' => '遠端執行結帳流程',
|
||||
]);
|
||||
}
|
||||
$selectedMachine = null;
|
||||
if ($request->has('machine_id')) {
|
||||
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
|
||||
$query->where('command_type', 'reload_stock')
|
||||
->latest()
|
||||
->limit(50);
|
||||
}])->find($request->machine_id);
|
||||
}
|
||||
|
||||
// 遠端鎖定頁
|
||||
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' => '遠端控制商品出貨',
|
||||
return view('admin.remote.stock', [
|
||||
'machines' => $machines,
|
||||
'selectedMachine' => $selectedMachine,
|
||||
'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);
|
||||
|
||||
18
app/Http/Controllers/ApiDocsController.php
Normal file
18
app/Http/Controllers/ApiDocsController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ApiDocsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the API documentation page.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$docs = config('api-docs');
|
||||
|
||||
return view('docs.api-docs', compact('docs'));
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class ProfileController extends Controller
|
||||
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:1024'],
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:1024'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
@@ -26,7 +26,7 @@ class EnsureTenantAccess
|
||||
return redirect()->route('login')->with('error', __('Your account is associated with a deactivated company.'));
|
||||
}
|
||||
|
||||
if ($company->valid_until && $company->valid_until->isPast()) {
|
||||
if ($company->end_date && $company->end_date->isPast()) {
|
||||
auth()->logout();
|
||||
return redirect()->route('login')->with('error', __('Your company contract has expired.'));
|
||||
}
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
|
||||
43
app/Models/Machine/MachineAdvertisement.php
Normal file
43
app/Models/Machine/MachineAdvertisement.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Machine;
|
||||
|
||||
use App\Models\System\Advertisement;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MachineAdvertisement extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'machine_id',
|
||||
'advertisement_id',
|
||||
'position',
|
||||
'sort_order',
|
||||
'start_at',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the advertisement associated with this assignment.
|
||||
*/
|
||||
public function advertisement()
|
||||
{
|
||||
return $this->belongsTo(Advertisement::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the machine associated with this assignment.
|
||||
*/
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -21,11 +21,13 @@ class MaintenanceRecord extends Model
|
||||
'content',
|
||||
'photos',
|
||||
'maintenance_at',
|
||||
'is_confirmed',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'photos' => 'array',
|
||||
'maintenance_at' => 'datetime',
|
||||
'is_confirmed' => 'boolean',
|
||||
];
|
||||
|
||||
public function machine()
|
||||
|
||||
@@ -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,26 +10,43 @@ 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',
|
||||
'sku',
|
||||
'name_dictionary_key',
|
||||
'barcode',
|
||||
'spec',
|
||||
'manufacturer',
|
||||
'description',
|
||||
'price',
|
||||
'member_price',
|
||||
'cost',
|
||||
'track_limit',
|
||||
'spring_limit',
|
||||
'type',
|
||||
'image_url',
|
||||
'status',
|
||||
'name_dictionary_key',
|
||||
'is_active',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'member_price' => 'decimal:2',
|
||||
'cost' => 'decimal:2',
|
||||
'track_limit' => 'integer',
|
||||
'spring_limit' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
@@ -37,4 +54,40 @@ class Product extends Model
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 自動附加到 JSON/陣列輸出的屬性(供 Alpine.js 等前端使用)
|
||||
*/
|
||||
protected $appends = ['localized_name'];
|
||||
|
||||
/**
|
||||
* 取得當前語系的商品名稱。
|
||||
* 回退順序:當前語系 → zh_TW → name 欄位
|
||||
*/
|
||||
public function getLocalizedNameAttribute(): string
|
||||
{
|
||||
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
|
||||
$locale = app()->getLocale();
|
||||
// 先找當前語系
|
||||
$translation = $this->translations->firstWhere('locale', $locale);
|
||||
if ($translation) {
|
||||
return $translation->value;
|
||||
}
|
||||
// 回退至 zh_TW
|
||||
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
|
||||
if ($fallback) {
|
||||
return $fallback->value;
|
||||
}
|
||||
}
|
||||
return $this->name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translations for the product name.
|
||||
*/
|
||||
public function translations()
|
||||
{
|
||||
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'name_dictionary_key')
|
||||
->where('group', 'product');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,44 @@ class ProductCategory extends Model
|
||||
'name_dictionary_key',
|
||||
];
|
||||
|
||||
/**
|
||||
* 自動附加到 JSON/陣列輸出
|
||||
*/
|
||||
protected $appends = ['localized_name'];
|
||||
|
||||
public function products()
|
||||
{
|
||||
return $this->hasMany(Product::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the translations for the category name.
|
||||
*/
|
||||
public function translations()
|
||||
{
|
||||
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'name_dictionary_key')
|
||||
->where('group', 'category');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得當前語系的商品名稱。
|
||||
* 回退順序:當前語系 → zh_TW → name 欄位
|
||||
*/
|
||||
public function getLocalizedNameAttribute(): string
|
||||
{
|
||||
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
|
||||
$locale = app()->getLocale();
|
||||
// 先找當前語系
|
||||
$translation = $this->translations->firstWhere('locale', $locale);
|
||||
if ($translation) {
|
||||
return $translation->value;
|
||||
}
|
||||
// 回退至 zh_TW
|
||||
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
|
||||
if ($fallback) {
|
||||
return $fallback->value;
|
||||
}
|
||||
}
|
||||
return $this->name ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
72
app/Models/System/Advertisement.php
Normal file
72
app/Models/System/Advertisement.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use App\Models\Machine\MachineAdvertisement;
|
||||
use App\Traits\TenantScoped;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Advertisement extends Model
|
||||
{
|
||||
use HasFactory, TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'name',
|
||||
'type',
|
||||
'duration',
|
||||
'url',
|
||||
'is_active',
|
||||
'start_at',
|
||||
'end_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'duration' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the machine assignments for this advertisement.
|
||||
*/
|
||||
public function machineAdvertisements()
|
||||
{
|
||||
return $this->hasMany(MachineAdvertisement::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the company that owns the advertisement.
|
||||
*/
|
||||
public function company()
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to only include active advertisements.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,20 +16,42 @@ class Company extends Model
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'original_type',
|
||||
'current_type',
|
||||
'tax_id',
|
||||
'contact_name',
|
||||
'contact_phone',
|
||||
'contact_email',
|
||||
'status',
|
||||
'valid_until',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'warranty_start_date',
|
||||
'warranty_end_date',
|
||||
'software_start_date',
|
||||
'software_end_date',
|
||||
'note',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'valid_until' => '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');
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,10 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Translation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, \App\Traits\TenantScoped;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'group',
|
||||
'key',
|
||||
'locale',
|
||||
|
||||
@@ -31,6 +31,7 @@ class User extends Authenticatable
|
||||
'avatar',
|
||||
'role',
|
||||
'status',
|
||||
'is_admin',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -51,6 +52,7 @@ class User extends Authenticatable
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -21,15 +72,14 @@ class MachineService
|
||||
return DB::transaction(function () use ($serialNo, $data) {
|
||||
$machine = Machine::where('serial_no', $serialNo)->firstOrFail();
|
||||
|
||||
// 參數相容性處理 (Mapping legacy fields to new fields)
|
||||
// 採用現代化語意命名 (Modern semantic naming)
|
||||
$temperature = $data['temperature'] ?? $machine->temperature;
|
||||
$currentPage = $data['current_page'] ?? $data['M_Stus2'] ?? $machine->current_page;
|
||||
$doorStatus = $data['door_status'] ?? $data['door'] ?? $machine->door_status;
|
||||
$firmwareVersion = $data['firmware_version'] ?? $data['M_Ver'] ?? $machine->firmware_version;
|
||||
$model = $data['model'] ?? $data['M_Stus'] ?? $machine->model;
|
||||
$currentPage = $data['current_page'] ?? $machine->current_page;
|
||||
$doorStatus = $data['door_status'] ?? $machine->door_status;
|
||||
$firmwareVersion = $data['firmware_version'] ?? $machine->firmware_version;
|
||||
$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 {
|
||||
@@ -91,6 +167,97 @@ class MachineService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine slot stock, expiry, and batch.
|
||||
*
|
||||
* @param Machine $machine
|
||||
* @param array $data
|
||||
* @param int|null $userId
|
||||
* @return void
|
||||
*/
|
||||
public function updateSlot(Machine $machine, array $data, ?int $userId = null): void
|
||||
{
|
||||
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,
|
||||
];
|
||||
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).
|
||||
@@ -114,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'],
|
||||
|
||||
18
compose.yaml
18
compose.yaml
@@ -28,6 +28,24 @@ services:
|
||||
- mysql
|
||||
- redis
|
||||
|
||||
laravel.queue:
|
||||
image: 'sail-8.5/app'
|
||||
container_name: star-cloud-queue
|
||||
hostname: star-cloud-queue
|
||||
command: php artisan queue:work --tries=3 --timeout=90
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER}'
|
||||
LARAVEL_SAIL: 1
|
||||
TZ: 'Asia/Taipei'
|
||||
volumes:
|
||||
- '.:/var/www/html'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- mysql
|
||||
- redis
|
||||
restart: always
|
||||
|
||||
mysql:
|
||||
image: 'mysql/mysql-server:8.0'
|
||||
container_name: star-cloud-mysql
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||
"spatie/laravel-permission": "^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
174
composer.lock
generated
174
composer.lock
generated
@@ -4,8 +4,62 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a723334f883b537b67e4475890eb949e",
|
||||
"content-hash": "2889e194212440faeb9f8f3dd7513795",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "2.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
|
||||
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.1",
|
||||
"phpunit/phpunit": "^7 | ^8 | ^9",
|
||||
"spatie/phpunit-snapshot-assertions": "^4.2.9",
|
||||
"squizlabs/php_codesniffer": "^3.4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
|
||||
},
|
||||
"time": "2022-12-07T17:46:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.8",
|
||||
@@ -135,6 +189,56 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DASPRiD/Enum.git",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||
},
|
||||
"time": "2025-09-16T12:23:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
@@ -3554,6 +3658,74 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "simplesoftwareio/simple-qrcode",
|
||||
"version": "4.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
|
||||
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
|
||||
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"bacon/bacon-qr-code": "^2.0",
|
||||
"ext-gd": "*",
|
||||
"php": ">=7.2|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~1",
|
||||
"phpunit/phpunit": "~9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "Allows the generation of PNG QrCodes.",
|
||||
"illuminate/support": "Allows for use within Laravel."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
|
||||
},
|
||||
"providers": [
|
||||
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"SimpleSoftwareIO\\QrCode\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Simple Software LLC",
|
||||
"email": "support@simplesoftware.io"
|
||||
}
|
||||
],
|
||||
"description": "Simple QrCode is a QR code generator made for Laravel.",
|
||||
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
|
||||
"keywords": [
|
||||
"Simple",
|
||||
"generator",
|
||||
"laravel",
|
||||
"qrcode",
|
||||
"wrapper"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
|
||||
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
|
||||
},
|
||||
"time": "2021-02-08T20:43:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.93.0",
|
||||
|
||||
466
config/api-docs.php
Normal file
466
config/api-docs.php
Normal file
@@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Star Cloud IoT API 說明文件',
|
||||
'version' => 'v1.0.0',
|
||||
'description' => '此文件提供 Star Cloud 智能販賣機 IoT 端點通訊協議說明,供硬體端與前端開發者調研與串接使用。',
|
||||
'categories' => [
|
||||
[
|
||||
'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',
|
||||
'method' => 'POST',
|
||||
'path' => '/api/v1/app/machine/status/B010',
|
||||
'description' => '機台定期向雲端回報當前頁面、版本、溫度及門禁狀態。身份由 Bearer Token 識別。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'current_page' => [
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
'description' => '當前頁面編號。對照表:
|
||||
0: 離線, 1: 主頁面, 2: 販賣頁, 3: 管理頁, 4: 補貨頁, 5: 教學頁
|
||||
6: 購買中, 7: 鎖定頁, 60: 出貨成功, 61: 貨道測試, 62: 付款選擇
|
||||
63: 等待付款, 64: 出貨, 65: 收據簽單, 66: 通行碼, 67: 取貨碼
|
||||
68: 訊息顯示, 69: 取消購買, 610: 購買結束, 611: 來店禮, 612: 出貨失敗',
|
||||
'example' => 1
|
||||
],
|
||||
'firmware_version' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '軟體或韌體版本號',
|
||||
'example' => '1.0.5'
|
||||
],
|
||||
'model' => [
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => '機台型號',
|
||||
'example' => 'STAR-V1'
|
||||
],
|
||||
'temperature' => [
|
||||
'type' => 'float',
|
||||
'required' => false,
|
||||
'description' => '感測環境溫度',
|
||||
'example' => 25.5
|
||||
],
|
||||
'door_status' => [
|
||||
'type' => 'integer',
|
||||
'required' => false,
|
||||
'description' => '門禁狀態 (0: 關閉, 1: 開啟)',
|
||||
'example' => 0
|
||||
],
|
||||
'log' => [
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => '事件日誌主訊息',
|
||||
'example' => 'Door opened'
|
||||
],
|
||||
'log_level' => [
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => '日誌等級 (info, warning, error)',
|
||||
'example' => 'info'
|
||||
],
|
||||
'log_payload' => [
|
||||
'type' => 'object',
|
||||
'required' => false,
|
||||
'description' => '詳細上下文 (JSON 對象)',
|
||||
'example' => ['error_code' => 500, 'component' => 'door_sensor']
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '請求是否處理成功',
|
||||
'example' => true
|
||||
],
|
||||
'code' => [
|
||||
'type' => 'integer',
|
||||
'description' => '內部業務狀態碼',
|
||||
'example' => 200
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'string',
|
||||
'description' => '回應訊息說明',
|
||||
'example' => 'OK'
|
||||
],
|
||||
'status' => [
|
||||
'type' => 'string',
|
||||
'description' => '雲端指令代碼。對照表:
|
||||
49: reload B017 (貨道同步), 51: reboot (重啟系統)
|
||||
60: reboot card machine (刷卡機重啟), 61: checkout (觸發結帳)
|
||||
70: unlock (解鎖), 71: lock (鎖定), 85: reload B0552 (遠端出貨)
|
||||
待定義: change (遠端找零 - 目前 Java App 尚無對接)',
|
||||
'example' => '49'
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'current_page' => 1,
|
||||
'firmware_version' => '1.0.5',
|
||||
'model' => 'STAR-V1',
|
||||
'temperature' => 25.5,
|
||||
'door_status' => 0,
|
||||
'log' => 'Door opened',
|
||||
'log_level' => 'info',
|
||||
'log_payload' => [
|
||||
'error_code' => 500,
|
||||
'component' => 'door_sensor'
|
||||
],
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'OK',
|
||||
'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('??###?')),
|
||||
|
||||
24
database/factories/System/CompanyFactory.php
Normal file
24
database/factories/System/CompanyFactory.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\System;
|
||||
|
||||
use App\Models\System\Company;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class CompanyFactory extends Factory
|
||||
{
|
||||
protected $model = Company::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->company,
|
||||
'code' => $this->faker->unique()->bothify('COMP###'),
|
||||
'status' => 1,
|
||||
'settings' => [
|
||||
'enable_material_code' => false,
|
||||
'enable_points' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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('maintenance_records', function (Blueprint $table) {
|
||||
$table->boolean('is_confirmed')->default(false)->after('photos')->comment('已確認告知客戶並簽名');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('maintenance_records', function (Blueprint $table) {
|
||||
$table->dropColumn('is_confirmed');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?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
|
||||
{
|
||||
// 擴充 products 表
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->string('spec')->nullable()->after('name')->comment('規格');
|
||||
$table->string('manufacturer')->nullable()->after('barcode')->comment('生產公司');
|
||||
$table->integer('track_limit')->default(0)->after('manufacturer')->comment('履帶貨道上限');
|
||||
$table->integer('spring_limit')->default(0)->after('track_limit')->comment('彈簧貨道上限');
|
||||
$table->decimal('member_price', 10, 2)->default(0)->after('price')->comment('會員價');
|
||||
$table->json('metadata')->nullable()->after('is_active')->comment('進階 Metadata (點數、物料代碼等)');
|
||||
});
|
||||
|
||||
// 擴充 companies 表
|
||||
Schema::table('companies', function (Blueprint $table) {
|
||||
$table->json('settings')->nullable()->after('note')->comment('客戶功能設定 (Feature Toggles)');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'spec',
|
||||
'manufacturer',
|
||||
'track_limit',
|
||||
'spring_limit',
|
||||
'member_price',
|
||||
'metadata'
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::table('companies', function (Blueprint $table) {
|
||||
$table->dropColumn('settings');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('companies', function (Blueprint $table) {
|
||||
$table->string('code', 100)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('companies', function (Blueprint $table) {
|
||||
$table->string('code', 20)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('translations', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('company_id')->nullable()->after('id')->index()->comment('所屬公司 ID');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('translations', function (Blueprint $table) {
|
||||
$table->dropColumn('company_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
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->renameColumn('image', 'image_url');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->renameColumn('image_url', 'image');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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->renameColumn('valid_until', 'end_date');
|
||||
|
||||
// 新增業務類型
|
||||
$table->string('original_type')->default('lease')->after('code')->comment('原始類型: buyout, lease');
|
||||
$table->string('current_type')->default('lease')->after('original_type')->comment('當前類型: buyout, lease');
|
||||
|
||||
// 新增起始日
|
||||
$table->date('start_date')->nullable()->after('status')->comment('合約起始日');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('companies', function (Blueprint $table) {
|
||||
$table->renameColumn('end_date', 'valid_until');
|
||||
$table->dropColumn(['original_type', 'current_type', 'start_date']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->boolean('is_admin')->default(false)->after('is_system');
|
||||
});
|
||||
|
||||
// 資料遷移:將所有租戶中名稱為「管理員」的角色標示為 is_admin = true
|
||||
// 這樣既有的授權篩選才不會斷掉
|
||||
DB::table('roles')
|
||||
->whereNotNull('company_id')
|
||||
->where('name', '管理員')
|
||||
->update(['is_admin' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropColumn('is_admin');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. 從 roles 移除 is_admin
|
||||
if (Schema::hasColumn('roles', 'is_admin')) {
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropColumn('is_admin');
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 在 users 新增 is_admin
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_admin')->default(false)->after('status');
|
||||
});
|
||||
|
||||
// 3. 資料遷移:針對現有租戶,將每一家公司最先建立的帳號(或是目前名稱為管理員角色的人)標記為 is_admin = true
|
||||
// 取得所有租戶公司 ID
|
||||
$companyIds = DB::table('companies')->pluck('id');
|
||||
|
||||
foreach ($companyIds as $companyId) {
|
||||
// 優先找該公司 ID 最小的 user (通常是第一個建立的)
|
||||
$userId = DB::table('users')
|
||||
->where('company_id', $companyId)
|
||||
->orderBy('id', 'asc')
|
||||
->value('id');
|
||||
|
||||
if ($userId) {
|
||||
DB::table('users')->where('id', $userId)->update(['is_admin' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_admin');
|
||||
});
|
||||
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->boolean('is_admin')->default(false)->after('is_system');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. 先將所有已刪除帳號的 is_admin 全部歸零,確保不會標記在「看不到的人」身上
|
||||
DB::table('users')->whereNotNull('deleted_at')->update(['is_admin' => false]);
|
||||
|
||||
// 2. 針對每一家公司,重新撈取「目前還存活 (deleted_at is null)」的最早建立帳號
|
||||
$companyIds = DB::table('companies')->pluck('id');
|
||||
|
||||
foreach ($companyIds as $companyId) {
|
||||
// 找該公司中,目前 ID 最小且「尚未被刪除」的 User
|
||||
$userId = DB::table('users')
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id', 'asc')
|
||||
->value('id');
|
||||
|
||||
if ($userId) {
|
||||
// 將該帳號設為管理員,並確保該公司其它生存帳號如果是 true 的先清掉 (一對一標記)
|
||||
DB::table('users')
|
||||
->where('company_id', $companyId)
|
||||
->where('id', '!=', $userId)
|
||||
->update(['is_admin' => false]);
|
||||
|
||||
DB::table('users')->where('id', $userId)->update(['is_admin' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 基本上這是資料修正,回復也不太有意義
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('advertisements', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->nullable()->constrained()->onDelete('cascade');
|
||||
$table->string('name');
|
||||
$table->string('type')->default('image'); // image, video
|
||||
$table->integer('duration')->default(15); // 15, 30, 60
|
||||
$table->string('url');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('advertisements');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('machine_advertisements', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('machine_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('advertisement_id')->constrained()->onDelete('cascade');
|
||||
$table->string('position')->comment('vending, visit_gift, standby');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->dateTime('start_at')->nullable();
|
||||
$table->dateTime('end_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('machine_advertisements');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. 取得權限 ID
|
||||
$permission = DB::table('permissions')
|
||||
->where('name', 'menu.data-config.sub-account-roles')
|
||||
->first();
|
||||
|
||||
if ($permission) {
|
||||
// 2. 移除角色與該權限的關聯 (雖然 Spatie 通常會處理,但手動確保清理乾淨)
|
||||
DB::table('role_has_permissions')
|
||||
->where('permission_id', $permission->id)
|
||||
->delete();
|
||||
|
||||
// 3. 移除權限本身
|
||||
DB::table('permissions')
|
||||
->where('id', $permission->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
// 4. 清理權限快取 (如果有的話)
|
||||
try {
|
||||
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
} catch (\Exception $e) {
|
||||
// 忽略快取清理失敗(例如在沒有 Redis 的環境中)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 由於是要永久拿掉,down 邏輯通常不需要重建,
|
||||
// 若真要復原,應透過重跑 Seeder 或手動新增。
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -22,6 +22,7 @@ class RoleSeeder extends Seeder
|
||||
'menu.members',
|
||||
'menu.machines',
|
||||
'menu.machines.list',
|
||||
'menu.machines.permissions',
|
||||
'menu.machines.utilization',
|
||||
'menu.machines.maintenance',
|
||||
'menu.app',
|
||||
@@ -30,8 +31,11 @@ class RoleSeeder extends Seeder
|
||||
'menu.analysis',
|
||||
'menu.audit',
|
||||
'menu.data-config',
|
||||
'menu.data-config.products',
|
||||
'menu.data-config.advertisements',
|
||||
'menu.data-config.sub-accounts',
|
||||
'menu.data-config.sub-account-roles',
|
||||
'menu.data-config.points',
|
||||
'menu.data-config.badges',
|
||||
'menu.remote',
|
||||
'menu.line',
|
||||
'menu.reservation',
|
||||
@@ -64,6 +68,7 @@ class RoleSeeder extends Seeder
|
||||
'menu.members',
|
||||
'menu.machines',
|
||||
'menu.machines.list',
|
||||
'menu.machines.permissions',
|
||||
'menu.machines.utilization',
|
||||
'menu.machines.maintenance',
|
||||
'menu.app',
|
||||
@@ -72,8 +77,11 @@ class RoleSeeder extends Seeder
|
||||
'menu.analysis',
|
||||
'menu.audit',
|
||||
'menu.data-config',
|
||||
'menu.data-config.products',
|
||||
'menu.data-config.advertisements',
|
||||
'menu.data-config.sub-accounts',
|
||||
'menu.data-config.sub-account-roles',
|
||||
'menu.data-config.points',
|
||||
'menu.data-config.badges',
|
||||
'menu.remote',
|
||||
'menu.line',
|
||||
'menu.reservation',
|
||||
|
||||
@@ -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 週期內執行耗時匯出。
|
||||
1307
lang/en.json
1307
lang/en.json
File diff suppressed because it is too large
Load Diff
1422
lang/ja.json
1422
lang/ja.json
File diff suppressed because it is too large
Load Diff
819
lang/zh_TW.json
819
lang/zh_TW.json
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,8 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => 'These credentials do not match our records.',
|
||||
'password' => 'The provided password is incorrect.',
|
||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||
'failed' => '帳號或密碼錯誤,請重新確認。',
|
||||
'password' => '您輸入的密碼不正確。',
|
||||
'throttle' => '登入嘗試次數過多。請在 :seconds 秒後再試。',
|
||||
|
||||
];
|
||||
|
||||
@@ -179,8 +179,8 @@ return [
|
||||
*/
|
||||
|
||||
'custom' => [
|
||||
'attribute-name' => [
|
||||
'rule-name' => 'custom-message',
|
||||
'is_confirmed' => [
|
||||
'accepted' => '您必須勾選確認已告知客戶並取得簽名。',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -196,12 +196,18 @@ return [
|
||||
*/
|
||||
|
||||
'attributes' => [
|
||||
'username' => '帳號',
|
||||
'name' => '姓名',
|
||||
'email' => '電子郵件',
|
||||
'password' => '密碼',
|
||||
'current_password' => '目前密碼',
|
||||
'password_confirmation' => '確認密碼',
|
||||
'phone' => '電話',
|
||||
'machine_id' => '機台',
|
||||
'category' => '類別',
|
||||
'maintenance_at' => '維修日期',
|
||||
'content' => '維修內容',
|
||||
'is_confirmed' => '確認勾選框',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
171
package-lock.json
generated
171
package-lock.json
generated
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"name": "html",
|
||||
"name": "star-cloud",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"flatpickr": "^4.6.13",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alpinejs/collapse": "^3.15.3",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
@@ -871,6 +875,7 @@
|
||||
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Fuzzyma"
|
||||
@@ -896,6 +901,7 @@
|
||||
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18"
|
||||
},
|
||||
@@ -930,6 +936,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
@@ -1123,6 +1138,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -1243,6 +1259,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1485,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",
|
||||
@@ -1669,6 +1697,39 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
|
||||
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -1731,12 +1792,19 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -1748,6 +1816,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/just-extend": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz",
|
||||
@@ -1775,6 +1855,15 @@
|
||||
"vite": "^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -1947,6 +2036,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -2014,6 +2109,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2157,6 +2253,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pptxgenjs": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
|
||||
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.8.1",
|
||||
"https": "^1.0.0",
|
||||
"image-size": "^1.2.1",
|
||||
"jszip": "^3.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/preline": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/preline/-/preline-3.2.3.tgz",
|
||||
@@ -2172,6 +2280,12 @@
|
||||
"vanilla-calendar-pro": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2179,6 +2293,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -2210,6 +2333,21 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -2321,6 +2459,18 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2331,6 +2481,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -2373,6 +2532,7 @@
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -2469,6 +2629,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2496,6 +2657,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
||||
@@ -2531,7 +2698,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vanilla-calendar-pro": {
|
||||
@@ -2551,6 +2717,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -17,5 +17,9 @@
|
||||
"preline": "^3.2.3",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"flatpickr": "^4.6.13",
|
||||
"pptxgenjs": "^4.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
135
pptx_gen.cjs
Normal file
135
pptx_gen.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
const pptxgen = require("pptxgenjs");
|
||||
const fs = require("fs");
|
||||
|
||||
const pres = new pptxgen();
|
||||
pres.layout = "LAYOUT_16x9";
|
||||
pres.title = "Star Cloud Demo Day - Technical Edition";
|
||||
pres.author = "許家偉";
|
||||
|
||||
// --- Theme Colors ---
|
||||
const COLORS = {
|
||||
bg: "0F172A",
|
||||
cardBg: "1E293B",
|
||||
highlight: "2DD4BF",
|
||||
secondary: "7DD3FC",
|
||||
text: "FFFFFF",
|
||||
muted: "94A3B8"
|
||||
};
|
||||
|
||||
const FONTS = {
|
||||
header: "Georgia",
|
||||
body: "Calibri"
|
||||
};
|
||||
|
||||
const makeShadow = (opacity = 0.2) => ({
|
||||
type: "outer",
|
||||
blur: 8,
|
||||
offset: 3,
|
||||
angle: 135,
|
||||
color: "000000",
|
||||
opacity: opacity
|
||||
});
|
||||
|
||||
// --- Slide 1: Title ---
|
||||
const slide1 = pres.addSlide();
|
||||
slide1.background = { color: COLORS.bg };
|
||||
slide1.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 0.15, h: "100%", fill: { color: COLORS.highlight } });
|
||||
slide1.addText("Star Cloud Demo Day", { x: 1.0, y: 1.8, w: 8, h: 1, fontSize: 48, fontFace: FONTS.header, color: COLORS.text, bold: true, margin: 0 });
|
||||
slide1.addText("2026-03-25 ~ 2026-03-31 成果發表", { x: 1.0, y: 2.8, w: 8, h: 0.5, fontSize: 24, fontFace: FONTS.body, color: COLORS.secondary, italic: true });
|
||||
slide1.addShape(pres.shapes.RECTANGLE, { x: 7.5, y: 4.5, w: 2, h: 0.6, fill: { color: COLORS.highlight, transparency: 10 }, shadow: makeShadow() });
|
||||
slide1.addText("演講者:許家偉", { x: 7.5, y: 4.5, w: 2, h: 0.6, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, align: "center", valign: "middle", bold: true });
|
||||
|
||||
// --- Slide 2: Overview ---
|
||||
const slide2 = pres.addSlide();
|
||||
slide2.background = { color: COLORS.bg };
|
||||
slide2.addText("本週進度總覽 / Overview", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 32, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
|
||||
slide2.addShape(pres.shapes.LINE, { x: 2, y: 3, w: 6, h: 0, line: { color: COLORS.highlight, width: 4 } });
|
||||
const timelineItems = [
|
||||
{ title: "機台權限管理", desc: "模組獨立化與效能優化" },
|
||||
{ title: "商品管理整合", desc: "狀態整合與多語系修復" },
|
||||
{ title: "廣告隔離機制", desc: "多租戶素材歸屬強化" }
|
||||
];
|
||||
timelineItems.forEach((item, idx) => {
|
||||
const x = 2 + (idx * 3);
|
||||
slide2.addShape(pres.shapes.OVAL, { x: x - 0.2, y: 2.8, w: 0.4, h: 0.4, fill: { color: COLORS.secondary }, line: { color: COLORS.text, width: 2 } });
|
||||
slide2.addText(item.title, { x: x - 1, y: 3.3, w: 2, h: 0.4, fontSize: 18, fontFace: FONTS.header, color: COLORS.text, align: "center", bold: true });
|
||||
slide2.addText(item.desc, { x: x - 1, y: 3.7, w: 2, h: 0.6, fontSize: 12, fontFace: FONTS.body, color: COLORS.muted, align: "center" });
|
||||
});
|
||||
|
||||
// --- Slide 3: Highlight 1 - Machine Permissions (WITH EAGER LOADING) ---
|
||||
const slide3 = pres.addSlide();
|
||||
slide3.background = { color: COLORS.bg };
|
||||
slide3.addText("亮點一:機台權限管理獨立化", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 28, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
|
||||
|
||||
const features = [
|
||||
{ title: "直覺檢索", text: "管理員可快速檢索、分配所屬帳號對應的機台清單。" },
|
||||
{ title: "即時過濾", text: "透過 Alpine.js 實作,輸入關鍵字即動態無刷新過濾。" },
|
||||
{ title: "效能關鍵:Eager Loading", text: "使用 with('machines') 解決 N+1 問題,萬筆資料秒開。" }
|
||||
];
|
||||
|
||||
features.forEach((f, idx) => {
|
||||
const y = 1.2 + (idx * 1.3);
|
||||
slide3.addShape(pres.shapes.RECTANGLE, { x: 0.5, y: y, w: 4, h: 1.1, fill: { color: COLORS.cardBg }, line: { color: COLORS.secondary, width: 1 }, shadow: makeShadow() });
|
||||
slide3.addText(f.title, { x: 0.7, y: y + 0.1, w: 3.5, h: 0.3, fontSize: 16, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
|
||||
slide3.addText(f.text, { x: 0.7, y: y + 0.45, w: 3.5, h: 0.5, fontSize: 12, fontFace: FONTS.body, color: COLORS.text });
|
||||
});
|
||||
|
||||
slide3.addImage({
|
||||
path: "/home/mama/.gemini/antigravity/brain/58a170d0-7144-4e9f-9396-3e753a0bf69a/machine_permissions_table_1774944648298.png",
|
||||
x: 4.8, y: 1.2, w: 4.8, h: 3.7, sizing: { type: 'contain' }, shadow: makeShadow(0.3)
|
||||
});
|
||||
|
||||
// --- Slide 4: Highlight 2 ---
|
||||
const slide4 = pres.addSlide();
|
||||
slide4.background = { color: COLORS.bg };
|
||||
slide4.addText("亮點二:商品管理整合與 UX 完善", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 28, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
|
||||
const comparison = [
|
||||
{ title: "功能整合 (Integrated)", items: ["• 「商品狀態」納入主流程", "• 減少跳轉,提升管理效率"] },
|
||||
{ title: "細節優化 (Enhanced)", items: ["• 修復多語系(ZH/EN/JA)存取故障", "• 密碼欄位新增顯隱切換按鈕"] }
|
||||
];
|
||||
comparison.forEach((c, idx) => {
|
||||
const x = 0.5 + (idx * 4.75);
|
||||
slide4.addShape(pres.shapes.RECTANGLE, { x: x, y: 1.2, w: 4.25, h: 3.5, fill: { color: COLORS.cardBg }, line: { color: COLORS.highlight, width: 2 }, shadow: makeShadow(0.2) });
|
||||
slide4.addText(c.title, { x: x + 0.2, y: 1.4, w: 3.8, h: 0.4, fontSize: 18, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
|
||||
slide4.addText(c.items.map(i => ({ text: i, options: { breakLine: true, bullet: true } })), { x: x + 0.3, y: 2.0, w: 3.6, h: 2.5, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, margin: 0 });
|
||||
});
|
||||
|
||||
// --- Slide 5: Highlight 3 ---
|
||||
const slide5 = pres.addSlide();
|
||||
slide5.background = { color: COLORS.bg };
|
||||
slide5.addText("亮點三:多租戶廣告素材隔離機制", { x: 1.0, y: 0.4, w: 8, h: 1, fontSize: 32, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
|
||||
slide5.addShape(pres.shapes.OVAL, { x: 4.25, y: 2.0, w: 1.5, h: 1.5, fill: { color: COLORS.highlight, transparency: 30 }, line: { color: COLORS.secondary, width: 2 } });
|
||||
slide5.addText("安全性隔離\n(Shield)", { x: 4.25, y: 2.5, w: 1.5, h: 0.5, fontSize: 14, fontFace: FONTS.header, color: COLORS.text, align: "center", bold: true });
|
||||
const isolations = [
|
||||
{ x: 1, y: 1.8, label: "公司 A\n廣告素材", color: "A8DADC" },
|
||||
{ x: 7.5, y: 1.8, label: "公司 B\n廣告素材", color: "A8DADC" },
|
||||
{ x: 1, y: 3.8, label: "公司 C\n廣告素材", color: "A8DADC" },
|
||||
{ x: 7.5, y: 3.8, label: "公司 D\n廣告素材", color: "A8DADC" }
|
||||
];
|
||||
isolations.forEach(i => {
|
||||
slide5.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: i.x, y: i.y, w: 1.5, h: 0.8, fill: { color: COLORS.cardBg }, line: { color: COLORS.secondary, width: 1 }, rectRadius: 0.1 });
|
||||
slide5.addText(i.label, { x: i.x, y: i.y, w: 1.5, h: 0.8, fontSize: 12, fontFace: FONTS.body, color: COLORS.text, align: "center", valign: "middle" });
|
||||
slide5.addShape(pres.shapes.LINE, { x: i.x + (i.x < 5 ? 1.5 : 0), y: i.y + 0.4, w: i.x < 5 ? (4.25 - (i.x + 1.5)) : (7.5 - (i.x + 1.5)), h: 2.75 - (i.y + 0.4), line: { color: COLORS.secondary, width: 1, dashType: "dash" } });
|
||||
});
|
||||
slide5.addText("嚴格驗證 company_id,確保素材 100% 歸屬隔離。", { x: 0.5, y: 5.0, w: 9, h: 0.4, fontSize: 14, fontFace: FONTS.body, color: COLORS.muted, align: "center", italic: true });
|
||||
|
||||
// --- Slide 6: Future Roadmap ---
|
||||
const slide6 = pres.addSlide();
|
||||
slide6.background = { color: COLORS.bg };
|
||||
slide6.addText("未來計畫 / Roadmap", { x: 0.5, y: 0.4, w: 9, h: 1, fontSize: 36, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
|
||||
const roadmap = [
|
||||
{ icon: "Optim", title: "操作優化", text: "針對樂樂反饋優化操作流程與介面體驗。" },
|
||||
{ icon: "Sync", title: "API 對接", text: "與 Terry 配合,實現機台通訊穩定與對接。" },
|
||||
{ icon: "Remote", title: "遠端管理", text: "實作遠端控制與異常即時監控監管。" }
|
||||
];
|
||||
roadmap.forEach((r, idx) => {
|
||||
const x = 0.5 + (idx * 3.1);
|
||||
slide6.addShape(pres.shapes.RECTANGLE, { x: x, y: 2.0, w: 2.8, h: 2.5, fill: { color: COLORS.highlight, transparency: 85 }, line: { color: COLORS.highlight, width: 2 }, shadow: makeShadow() });
|
||||
slide6.addText(r.title, { x: x, y: 2.3, w: 2.8, h: 0.5, fontSize: 20, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
|
||||
slide6.addText(r.text, { x: x + 0.2, y: 3.0, w: 2.4, h: 1.2, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, align: "center" });
|
||||
});
|
||||
slide6.addText("Star Cloud 將持續進化,打造最領先的智能雲端平台。🚀", { x: 0.5, y: 5.0, w: 9, h: 0.4, fontSize: 14, fontFace: FONTS.body, color: COLORS.secondary, align: "center", bold: true });
|
||||
|
||||
pres.writeFile({ fileName: "star-cloud-demo-20260331.pptx" })
|
||||
.then(fileName => console.log(`Presentation created: ${fileName}`))
|
||||
.catch(err => { console.error("Error creating presentation:", err); process.exit(1); });
|
||||
BIN
public/S1.png
Normal file
BIN
public/S1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
BIN
public/S1_cropped.png
Normal file
BIN
public/S1_cropped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
public/S1_cropped_transparent.png
Normal file
BIN
public/S1_cropped_transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
BIN
public/S1_edited.png
Normal file
BIN
public/S1_edited.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 18 KiB |
@@ -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 {
|
||||
@@ -289,7 +302,6 @@
|
||||
.hs-select-menu {
|
||||
@apply mt-2 max-h-72 p-2 space-y-0.5 z-50 bg-white border border-slate-200 rounded-2xl shadow-2xl overflow-y-auto;
|
||||
@apply dark:bg-slate-900 dark:border-slate-800;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.hs-select-option {
|
||||
@@ -318,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';
|
||||
|
||||
866
resources/views/admin/ads/index.blade.php
Normal file
866
resources/views/admin/ads/index.blade.php
Normal file
@@ -0,0 +1,866 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@php
|
||||
$routeName = request()->route()->getName();
|
||||
$baseRoute = 'admin.data-config.advertisements';
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-2 pb-20"
|
||||
x-data="adManager"
|
||||
data-ads="{{ json_encode($advertisements->items()) }}"
|
||||
data-machines="{{ json_encode($machines) }}"
|
||||
data-all-ads="{{ json_encode($allAds) }}"
|
||||
data-active-tab="{{ $tab }}"
|
||||
data-urls='{
|
||||
"store": "{{ route($baseRoute . ".store") }}",
|
||||
"update": "{{ route($baseRoute . ".update", ":id") }}",
|
||||
"delete": "{{ route($baseRoute . ".destroy", ":id") }}",
|
||||
"getMachineAds": "{{ route($baseRoute . ".machine.get", ":id") }}",
|
||||
"assign": "{{ route($baseRoute . ".assign") }}",
|
||||
"reorder": "{{ route($baseRoute . ".assignments.reorder") }}",
|
||||
"removeAssignment": "{{ route($baseRoute . ".assignment.remove", ":id") }}"
|
||||
}'>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Advertisement Management') }}</h1>
|
||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
|
||||
{{ __('Manage ad materials and machine playback settings') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3" x-show="activeTab === 'list'">
|
||||
<button @click="openAddModal()" class="btn-luxury-primary">
|
||||
<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="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>{{ __('Add Advertisement') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation (Pills Style match Machine List) -->
|
||||
<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" aria-label="Tabs">
|
||||
<button type="button"
|
||||
@click="activeTab = 'list'"
|
||||
:class="activeTab === '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">
|
||||
{{ __('Advertisement List') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="activeTab = 'machine'"
|
||||
:class="activeTab === 'machine' ? '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">
|
||||
{{ __('Machine Advertisement Settings') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Contents -->
|
||||
<div class="mt-6">
|
||||
<!-- List Tab -->
|
||||
<div x-show="activeTab === 'list'" class="luxury-card rounded-3xl p-8 animate-luxury-in" x-cloak>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Preview') }}</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Name') }}</th>
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
<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-left">{{ __('Company Name') }}</th>
|
||||
@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>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
@forelse($advertisements as $ad)
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-4">
|
||||
<div @click="openPreview(@js($ad))"
|
||||
class="w-16 h-9 rounded-lg bg-slate-100 dark:bg-slate-800 overflow-hidden shadow-sm border border-slate-200 dark:border-white/5 cursor-pointer hover:scale-105 hover:shadow-cyan-500/20 transition-all duration-300">
|
||||
@if($ad->type === 'image')
|
||||
<img src="{{ $ad->url }}" class="w-full h-full object-cover">
|
||||
@else
|
||||
<div class="w-full h-full flex items-center justify-center bg-slate-900 group-hover:bg-cyan-900 transition-colors">
|
||||
<svg class="size-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td @click="openPreview(@js($ad))"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors cursor-pointer">
|
||||
{{ $ad->name }}
|
||||
</td>
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-slate-600 dark:text-slate-300">
|
||||
{{ $ad->company->name ?? __('System Default') }}
|
||||
</td>
|
||||
@endif
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest px-2 py-0.5 rounded-full {{ $ad->type === 'video' ? 'bg-indigo-500/10 text-indigo-500 border border-indigo-500/20' : 'bg-cyan-500/10 text-cyan-500 border border-cyan-500/20' }}">
|
||||
{{ __($ad->type) }}
|
||||
</span>
|
||||
</td>
|
||||
<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">
|
||||
@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">
|
||||
<button @click="openEditModal(@js($ad))" 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">
|
||||
<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>
|
||||
<button @click="confirmDelete(@js($ad->id))" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20">
|
||||
<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="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ auth()->user()->isSystemAdmin() ? 7 : 6 }}" class="px-6 py-20 text-center text-slate-400 italic">{{ __('No advertisements found.') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
{{ $advertisements->links('vendor.pagination.luxury') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Machine View Tab -->
|
||||
<div x-show="activeTab === 'machine'" class="space-y-6" x-cloak>
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
|
||||
<!-- Machine Filter -->
|
||||
<div class="max-w-md mx-auto mb-10">
|
||||
<label class="block text-sm font-black text-slate-700 dark:text-slate-200 mb-3 text-center uppercase tracking-widest">{{ __('Please select a machine first') }}</label>
|
||||
<x-searchable-select
|
||||
name="machine_selector"
|
||||
:options="$machines->map(fn($m) => (object)['id' => $m->id, 'name' => $m->name . ' (' . $m->serial_no . ')'])"
|
||||
placeholder="{{ __('Search Machine...') }}"
|
||||
@change="selectMachine($event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedMachineId" class="animate-luxury-in">
|
||||
<!-- Positions Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
@foreach(['vending', 'visit_gift', 'standby'] as $pos)
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h3 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-cyan-500"></span>
|
||||
{{ __($pos) }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button x-cloak x-show="machineAds['{{ $pos }}'] && machineAds['{{ $pos }}'].length > 0"
|
||||
@click="startSequencePreview('{{ $pos }}')"
|
||||
class="p-1.5 px-3 text-[10px] font-black bg-slate-800 dark:bg-slate-700 text-white rounded-lg hover:bg-slate-700 transition-colors uppercase flex items-center gap-1">
|
||||
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
{{ __('Preview') }}
|
||||
</button>
|
||||
<button @click="openAssignModal('{{ $pos }}')" class="p-1.5 px-3 text-[10px] font-black bg-cyan-500 text-white rounded-lg hover:bg-cyan-600 transition-colors uppercase shadow-sm shadow-cyan-500/20 flex items-center gap-1">
|
||||
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4.5v15m7.5-7.5h-15" /></svg>
|
||||
{{ __('Ad Settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="luxury-card p-4 bg-slate-50/50 dark:bg-slate-900/40 border border-slate-100 dark:border-white/5 space-y-3 min-h-[150px]">
|
||||
<template x-if="!machineAds['{{ $pos }}'] || machineAds['{{ $pos }}'].length === 0">
|
||||
<div class="flex flex-col items-center justify-center h-full py-10">
|
||||
<svg class="size-6 text-slate-300 dark:text-slate-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v6m-3-3h6m-9-3a9 9 0 1118 0 9 9 0 01-18 0z"/></svg>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">{{ __('No assignments') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="(assign, index) in machineAds['{{ $pos }}']" :key="assign.id">
|
||||
<div class="flex items-center gap-4 bg-white dark:bg-slate-800 p-3 rounded-xl border border-slate-100 dark:border-white/5 shadow-sm group hover:border-cyan-500/30 transition-all">
|
||||
<!-- Sort Controls -->
|
||||
<div class="flex flex-col gap-1 shrink-0 bg-slate-50 dark:bg-slate-900 rounded-lg p-1 border border-slate-100 dark:border-white/5">
|
||||
<button @click.prevent="moveUp('{{ $pos }}', index)" :disabled="index === 0" class="p-1 text-slate-400 hover:text-cyan-500 disabled:opacity-30 disabled:hover:text-slate-400 transition-colors">
|
||||
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" /></svg>
|
||||
</button>
|
||||
<button @click.prevent="moveDown('{{ $pos }}', index)" :disabled="index === machineAds['{{ $pos }}'].length - 1" class="p-1 text-slate-400 hover:text-cyan-500 disabled:opacity-30 disabled:hover:text-slate-400 transition-colors">
|
||||
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button @click="openPreview(assign.advertisement)" class="w-12 h-12 rounded-lg bg-slate-100 dark:bg-slate-900 border border-white/5 overflow-hidden shrink-0 hover:border-cyan-500/50 hover:shadow-[0_0_15px_rgba(6,182,212,0.3)] transition-all relative group/thumb">
|
||||
<div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity z-10">
|
||||
<svg class="size-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</div>
|
||||
<template x-if="assign.advertisement.type === 'image'">
|
||||
<img :src="assign.advertisement.url" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="assign.advertisement.type === 'video'">
|
||||
<div class="w-full h-full flex items-center justify-center bg-slate-900 group-hover/thumb:bg-cyan-900 transition-colors">
|
||||
<svg class="size-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
|
||||
</div>
|
||||
</template>
|
||||
</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-[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>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!selectedMachineId" class="py-20 text-center text-slate-400 italic">
|
||||
{{ __('Please select a machine to view and manage its advertisements.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
@include('admin.ads.partials.ad-modal')
|
||||
@include('admin.ads.partials.assign-modal')
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<div x-show="isPreviewOpen"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@keydown.escape.window="isPreviewOpen = false">
|
||||
|
||||
<div class="fixed inset-0 bg-slate-950/90 backdrop-blur-xl" @click="isPreviewOpen = false"></div>
|
||||
|
||||
<div class="relative max-w-5xl w-full max-h-[90vh] flex flex-col items-center justify-center animate-luxury-in">
|
||||
<!-- Close Button -->
|
||||
<button @click="isPreviewOpen = false"
|
||||
class="absolute -top-12 right-0 p-2 text-white/50 hover:text-white transition-colors">
|
||||
<svg class="size-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="w-full bg-slate-900/40 rounded-[2rem] border border-white/5 overflow-hidden shadow-2xl flex items-center justify-center">
|
||||
<template x-if="isPreviewOpen && previewAd.type === 'image'">
|
||||
<img :src="previewAd.url" class="max-w-full max-h-[80vh] object-contain shadow-2xl">
|
||||
</template>
|
||||
<template x-if="isPreviewOpen && previewAd.type === 'video'">
|
||||
<video :src="previewAd.url" controls autoplay class="max-w-full max-h-[80vh] shadow-2xl"></video>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="mt-4 text-center">
|
||||
<h4 class="text-white font-black text-lg uppercase tracking-widest" x-text="previewAd.name"></h4>
|
||||
<p class="text-white/40 text-[10px] font-bold uppercase tracking-[0.2em] mt-1" x-text="previewAd.type + ' | ' + previewAd.duration + 's'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sequence Preview Modal -->
|
||||
<div x-show="isSequencePreviewOpen"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@keydown.escape.window="stopSequencePreview()">
|
||||
|
||||
<div class="fixed inset-0 bg-slate-950/95 backdrop-blur-md" @click="stopSequencePreview()"></div>
|
||||
|
||||
<div class="relative w-full max-w-5xl h-[85vh] flex flex-col items-center justify-center animate-luxury-in" x-show="currentSequenceAd">
|
||||
|
||||
<!-- Close Button -->
|
||||
<button @click="stopSequencePreview()"
|
||||
class="absolute -top-12 right-0 p-2 text-white/50 hover:text-white transition-colors">
|
||||
<svg class="size-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
|
||||
<!-- Media Container -->
|
||||
<div class="relative w-full h-full bg-slate-900 rounded-[2rem] border border-white/5 overflow-hidden shadow-2xl flex items-center justify-center">
|
||||
|
||||
<template x-if="currentSequenceAd && currentSequenceAd.advertisement.type === 'image'">
|
||||
<img :src="currentSequenceAd.advertisement.url"
|
||||
class="max-w-full max-h-full object-contain animate-luxury-in"
|
||||
:key="'img-'+currentSequenceAd.id">
|
||||
</template>
|
||||
|
||||
<template x-if="currentSequenceAd && currentSequenceAd.advertisement.type === 'video'">
|
||||
<video :src="currentSequenceAd.advertisement.url"
|
||||
autoplay muted playsinline
|
||||
class="max-w-full max-h-full object-contain animate-luxury-in"
|
||||
:key="'vid-'+currentSequenceAd.id"></video>
|
||||
</template>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="absolute bottom-0 left-0 h-1.5 bg-cyan-500 transition-all duration-100 ease-linear"
|
||||
:style="'width: ' + sequenceProgress + '%'"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header Info -->
|
||||
<div class="absolute top-6 left-6 right-6 flex items-center justify-between z-10 w-[calc(100%-3rem)]">
|
||||
<div class="bg-black/60 backdrop-blur-md rounded-xl px-4 py-2 border border-white/10 flex items-center gap-3">
|
||||
<span class="text-white font-black tracking-widest text-sm" x-text="currentSequenceAd?.advertisement.name"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
|
||||
<span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></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">
|
||||
<button @click.stop="prevSequenceAd()" class="p-2.5 bg-black/60 backdrop-blur-md rounded-xl border border-white/10 text-white hover:bg-white/20 transition-all group">
|
||||
<svg class="size-5 group-hover:-translate-x-0.5 transition-transform" 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>
|
||||
<!-- Play/Pause -->
|
||||
<button @click.stop="toggleSequencePlay()" class="p-3 bg-cyan-500 backdrop-blur-md rounded-xl border border-cyan-400/30 text-white hover:bg-cyan-400 transition-all shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
||||
<svg x-show="!isSequencePaused" class="size-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
<svg x-show="isSequencePaused" class="size-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button @click.stop="nextSequenceAd()" class="p-2.5 bg-black/60 backdrop-blur-md rounded-xl border border-white/10 text-white hover:bg-white/20 transition-all group">
|
||||
<svg class="size-5 group-hover:translate-x-0.5 transition-transform" 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>
|
||||
|
||||
<x-delete-confirm-modal
|
||||
:title="__('Delete Advertisement Confirmation')"
|
||||
:message="__('Are you sure you want to delete this advertisement? This will also remove all assignments to machines.')"
|
||||
/>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('adManager', () => ({
|
||||
activeTab: 'list',
|
||||
selectedMachineId: null,
|
||||
machines: [],
|
||||
allAds: [],
|
||||
machineAds: {
|
||||
vending: [],
|
||||
visit_gift: [],
|
||||
standby: []
|
||||
},
|
||||
urls: {},
|
||||
|
||||
// Ad CRUD Modal
|
||||
isAdModalOpen: false,
|
||||
isDeleteConfirmOpen: false,
|
||||
deleteFormAction: '',
|
||||
|
||||
// Preview
|
||||
isPreviewOpen: false,
|
||||
previewAd: { url: '', type: '', name: '', duration: 15 },
|
||||
|
||||
adFormMode: 'add',
|
||||
fileName: '',
|
||||
mediaPreview: null,
|
||||
adForm: {
|
||||
id: null,
|
||||
name: '',
|
||||
type: 'image',
|
||||
duration: 15,
|
||||
is_active: true,
|
||||
url: '',
|
||||
start_at: '',
|
||||
end_at: ''
|
||||
},
|
||||
|
||||
// Assign Modal
|
||||
isAssignModalOpen: false,
|
||||
assignForm: {
|
||||
machine_id: null,
|
||||
advertisement_id: '',
|
||||
position: ''
|
||||
},
|
||||
|
||||
// Sequence Preview
|
||||
isSequencePreviewOpen: false,
|
||||
sequenceAds: [],
|
||||
currentSequenceIndex: 0,
|
||||
sequenceInterval: null,
|
||||
sequenceRemainingTime: 0,
|
||||
sequenceProgress: 0,
|
||||
isSequencePaused: false,
|
||||
|
||||
get currentSequenceAd() {
|
||||
return this.sequenceAds[this.currentSequenceIndex] || null;
|
||||
},
|
||||
|
||||
startSequencePreview(pos) {
|
||||
if (!this.machineAds[pos] || this.machineAds[pos].length === 0) return;
|
||||
this.sequenceAds = this.machineAds[pos];
|
||||
this.currentSequenceIndex = 0;
|
||||
this.isSequencePreviewOpen = true;
|
||||
this.isSequencePaused = false;
|
||||
|
||||
this.playSequenceAd();
|
||||
},
|
||||
|
||||
stopSequencePreview() {
|
||||
this.isSequencePreviewOpen = false;
|
||||
this.clearSequenceTimers();
|
||||
},
|
||||
|
||||
clearSequenceTimers() {
|
||||
if (this.sequenceInterval) clearInterval(this.sequenceInterval);
|
||||
},
|
||||
|
||||
playSequenceAd() {
|
||||
this.clearSequenceTimers();
|
||||
|
||||
if (this.isSequencePaused) return;
|
||||
|
||||
const currentAd = this.currentSequenceAd?.advertisement;
|
||||
if (!currentAd) return;
|
||||
|
||||
this.sequenceRemainingTime = currentAd.duration;
|
||||
this.sequenceProgress = 0;
|
||||
|
||||
this.sequenceInterval = setInterval(() => {
|
||||
if (this.isSequencePaused) return;
|
||||
|
||||
this.sequenceRemainingTime -= 0.1;
|
||||
this.sequenceProgress = ((currentAd.duration - this.sequenceRemainingTime) / currentAd.duration) * 100;
|
||||
|
||||
if (this.sequenceRemainingTime <= 0) {
|
||||
this.nextSequenceAd();
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
|
||||
toggleSequencePlay() {
|
||||
this.isSequencePaused = !this.isSequencePaused;
|
||||
},
|
||||
|
||||
nextSequenceAd() {
|
||||
this.currentSequenceIndex++;
|
||||
if (this.currentSequenceIndex >= this.sequenceAds.length) {
|
||||
this.currentSequenceIndex = 0; // Loop back
|
||||
}
|
||||
this.playSequenceAd();
|
||||
},
|
||||
|
||||
prevSequenceAd() {
|
||||
this.currentSequenceIndex--;
|
||||
if (this.currentSequenceIndex < 0) {
|
||||
this.currentSequenceIndex = this.sequenceAds.length - 1;
|
||||
}
|
||||
this.playSequenceAd();
|
||||
},
|
||||
|
||||
openPreview(ad) {
|
||||
this.previewAd = { ...ad };
|
||||
this.isPreviewOpen = true;
|
||||
},
|
||||
|
||||
// Sort Reordering logic
|
||||
moveUp(position, index) {
|
||||
if (index > 0) {
|
||||
const list = this.machineAds[position];
|
||||
const temp = list[index];
|
||||
list[index] = list[index - 1];
|
||||
list[index - 1] = temp;
|
||||
this.syncSortOrder(position);
|
||||
}
|
||||
},
|
||||
|
||||
moveDown(position, index) {
|
||||
const list = this.machineAds[position];
|
||||
if (index < list.length - 1) {
|
||||
const temp = list[index];
|
||||
list[index] = list[index + 1];
|
||||
list[index + 1] = temp;
|
||||
this.syncSortOrder(position);
|
||||
}
|
||||
},
|
||||
|
||||
async syncSortOrder(position) {
|
||||
const list = this.machineAds[position];
|
||||
const assignmentIds = list.map(item => item.id);
|
||||
try {
|
||||
const response = await fetch(this.urls.reorder, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ assignment_ids: assignmentIds })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
window.showToast?.(result.message, 'success');
|
||||
} else {
|
||||
window.showToast?.(result.message || 'Error', 'error');
|
||||
this.fetchMachineAds(); // 如果更新失敗,重取恢復畫面原本樣子
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to update sort order', e);
|
||||
window.showToast?.('System Error', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
handleFileChange(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validation
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const maxSize = isVideo ? 50 * 1024 * 1024 : 10 * 1024 * 1024; // 50MB for video, 10MB for image
|
||||
|
||||
if (file.size > maxSize) {
|
||||
window.showToast?.(`{{ __("File is too large") }} (${isVideo ? '50MB' : '10MB'} MAX)`, 'error');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.fileName = file.name;
|
||||
|
||||
// Set form type based on file
|
||||
if (isVideo) this.adForm.type = 'video';
|
||||
else if (isImage) this.adForm.type = 'image';
|
||||
|
||||
// Local Preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
this.mediaPreview = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Update Select UI
|
||||
this.$nextTick(() => {
|
||||
window.HSSelect.getInstance('#ad_type_select')?.setValue(this.adForm.type);
|
||||
});
|
||||
},
|
||||
|
||||
removeMedia() {
|
||||
this.fileName = '';
|
||||
this.mediaPreview = null;
|
||||
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
|
||||
// If editing, we still keep the original url in adForm.url but the UI shows "empty"
|
||||
},
|
||||
|
||||
async submitAdForm() {
|
||||
const form = this.$refs.adFormEl;
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Basic validation
|
||||
if (!this.adForm.name) {
|
||||
window.showToast?.('{{ __("Please enter a name") }}', 'error');
|
||||
return;
|
||||
}
|
||||
if (this.adFormMode === 'add' && !this.fileName) {
|
||||
window.showToast?.('{{ __("Please select a file") }}', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = this.adFormMode === 'add' ? this.urls.store : this.urls.update.replace(':id', this.adForm.id);
|
||||
if (this.adFormMode === 'edit') formData.append('_method', 'PUT');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
window.showToast?.(result.message, 'success');
|
||||
this.isAdModalOpen = false;
|
||||
location.reload(); // Reload to refresh the list
|
||||
} else {
|
||||
window.showToast?.(result.message || 'Error', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to submit ad', e);
|
||||
window.showToast?.('System Error', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(this.assignForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Server error response:', errorText);
|
||||
throw new Error(`Server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.isAssignModalOpen = false;
|
||||
this.fetchMachineAds();
|
||||
window.showToast?.(result.message, 'success');
|
||||
} else {
|
||||
window.showToast?.(result.message || 'Error', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to assign ad', e);
|
||||
}
|
||||
},
|
||||
|
||||
init() {
|
||||
this.urls = JSON.parse(this.$el.dataset.urls);
|
||||
this.machines = JSON.parse(this.$el.dataset.machines || '[]');
|
||||
this.allAds = JSON.parse(this.$el.dataset.allAds || '[]');
|
||||
this.activeTab = this.$el.dataset.activeTab || 'list';
|
||||
|
||||
// Sync custom selects when modals open
|
||||
this.$watch('isAdModalOpen', value => {
|
||||
if (value) {
|
||||
this.$nextTick(() => {
|
||||
window.HSSelect.getInstance('#ad_type_select')?.setValue(this.adForm.type);
|
||||
window.HSSelect.getInstance('#ad_duration_select')?.setValue(this.adForm.duration.toString());
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
},
|
||||
|
||||
selectMachine(id) {
|
||||
if (!id || id === ' ') {
|
||||
this.selectedMachineId = null;
|
||||
return;
|
||||
}
|
||||
this.selectedMachineId = id;
|
||||
this.fetchMachineAds();
|
||||
},
|
||||
|
||||
async fetchMachineAds() {
|
||||
const url = this.urls.getMachineAds.replace(':id', this.selectedMachineId);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.machineAds = {
|
||||
vending: result.data.vending || [],
|
||||
visit_gift: result.data.visit_gift || [],
|
||||
standby: result.data.standby || []
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch machine ads', e);
|
||||
}
|
||||
},
|
||||
|
||||
openAddModal() {
|
||||
this.adFormMode = 'add';
|
||||
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;
|
||||
},
|
||||
|
||||
openEditModal(ad) {
|
||||
this.adFormMode = 'edit';
|
||||
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;
|
||||
},
|
||||
|
||||
openAssignModal(pos) {
|
||||
this.assignForm = {
|
||||
machine_id: this.selectedMachineId,
|
||||
advertisement_id: '',
|
||||
position: pos,
|
||||
sort_order: this.machineAds[pos]?.length || 0
|
||||
};
|
||||
|
||||
this.updateAssignSelect();
|
||||
this.isAssignModalOpen = true;
|
||||
},
|
||||
|
||||
updateAssignSelect() {
|
||||
const machine = this.machines.find(m => m.id == this.selectedMachineId);
|
||||
const companyId = machine ? machine.company_id : null;
|
||||
|
||||
// 篩選出同公司的素材(或是系統層級的共通素材如果 company_id 為 null)
|
||||
// 若沒有特別設定,通常 null 為系統共用
|
||||
const filteredAds = this.allAds.filter(ad => ad.company_id == companyId || ad.company_id == null);
|
||||
|
||||
const wrapper = document.getElementById('assign_ad_select_wrapper');
|
||||
if (!wrapper) return;
|
||||
wrapper.innerHTML = '';
|
||||
|
||||
const selectEl = document.createElement('select');
|
||||
selectEl.name = 'advertisement_id';
|
||||
selectEl.id = 'assign_ad_select_' + Date.now();
|
||||
selectEl.className = 'hidden';
|
||||
|
||||
const configStr = JSON.stringify({
|
||||
"placeholder": "{{ __('Please select a material') }}",
|
||||
"hasSearch": true,
|
||||
"searchPlaceholder": "{{ __('Search...') }}",
|
||||
"isHidePlaceholder": false,
|
||||
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
|
||||
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
|
||||
"toggleClasses": "hs-select-toggle luxury-select-toggle",
|
||||
"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', configStr);
|
||||
|
||||
if (filteredAds.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = "{{ __('No materials available') }}";
|
||||
opt.disabled = true;
|
||||
selectEl.appendChild(opt);
|
||||
} else {
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = '';
|
||||
emptyOpt.textContent = "{{ __('Please select a material') }}";
|
||||
selectEl.appendChild(emptyOpt);
|
||||
|
||||
filteredAds.forEach(ad => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ad.id;
|
||||
opt.textContent = `${ad.name} (${ad.type === 'video' ? "{{ __('video') }}" : "{{ __('image') }}"}, ${ad.duration}s)`;
|
||||
opt.setAttribute('data-title', opt.textContent);
|
||||
if (ad.id === this.assignForm.advertisement_id) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
wrapper.appendChild(selectEl);
|
||||
|
||||
// Set the initial value after appending but before autoInit
|
||||
if (this.assignForm.advertisement_id) {
|
||||
selectEl.value = this.assignForm.advertisement_id;
|
||||
}
|
||||
|
||||
selectEl.addEventListener('change', (e) => {
|
||||
this.assignForm.advertisement_id = e.target.value;
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
|
||||
window.HSStaticMethods.autoInit(['select']);
|
||||
|
||||
// If we have a value, ensure the Preline instance reflects it
|
||||
if (this.assignForm.advertisement_id) {
|
||||
setTimeout(() => {
|
||||
window.HSSelect.getInstance(selectEl)?.setValue(this.assignForm.advertisement_id);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async removeAssignment(id) {
|
||||
if (!confirm("{{ __('Are you sure you want to remove this assignment?') }}")) return;
|
||||
|
||||
const url = this.urls.removeAssignment.replace(':id', id);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.fetchMachineAds();
|
||||
window.showToast?.(result.message, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to remove assignment', e);
|
||||
}
|
||||
},
|
||||
|
||||
confirmDelete(id) {
|
||||
this.deleteFormAction = this.urls.delete.replace(':id', id);
|
||||
this.isDeleteConfirmOpen = true;
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
222
resources/views/admin/ads/partials/ad-modal.blade.php
Normal file
222
resources/views/admin/ads/partials/ad-modal.blade.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<div x-show="isAdModalOpen"
|
||||
class="fixed inset-0 z-[100] overflow-y-auto"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
|
||||
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="isAdModalOpen = false"></div>
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen p-4 sm:p-0">
|
||||
<div class="relative inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-xl sm:w-full overflow-visible animate-luxury-in">
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight" x-text="adFormMode === 'add' ? '{{ __("Add Advertisement") }}' : '{{ __("Edit Advertisement") }}'"></h3>
|
||||
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{{ __('Manage your ad material details') }}</p>
|
||||
</div>
|
||||
<button @click="isAdModalOpen = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
||||
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form x-ref="adFormEl"
|
||||
:action="adFormMode === 'add' ? urls.store : urls.update.replace(':id', adForm.id)"
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
@submit.prevent="submitAdForm"
|
||||
class="space-y-6">
|
||||
@csrf
|
||||
<template x-if="adFormMode === 'edit'">
|
||||
@method('PUT')
|
||||
</template>
|
||||
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
{{ __('Company Name') }}
|
||||
</label>
|
||||
<x-searchable-select
|
||||
name="company_id"
|
||||
id="ad_company_select"
|
||||
:has-search="true"
|
||||
:selected="null"
|
||||
:options="['' => __('System Default (All Companies)')] + $companies->pluck('name', 'id')->toArray()"
|
||||
@change="adForm.company_id = $event.target.value"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
{{ __('Material Name') }}
|
||||
<span class="text-rose-500 ml-0.5">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" x-model="adForm.name" required
|
||||
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 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="{{ __('Enter ad material name') }}">
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ __('Material Type') }}
|
||||
<span class="text-rose-500 ml-0.5">*</span>
|
||||
</label>
|
||||
<x-searchable-select
|
||||
name="type"
|
||||
id="ad_type_select"
|
||||
:has-search="false"
|
||||
:selected="null"
|
||||
:options="['image' => __('image'), 'video' => __('video')]"
|
||||
@change="adForm.type = $event.target.value; removeMedia();"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
|
||||
{{ __('Duration (Seconds)') }}
|
||||
<span class="text-rose-500 ml-0.5">*</span>
|
||||
</label>
|
||||
<x-searchable-select
|
||||
name="duration"
|
||||
id="ad_duration_select"
|
||||
:has-search="false"
|
||||
:selected="null"
|
||||
:options="['15' => '15 ' . __('Seconds'), '30' => '30 ' . __('Seconds'), '60' => '60 ' . __('Seconds')]"
|
||||
@change="adForm.duration = $event.target.value"
|
||||
/>
|
||||
</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">
|
||||
<span x-text="adForm.type === 'image' ? '{{ __("Upload Image") }}' : '{{ __("Upload Video") }}'"></span>
|
||||
<template x-if="adFormMode === 'add'">
|
||||
<span class="text-rose-500 ml-0.5">*</span>
|
||||
</template>
|
||||
</label>
|
||||
|
||||
<div class="relative group">
|
||||
<template x-if="!mediaPreview">
|
||||
<div @click="$refs.fileInput.click()" class="aspect-video rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 flex flex-col items-center justify-center gap-4 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800/80 hover:border-cyan-500/50 transition-all duration-300 group">
|
||||
<div class="p-4 rounded-2xl bg-white dark:bg-slate-800 shadow-sm border border-slate-100 dark:border-slate-700 group-hover:scale-110 group-hover:text-cyan-500 transition-all duration-300">
|
||||
<svg class="size-8 text-slate-400 dark:text-slate-500 group-hover:text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||
</div>
|
||||
<div class="text-center px-4">
|
||||
<p class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-tighter">{{ __('Click to upload') }}</p>
|
||||
<p class="text-[10px] font-bold text-slate-400 mt-1 uppercase" x-text="adForm.type === 'image' ? 'JPG, PNG, WEBP (Max 10MB)' : 'MP4, MOV (Max 50MB)'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="mediaPreview">
|
||||
<div class="relative aspect-video rounded-3xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl group/media">
|
||||
<template x-if="adForm.type === 'image'">
|
||||
<img :src="mediaPreview" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="adForm.type === 'video'">
|
||||
<div class="w-full h-full bg-slate-900 flex items-center justify-center">
|
||||
<video :src="mediaPreview" class="max-w-full max-h-full object-contain" muted></video>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="p-4 rounded-full bg-block/20 backdrop-blur-sm border border-white/10">
|
||||
<svg class="size-8 text-white/50" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="absolute inset-0 bg-slate-950/40 opacity-0 group-hover/media:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-3">
|
||||
<button type="button" @click="$refs.fileInput.click()" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-cyan-500 hover:text-white transition-all duration-300 shadow-lg">
|
||||
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
|
||||
</button>
|
||||
<button type="button" @click="openPreview(adFormMode === 'edit' ? (fileName ? {url: mediaPreview, type: adForm.type, name: fileName} : adForm) : {url: mediaPreview, type: adForm.type, name: fileName})" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-emerald-500 hover:text-white transition-all duration-300 shadow-lg">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</button>
|
||||
<button type="button" @click="removeMedia" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-rose-500 hover:text-white transition-all duration-300 shadow-lg" x-show="mediaPreview">
|
||||
<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="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<input type="file" name="file" x-ref="fileInput"
|
||||
:accept="adForm.type === 'image' ? 'image/jpeg,image/png,image/gif,image/webp' : 'video/mp4,video/quicktime,video/x-msvideo'"
|
||||
class="hidden" @change="handleFileChange">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center gap-3 px-2">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="is_active" x-model="adForm.is_active" :value="adForm.is_active ? '1' : '0'" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div>
|
||||
</label>
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-200 tracking-tight">{{ __('Active Status') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Footer -->
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" @click="isAdModalOpen = false" class="px-6 py-3 text-sm font-black text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors uppercase tracking-widest">
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn-luxury-primary px-10 py-3">
|
||||
<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="m4.5 12.75 6 6 9-13.5" /></svg>
|
||||
<span>{{ __('Save Material') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
84
resources/views/admin/ads/partials/assign-modal.blade.php
Normal file
84
resources/views/admin/ads/partials/assign-modal.blade.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<div x-show="isAssignModalOpen"
|
||||
class="fixed inset-0 z-[100] overflow-y-auto"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
|
||||
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="isAssignModalOpen = false"></div>
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen p-4 sm:p-0">
|
||||
<div class="relative inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full overflow-visible animate-luxury-in">
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Assign Advertisement') }}</h3>
|
||||
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{{ __('Select a material to play on this machine') }}</p>
|
||||
</div>
|
||||
<button @click="isAssignModalOpen = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
||||
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitAssignment" class="space-y-6">
|
||||
<!-- Machine & Position Info (Read-only) -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border border-slate-100 dark:border-white/5">
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Target Position') }}</p>
|
||||
<p class="text-sm font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-tight" x-text="{ vending: '{{ __('vending') }}', visit_gift: '{{ __('visit_gift') }}', standby: '{{ __('standby') }}' }[assignForm.position] || assignForm.position"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ad Material Selection -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">{{ __('Select Material') }}</label>
|
||||
<div id="assign_ad_select_wrapper">
|
||||
<!-- Select dropdown will be dynamically built by updateAssignSelect() based on machine company context -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Action Footer -->
|
||||
<div class="flex items-center justify-end gap-3 pt-6">
|
||||
<button type="button" @click="isAssignModalOpen = false" class="px-6 py-3 text-sm font-black text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors uppercase tracking-widest">
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn-luxury-primary px-10 py-3">
|
||||
{{ __('Confirm Assignment') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Note: This logic is added to the main adManager data object in index.blade.php
|
||||
// Here we just define the submit handler
|
||||
async function submitAssignment() {
|
||||
try {
|
||||
const response = await fetch(this.urls.assign, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(this.assignForm)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.isAssignModalOpen = false;
|
||||
this.fetchMachineAds();
|
||||
window.showToast?.(result.message, 'success');
|
||||
} else {
|
||||
window.showToast?.(result.message || 'Error', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to assign ad', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -34,7 +34,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="edit-form" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6">
|
||||
<form id="edit-form" x-data="{
|
||||
imagePreviews: [
|
||||
{{ isset($machine->images[0]) ? "'" . Storage::disk('public')->url($machine->images[0]) . "'" : 'null' }},
|
||||
{{ isset($machine->images[1]) ? "'" . Storage::disk('public')->url($machine->images[1]) . "'" : 'null' }},
|
||||
{{ isset($machine->images[2]) ? "'" . Storage::disk('public')->url($machine->images[2]) . "'" : 'null' }}
|
||||
],
|
||||
removeImages: [false, false, false],
|
||||
handleImageUpload(event, index) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.imagePreviews[index] = e.target.result;
|
||||
this.removeImages[index] = false;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
removeImage(index) {
|
||||
this.imagePreviews[index] = null;
|
||||
this.removeImages[index] = true;
|
||||
if (this.$refs['imageInput_' + index]) this.$refs['imageInput_' + index].value = '';
|
||||
}
|
||||
}" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
@@ -58,7 +80,9 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="flex flex-col lg:flex-row gap-8 items-start">
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 space-y-8 order-2 lg:order-1 capitalize">
|
||||
<!-- Left: Basic info & Hardware -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Basic Information -->
|
||||
@@ -74,12 +98,12 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Name') }}</label>
|
||||
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Name') }} <span class="text-rose-500">*</span></label>
|
||||
<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>
|
||||
@@ -195,9 +219,73 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: System & Payment -->
|
||||
<div class="space-y-8">
|
||||
<!-- Right Column: Images & Primary System Settings -->
|
||||
<div class="w-full lg:w-96 space-y-8 order-1 lg:order-2 lg:sticky top-24">
|
||||
<!-- Machine Images -->
|
||||
<div class="luxury-card rounded-[2.5rem] p-8 animate-luxury-in">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-500">
|
||||
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="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 Images') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
@for($i = 0; $i < 3; $i++)
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between px-1 text-xs font-black text-slate-400 uppercase tracking-widest">
|
||||
<span>{{ __('Photo Slot') }} {{ $i + 1 }}</span>
|
||||
<template x-if="imagePreviews[{{ $i }}]">
|
||||
<span class="text-emerald-500 flex items-center gap-1 lowercase tracking-normal font-bold">
|
||||
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="m4.5 12.75 6 6 9-13.5"/></svg>
|
||||
{{ __('set') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<input type="hidden" name="remove_image_{{ $i }}" :value="removeImages[{{ $i }}] ? '1' : '0'">
|
||||
|
||||
<template x-if="!imagePreviews[{{ $i }}]">
|
||||
<div @click="$refs.imageInput_{{ $i }}.click()" class="aspect-video rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 flex flex-col items-center justify-center gap-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800/80 hover:border-cyan-500/50 transition-all duration-300 group/upload">
|
||||
<div class="p-2.5 rounded-xl bg-white dark:bg-slate-800 shadow-sm border border-slate-100 dark:border-slate-700 group-hover/upload:scale-110 group-hover/upload:text-cyan-500 transition-all duration-300 text-slate-400">
|
||||
<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="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-tighter">{{ __('Click to upload') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="imagePreviews[{{ $i }}]">
|
||||
<div class="relative aspect-video rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-lg group/image">
|
||||
<img :src="imagePreviews[{{ $i }}]" class="w-full h-full object-cover">
|
||||
<div class="absolute inset-0 bg-slate-950/40 opacity-0 group-hover/image:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-3">
|
||||
<button type="button" @click="$refs.imageInput_{{ $i }}.click()" class="p-2.5 rounded-xl bg-white text-slate-800 hover:bg-cyan-500 hover:text-white transition-all duration-300 shadow-lg">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
|
||||
</button>
|
||||
<button type="button" @click="removeImage({{ $i }})" class="p-2.5 rounded-xl bg-white text-slate-800 hover:bg-rose-500 hover:text-white transition-all duration-300 shadow-lg">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<input type="file" name="image_{{ $i }}" x-ref="imageInput_{{ $i }}" class="hidden" accept="image/*" @change="handleImageUpload($event, {{ $i }})">
|
||||
</div>
|
||||
</div>
|
||||
@endfor
|
||||
|
||||
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-white/5">
|
||||
<p class="text-[10px] font-bold text-slate-400 leading-relaxed uppercase tracking-widest text-center">
|
||||
{{ __('PNG, JPG, WEBP up to 10MB') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-20" style="animation-delay: 200ms">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-2 pb-20" x-data="{
|
||||
tab: '{{ $tab }}',
|
||||
showCreateMachineModal: false,
|
||||
showPhotoModal: false,
|
||||
showDetailDrawer: false,
|
||||
@@ -18,14 +19,17 @@
|
||||
showMaintenanceQrModal: false,
|
||||
maintenanceQrMachineName: '',
|
||||
maintenanceQrUrl: '',
|
||||
permissionSearchQuery: '',
|
||||
openMaintenanceQr(machine) {
|
||||
this.maintenanceQrMachineName = machine.name;
|
||||
const baseUrl = '{{ route('admin.maintenance.create', ['serial_no' => 'SERIAL_NO']) }}';
|
||||
this.maintenanceQrUrl = baseUrl.replace('SERIAL_NO', machine.serial_no);
|
||||
this.showMaintenanceQrModal = true;
|
||||
},
|
||||
openDetail(machine) {
|
||||
openDetail(machine, id, serial) {
|
||||
this.currentMachine = machine;
|
||||
window.activeMachineId = id || machine?.id;
|
||||
window.activeMachineSerial = serial || machine?.serial_no;
|
||||
this.showDetailDrawer = true;
|
||||
},
|
||||
openPhotoModal(machine) {
|
||||
@@ -60,14 +64,137 @@
|
||||
confirmDelete(action) {
|
||||
this.deleteFormAction = action;
|
||||
this.isDeleteConfirmOpen = true;
|
||||
},
|
||||
// API Token Management
|
||||
showApiToken: false,
|
||||
loadingRegenerate: false,
|
||||
isRegenerateConfirmOpen: false,
|
||||
copyToken(machine) {
|
||||
if (!machine?.api_token) return;
|
||||
navigator.clipboard.writeText(machine.api_token).then(() => {
|
||||
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('API Token Copied') }}', type: 'success' } }));
|
||||
});
|
||||
},
|
||||
regenerateToken() {
|
||||
this.isRegenerateConfirmOpen = true;
|
||||
},
|
||||
executeRegeneration(id, serial) {
|
||||
// 僅使用機台序號 (Serial Number) 作為識別碼
|
||||
const targetSerial = serial || window.activeMachineSerial || id;
|
||||
|
||||
if (!targetSerial) {
|
||||
console.error('ExecuteRegeneration failed: No serial number available');
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: '{{ __('Missing machine identification') }}', type: 'error' }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('ExecuteRegeneration using serial:', targetSerial);
|
||||
this.isRegenerateConfirmOpen = false;
|
||||
this.loadingRegenerate = true;
|
||||
|
||||
fetch(`/admin/basic-settings/machines/${targetSerial}/regenerate-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=\'csrf-token\']').content,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(res => res.json()).then(data => {
|
||||
this.loadingRegenerate = false;
|
||||
if(data.success) {
|
||||
if (this.currentMachine) {
|
||||
this.currentMachine.api_token = data.api_token;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message, type: 'success' } }));
|
||||
}
|
||||
}).catch(() => {
|
||||
this.loadingRegenerate = false;
|
||||
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Error processing request') }}', type: 'error' } }));
|
||||
});
|
||||
},
|
||||
// Permission Management
|
||||
showPermissionModal: false,
|
||||
isPermissionsLoading: false,
|
||||
targetUserId: null,
|
||||
targetUserName: '',
|
||||
allMachines: [],
|
||||
allMachinesCount: 0,
|
||||
permissions: {},
|
||||
openPermissionModal(user) {
|
||||
this.targetUserId = user.id;
|
||||
this.targetUserName = user.name;
|
||||
this.showPermissionModal = true;
|
||||
this.isPermissionsLoading = true;
|
||||
this.permissions = {};
|
||||
this.allMachines = [];
|
||||
this.permissionSearchQuery = '';
|
||||
|
||||
fetch(`/admin/machines/permissions/accounts/${user.id}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.machines) {
|
||||
this.allMachines = data.machines;
|
||||
this.allMachinesCount = data.machines.length;
|
||||
const tempPermissions = {};
|
||||
data.machines.forEach(m => {
|
||||
tempPermissions[m.id] = (data.assigned_ids || []).includes(m.id);
|
||||
});
|
||||
this.permissions = tempPermissions;
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Failed to load permissions') }}', type: 'error' } }));
|
||||
})
|
||||
.finally(() => {
|
||||
this.isPermissionsLoading = false;
|
||||
});
|
||||
},
|
||||
togglePermission(machineId) {
|
||||
this.permissions = { ...this.permissions, [machineId]: !this.permissions[machineId] };
|
||||
},
|
||||
toggleSelectAll() {
|
||||
const filtered = this.allMachines.filter(m =>
|
||||
!this.permissionSearchQuery ||
|
||||
m.name.toLowerCase().includes(this.permissionSearchQuery.toLowerCase()) ||
|
||||
m.serial_no.toLowerCase().includes(this.permissionSearchQuery.toLowerCase())
|
||||
);
|
||||
if (filtered.length === 0) return;
|
||||
const allSelected = filtered.every(m => this.permissions[m.id]);
|
||||
filtered.forEach(m => this.permissions[m.id] = !allSelected);
|
||||
},
|
||||
savePermissions() {
|
||||
const machineIds = Object.keys(this.permissions).filter(id => this.permissions[id]);
|
||||
|
||||
fetch(`/admin/machines/permissions/accounts/${this.targetUserId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=\'csrf-token\']').content,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ machine_ids: machineIds })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message, type: 'success' } }));
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
throw new Error(data.error || 'Update failed');
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
window.dispatchEvent(new CustomEvent('toast', { detail: { message: e.message, type: 'error' } }));
|
||||
});
|
||||
}
|
||||
}">
|
||||
}" @execute-regenerate.window="executeRegeneration($event.detail)">
|
||||
<!-- 1. Header Area -->
|
||||
<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')
|
||||
@@ -77,7 +204,7 @@
|
||||
</svg>
|
||||
<span>{{ __('Add Machine') }}</span>
|
||||
</button>
|
||||
@else
|
||||
@elseif($tab === 'models')
|
||||
<button @click="showCreateModelModal = true" class="btn-luxury-primary flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
@@ -98,26 +225,43 @@
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'models' ? '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' }}">
|
||||
{{ __('Models') }}
|
||||
</a>
|
||||
<a href="{{ route('admin.basic-settings.machines.index', ['tab' => 'permissions']) }}"
|
||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'permissions' ? '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' }}">
|
||||
{{ __('Machine Permissions') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 2. Main Content Card -->
|
||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in mt-6">
|
||||
<!-- Toolbar & Filters -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<form method="GET" action="{{ route('admin.basic-settings.machines.index') }}" class="relative group">
|
||||
<input type="hidden" name="tab" value="{{ $tab }}">
|
||||
<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" name="search" value="{{ request('search') }}"
|
||||
placeholder="{{ $tab === 'machines' ? __('Search machines...') : __('Search models...') }}"
|
||||
class="luxury-input py-2.5 pl-12 pr-6 block w-64">
|
||||
</form>
|
||||
<div class="flex items-center gap-4">
|
||||
<form method="GET" action="{{ route('admin.basic-settings.machines.index') }}" class="relative group">
|
||||
<input type="hidden" name="tab" value="{{ $tab }}">
|
||||
<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" name="search" value="{{ request('search') }}"
|
||||
placeholder="{{ $tab === 'machines' ? __('Search machines...') : ($tab === 'models' ? __('Search models...') : __('Search accounts...')) }}"
|
||||
class="luxury-input py-2.5 pl-12 pr-6 block w-64">
|
||||
</form>
|
||||
|
||||
@if($tab === 'permissions' && auth()->user()->isSystemAdmin())
|
||||
<div class="w-72">
|
||||
<form method="GET" action="{{ route('admin.basic-settings.machines.index') }}">
|
||||
<input type="hidden" name="tab" value="permissions">
|
||||
<input type="hidden" name="search" value="{{ request('search') }}">
|
||||
<x-searchable-select name="company_id" :options="$companies" :selected="request('company_id')"
|
||||
:placeholder="__('All Companies')" onchange="this.form.submit()" />
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($tab === 'machines')
|
||||
@@ -149,10 +293,10 @@
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
@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 cursor-pointer" @click="openDetail({{ $machine->toJson() }})">
|
||||
<td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden">
|
||||
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300 overflow-hidden">
|
||||
@if(isset($machine->image_urls[0]))
|
||||
<img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover">
|
||||
@else
|
||||
@@ -175,7 +319,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6" @click="openDetail({{ $machine->toJson() }})">
|
||||
<td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
|
||||
<span
|
||||
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
|
||||
{{ $machine->machineModel->name ?? '--' }}
|
||||
@@ -183,7 +327,7 @@
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
@php
|
||||
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInMinutes() < 5;
|
||||
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInSeconds() < 30;
|
||||
@endphp <div class="flex items-center gap-2.5">
|
||||
<div class="relative flex h-2.5 w-2.5">
|
||||
@if($isOnline)
|
||||
@@ -212,7 +356,7 @@
|
||||
<td class="px-6 py-6">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
|
||||
{{ $machine->company->name ?? __('None') }}
|
||||
{{ $machine->company->name ?? __('System') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right flex items-center justify-end gap-2">
|
||||
@@ -245,8 +389,8 @@
|
||||
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"
|
||||
title="{{ __('View Details') }}">
|
||||
<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" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
</td>
|
||||
@@ -266,6 +410,108 @@
|
||||
{{ $machines->appends(['tab' => 'machines'])->links('vendor.pagination.luxury') }}
|
||||
</div>
|
||||
|
||||
@elseif($tab === 'permissions')
|
||||
<!-- Permissions Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
||||
{{ __('Account Info') }}</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-left">
|
||||
{{ __('Company Name') }}</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">
|
||||
{{ __('Authorized Machines') }}</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">
|
||||
{{ __('Action') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
@forelse($users_list as $user)
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-6 font-display text-left">
|
||||
<div class="flex items-center gap-4 text-left">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
||||
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col text-left">
|
||||
<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">{{
|
||||
$user->name }}</span>
|
||||
<span
|
||||
class="text-xs font-mono font-bold text-slate-500 tracking-widest uppercase">{{
|
||||
$user->username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-left">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest uppercase">
|
||||
{{ $user->company->name ?? __('System') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<div
|
||||
class="flex flex-wrap gap-2 justify-center lg:justify-start max-w-[420px] mx-auto lg:mx-0 max-h-[140px] overflow-y-auto pr-2 custom-scrollbar py-1 text-left">
|
||||
@forelse($user->machines as $m)
|
||||
<div
|
||||
class="flex flex-col px-3 py-1.5 rounded-xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-white/5 hover:border-cyan-500/30 transition-all duration-300 text-left">
|
||||
<span class="text-[11px] font-black text-slate-700 dark:text-slate-200 leading-tight">{{
|
||||
$m->name }}</span>
|
||||
<span class="text-[9px] font-mono font-bold text-cyan-500 tracking-tighter mt-0.5 opacity-80">{{
|
||||
$m->serial_no }}</span>
|
||||
</div>
|
||||
@empty
|
||||
<div class="w-full text-center lg:text-left">
|
||||
<span
|
||||
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest opacity-40 italic">--
|
||||
{{ __('None') }} --</span>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<button
|
||||
@click='openPermissionModal({{ json_encode(["id" => $user->id, "name" => $user->name]) }})'
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500 hover:text-white transition-all duration-300 text-xs font-black uppercase tracking-widest shadow-sm shadow-cyan-500/5 group/auth">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 00-2 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>{{ __('Authorize') }}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-24 text-center">
|
||||
<div class="flex flex-col items-center gap-3 opacity-20">
|
||||
<svg class="size-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6 text-left mb-6">
|
||||
@if($users_list)
|
||||
{{ $users_list->appends(['tab' => 'permissions'])->links('vendor.pagination.luxury') }}
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@else
|
||||
<!-- Model Table -->
|
||||
<div class="overflow-x-auto">
|
||||
@@ -292,7 +538,7 @@
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300">
|
||||
class="flex-shrink-0 w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
@@ -380,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">
|
||||
@@ -396,23 +641,26 @@
|
||||
<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">{{
|
||||
__('Machine Name') }}</label>
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
|
||||
{{ __('Machine Name') }} <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" required class="luxury-input w-full"
|
||||
placeholder="{{ __('Enter machine name') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
|
||||
__('Serial No') }}</label>
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
|
||||
{{ __('Serial No') }} <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="serial_no" required class="luxury-input w-full"
|
||||
placeholder="{{ __('Enter serial number') }}">
|
||||
</div>
|
||||
<div class="relative z-20">
|
||||
<label
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
|
||||
__('Owner') }}</label>
|
||||
<x-searchable-select name="company_id" required :placeholder="__('Select Owner')">
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
|
||||
{{ __('Owner') }}
|
||||
</label>
|
||||
<x-searchable-select name="company_id" :placeholder="__('Select Owner')">
|
||||
@foreach($companies as $company)
|
||||
<option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}">
|
||||
{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}
|
||||
@@ -422,15 +670,17 @@
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
|
||||
__('Location') }}</label>
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
|
||||
{{ __('Location') }}
|
||||
</label>
|
||||
<input type="text" name="location" class="luxury-input w-full"
|
||||
placeholder="{{ __('Enter machine location') }}">
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<label
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
|
||||
__('Model') }}</label>
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
|
||||
{{ __('Model') }} <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<x-searchable-select name="machine_model_id" required :placeholder="__('Select Model')">
|
||||
@foreach($models as $model)
|
||||
<option value="{{ $model->id }}">{{ $model->name }}</option>
|
||||
@@ -439,8 +689,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
|
||||
__('Machine Images') }} ({{ __('Max 3') }})</label>
|
||||
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
|
||||
{{ __('Machine Images') }} ({{ __('Max 3') }})
|
||||
</label>
|
||||
<label
|
||||
class="relative flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl cursor-pointer bg-slate-50/50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-800/80 transition-all group">
|
||||
<template x-if="selectedFileCount === 0">
|
||||
@@ -453,6 +704,9 @@
|
||||
<p
|
||||
class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest">
|
||||
{{ __('Click to upload') }}</p>
|
||||
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 mt-1">
|
||||
{{ __('PNG, JPG, WEBP up to 10MB') }} ({{ __('Max 3') }})
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="selectedFileCount > 0">
|
||||
@@ -475,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>
|
||||
@@ -500,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">
|
||||
@@ -517,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>
|
||||
@@ -550,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">
|
||||
@@ -567,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>
|
||||
@@ -686,8 +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.')
|
||||
}}
|
||||
{{ __('PNG, JPG, WEBP up to 10MB') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -739,7 +985,6 @@
|
||||
</div>
|
||||
|
||||
<!-- Helper text -->
|
||||
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 text-white/40 text-[10px] font-bold uppercase tracking-[0.3em] pointer-events-none">
|
||||
{{ __('Click anywhere to close') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -772,7 +1017,7 @@
|
||||
|
||||
<div class="p-10 flex flex-col items-center gap-6">
|
||||
<div class="p-4 bg-white rounded-3xl shadow-xl border border-slate-100">
|
||||
<img :src="'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(maintenanceQrUrl)"
|
||||
<img :src="'{{ route('admin.basic-settings.qr-code') }}?data=' + encodeURIComponent(maintenanceQrUrl)"
|
||||
class="w-48 h-48"
|
||||
alt="{{ __('Maintenance QR Code') }}">
|
||||
</div>
|
||||
@@ -812,10 +1057,10 @@
|
||||
<div
|
||||
class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
|
||||
<div
|
||||
class="px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
|
||||
class="px-6 py-3 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-black text-slate-800 dark:text-white">{{ __('Parameters') }}</h2>
|
||||
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mt-1"
|
||||
<p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em] mt-1"
|
||||
x-text="currentMachine?.name"></p>
|
||||
</div>
|
||||
<button @click="showDetailDrawer = false"
|
||||
@@ -826,10 +1071,10 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 custom-scrollbar">
|
||||
<div class="flex-1 overflow-y-auto px-6 pt-1 pb-6 space-y-6 custom-scrollbar">
|
||||
<template x-if="currentMachine?.image_urls && currentMachine.image_urls.length > 0">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-[11px] font-black text-indigo-500 uppercase tracking-[0.3em]">{{
|
||||
<h3 class="text-xs font-black text-indigo-500 uppercase tracking-[0.3em]">{{
|
||||
__('Machine Images') }}</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<template x-for="(url, index) in currentMachine.image_urls" :key="index">
|
||||
@@ -848,28 +1093,27 @@
|
||||
</section>
|
||||
</template>
|
||||
<section class="space-y-6">
|
||||
<h3 class="text-[11px] 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">
|
||||
<span
|
||||
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{
|
||||
class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{
|
||||
__('Serial & Version') }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-mono font-bold text-slate-700 dark:text-slate-300"
|
||||
<div class="text-sm font-mono font-bold text-slate-700 dark:text-slate-300"
|
||||
x-text="currentMachine?.serial_no"></div>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-md bg-white dark:bg-slate-900 text-[9px] font-black text-slate-500 border border-slate-100 dark:border-slate-800"
|
||||
class="px-2 py-0.5 rounded-md bg-white dark:bg-slate-900 text-[10px] font-black text-slate-500 border border-slate-100 dark:border-slate-800"
|
||||
x-text="'v' + (currentMachine?.firmware_version || '1.0')"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
|
||||
<span
|
||||
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{
|
||||
class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{
|
||||
__('Heartbeat') }}</span>
|
||||
<div class="text-xs font-bold text-slate-700 dark:text-slate-300"
|
||||
<div class="text-sm font-bold text-slate-700 dark:text-slate-300"
|
||||
x-text="currentMachine?.last_heartbeat_at ? new Date(currentMachine.last_heartbeat_at).toLocaleString() : '--'">
|
||||
</div>
|
||||
</div>
|
||||
@@ -878,37 +1122,64 @@
|
||||
|
||||
<!-- Operational Settings -->
|
||||
<section class="space-y-6">
|
||||
<h3 class="text-[11px] font-black text-amber-500 uppercase tracking-[0.3em]">{{
|
||||
<h3 class="text-xs font-black text-amber-500 uppercase tracking-[0.3em]">{{
|
||||
__('Operations') }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5">
|
||||
<span class="text-xs font-bold text-slate-500">{{ __('Heating Range') }}</span>
|
||||
<span class="text-xs font-black text-slate-700 dark:text-slate-300"
|
||||
<span class="text-sm font-bold text-slate-500">{{ __('Heating Range') }}</span>
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-300"
|
||||
x-text="(currentMachine?.heating_start_time ? currentMachine.heating_start_time.substring(0, 5) : '00:00') + ' ~ ' + (currentMachine?.heating_end_time ? currentMachine.heating_end_time.substring(0, 5) : '00:00')"></span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5">
|
||||
<span class="text-xs font-bold text-slate-500">{{ __('Card Reader No') }}</span>
|
||||
<span class="text-xs font-black text-slate-700 dark:text-slate-300"
|
||||
<span class="text-sm font-bold text-slate-500">{{ __('Card Reader No') }}</span>
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-300"
|
||||
x-text="currentMachine?.card_reader_no || '--'"></span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5">
|
||||
<span class="text-xs font-bold text-slate-500">{{ __('API Token') }}</span>
|
||||
<span class="text-[10px] font-mono text-slate-400 truncate max-w-[150px]"
|
||||
x-text="currentMachine?.api_token || '--'"></span>
|
||||
<div class="flex flex-col gap-3 p-3 mt-1 bg-slate-50 dark:bg-slate-800/40 rounded-xl border border-slate-100 dark:border-slate-700/50 relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-black text-slate-500 uppercase tracking-widest">{{ __('API Token') }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<template x-if="currentMachine?.api_token">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="showApiToken = !showApiToken"
|
||||
class="p-1.5 rounded-lg text-slate-400 hover:text-cyan-500 hover:bg-cyan-50 dark:hover:bg-cyan-900/40 transition-all font-bold"
|
||||
:title="showApiToken ? '{{ __('Hide') }}' : '{{ __('Show') }}'">
|
||||
<svg x-show="!showApiToken" 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 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
<svg x-show="showApiToken" x-cloak 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/></svg>
|
||||
</button>
|
||||
<button @click="copyToken(currentMachine)"
|
||||
class="p-1.5 rounded-lg text-slate-400 hover:text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 transition-all font-bold"
|
||||
title="{{ __('Copy') }}">
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button @click="regenerateToken()" :disabled="loadingRegenerate"
|
||||
class="ml-2 px-2.5 py-1.5 rounded-lg bg-rose-50 dark:bg-rose-500/10 text-rose-500 hover:bg-rose-100 dark:hover:bg-rose-500/20 text-xs font-black uppercase tracking-widest transition-all disabled:opacity-50 flex items-center gap-1.5 border border-rose-100 dark:border-rose-500/20"
|
||||
title="{{ __('Regenerate') }}">
|
||||
<svg x-show="loadingRegenerate" class="animate-spin w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
<svg x-show="!loadingRegenerate" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="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>
|
||||
<span>{{ __('Regenerate') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900/50 rounded-lg border border-slate-200 dark:border-slate-700/50 p-2.5 overflow-x-auto custom-scrollbar">
|
||||
<span class="text-sm font-mono font-bold tracking-[0.1em] text-cyan-600 dark:text-cyan-400 select-all block whitespace-nowrap min-w-full"
|
||||
x-text="currentMachine?.api_token ? (showApiToken ? currentMachine.api_token : '•'.repeat(16)) : '{{ __('None') }}'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Location -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-[11px] font-black text-emerald-500 uppercase tracking-[0.3em]">{{
|
||||
<h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{
|
||||
__('Location') }}</h3>
|
||||
<div
|
||||
class="p-4 bg-emerald-50/30 dark:bg-emerald-500/5 rounded-2xl border border-emerald-100/50 dark:border-emerald-500/10">
|
||||
<p class="text-xs text-emerald-700 dark:text-emerald-400 leading-relaxed font-bold"
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-400 leading-relaxed font-bold"
|
||||
x-text="currentMachine?.location || '{{ __('No location set') }}'"></p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -926,6 +1197,175 @@
|
||||
<!-- Global Delete Confirm Modal -->
|
||||
<x-delete-confirm-modal />
|
||||
|
||||
<x-confirm-modal
|
||||
alpine-var="isRegenerateConfirmOpen"
|
||||
confirm-action="isRegenerateConfirmOpen = false; window.dispatchEvent(new CustomEvent('execute-regenerate', { detail: window.activeMachineSerial || window.activeMachineId }))"
|
||||
icon-type="warning"
|
||||
confirm-color="sky"
|
||||
:title="__('Are you sure?')"
|
||||
:message="__('Regenerating the token will disconnect the physical machine until it is updated. Continue?')"
|
||||
:confirm-text="__('Yes, regenerate')"
|
||||
/>
|
||||
|
||||
<!-- Machine Permissions Modal -->
|
||||
<template x-teleport='body'>
|
||||
<div x-show='showPermissionModal' class='fixed inset-0 z-[160] overflow-y-auto' x-cloak>
|
||||
<div class='flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0'>
|
||||
<div x-show='showPermissionModal' @click='showPermissionModal = false'
|
||||
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'></div>
|
||||
|
||||
<span class='hidden sm:inline-block sm:align-middle sm:h-screen'>​</span>
|
||||
|
||||
<div x-show='showPermissionModal' 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 px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full overflow-hidden animate-luxury-in'>
|
||||
|
||||
<div class='flex justify-between items-center mb-8'>
|
||||
<div>
|
||||
<h3 class='text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight'>
|
||||
{{ __('Authorized Machines Management') }}</h3>
|
||||
<div class='flex items-center gap-2 mt-1 drop-shadow-sm'>
|
||||
<span class='text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]'>{{
|
||||
__('Account') }}:</span>
|
||||
<span class='text-xs font-bold text-cyan-500 uppercase tracking-widest'
|
||||
x-text='targetUserName'></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click='showPermissionModal = false'
|
||||
class='text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors bg-slate-50 dark:bg-slate-800 p-2 rounded-xl'>
|
||||
<svg class='size-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2.5'
|
||||
d='M6 18L18 6M6 6l12 12' />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class='relative min-h-[400px]'>
|
||||
<div class='mb-6 flex flex-col md:flex-row gap-4 items-center'>
|
||||
<div class='flex-1 relative group w-full text-left'>
|
||||
<span class='absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10'>
|
||||
<svg class='size-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'
|
||||
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='permissionSearchQuery'
|
||||
placeholder='{{ __("Search machines...") }}'
|
||||
class='luxury-input py-3 pl-12 pr-6 block w-full text-sm font-extrabold' @click.stop>
|
||||
</div>
|
||||
<button @click="toggleSelectAll()"
|
||||
class="shrink-0 flex items-center gap-2 px-6 py-3 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200 dark:border-slate-700 font-black text-xs uppercase tracking-widest shadow-sm">
|
||||
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<span
|
||||
x-text="allMachines.filter(m => !permissionSearchQuery || m.name.toLowerCase().includes(permissionSearchQuery.toLowerCase()) || m.serial_no.toLowerCase().includes(permissionSearchQuery.toLowerCase())).every(m => permissions[m.id]) ? '{{ __('Deselect All') }}' : '{{ __('Select All') }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if='isPermissionsLoading'>
|
||||
<div
|
||||
class='absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm z-[170] rounded-2xl'>
|
||||
<div class='flex flex-col items-center gap-3'>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-[450px] overflow-y-auto pr-2 custom-scrollbar p-1'>
|
||||
<template
|
||||
x-for='machine in allMachines.filter(m => !permissionSearchQuery || m.name.toLowerCase().includes(permissionSearchQuery.toLowerCase()) || m.serial_no.toLowerCase().includes(permissionSearchQuery.toLowerCase()))'
|
||||
:key='machine.id'>
|
||||
<div @click='togglePermission(machine.id)'
|
||||
:class='permissions[machine.id] ? "border-cyan-500 bg-cyan-500/5 dark:bg-cyan-500/10 ring-1 ring-cyan-500/20 shadow-md shadow-cyan-500/10" : "border-slate-100 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-600 shadow-sm"'
|
||||
class='p-4 rounded-2xl border-2 cursor-pointer transition-all duration-300 group relative overflow-hidden'>
|
||||
<div class='flex flex-col relative z-10 text-left'>
|
||||
<div class='flex items-center gap-2'>
|
||||
<div class='size-2 rounded-full'
|
||||
:class='permissions[machine.id] ? "bg-cyan-500 animate-pulse" : "bg-slate-300 dark:bg-slate-700"'>
|
||||
</div>
|
||||
<span class='text-sm font-extrabold truncate drop-shadow-sm'
|
||||
:class='permissions[machine.id] ? "text-cyan-600 dark:text-cyan-400" : "text-slate-700 dark:text-slate-300"'
|
||||
x-text='machine.name'></span>
|
||||
</div>
|
||||
<span
|
||||
class='text-[10px] font-mono font-bold text-slate-400 mt-2 tracking-widest uppercase opacity-70'
|
||||
x-text='machine.serial_no'></span>
|
||||
</div>
|
||||
<div
|
||||
class='absolute -right-2 -bottom-2 opacity-[0.03] text-slate-900 dark:text-white pointer-events-none group-hover:scale-110 transition-transform duration-700'>
|
||||
<svg class='size-20' fill='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
d='M5 2h14c1.1 0 2 .9 2 2v16c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2zm0 2v16h14V4H5zm3 3h8v6H8V7zm0 8h3v2H8v-2zm5 0h3v2h-3v-2z' />
|
||||
</svg>
|
||||
</div>
|
||||
<div class='absolute top-4 right-4 animate-luxury-in'
|
||||
x-show='permissions[machine.id]'>
|
||||
<div
|
||||
class='size-5 rounded-full bg-cyan-500 flex items-center justify-center shadow-lg shadow-cyan-500/30'>
|
||||
<svg class='size-3 text-white' fill='none' stroke='currentColor'
|
||||
viewBox='0 0 24 24'>
|
||||
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='3'
|
||||
d='M5 13l4 4L19 7' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class='flex flex-col sm:flex-row justify-between items-center mt-10 pt-8 border-t border-slate-100 dark:border-slate-800 gap-6'>
|
||||
<div class='flex items-center gap-3'>
|
||||
<div class='flex -space-x-2'>
|
||||
<template x-for='i in Math.min(3, Object.values(permissions).filter(v => v).length)'
|
||||
:key='i'>
|
||||
<div
|
||||
class='size-6 rounded-full border-2 border-white dark:border-slate-900 bg-cyan-500 flex items-center justify-center shadow-sm'>
|
||||
<svg class='size-3 text-white' fill='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14.5v-9l6 4.5-6 4.5z' />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class='text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]'>
|
||||
{{ __('Selection') }}: <span class='text-cyan-500 text-xs font-extrabold'
|
||||
x-text='Object.values(permissions).filter(v => v).length'></span> / <span
|
||||
class="font-extrabold" x-text='allMachines?.length || 0'></span> {{ __('Devices') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class='flex gap-4 w-full sm:w-auto'>
|
||||
<button @click='showPermissionModal = false'
|
||||
class='flex-1 sm:flex-none btn-luxury-ghost px-8'>{{ __('Cancel') }}</button>
|
||||
<button @click='savePermissions()' class='flex-1 sm:flex-none btn-luxury-primary px-12 transition-all duration-300 shadow-lg shadow-cyan-500/20'
|
||||
:disabled='isPermissionsLoading'>
|
||||
<span>{{ __('Update Authorization') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -2,32 +2,125 @@
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6" x-data="{
|
||||
|
||||
showModal: false,
|
||||
showHistoryModal: false,
|
||||
editing: false,
|
||||
sidebarView: 'detail',
|
||||
currentCompany: {
|
||||
id: '',
|
||||
name: '',
|
||||
code: '',
|
||||
original_type: 'lease',
|
||||
current_type: 'lease',
|
||||
tax_id: '',
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
contact_email: '',
|
||||
valid_until: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
warranty_start_date: '',
|
||||
warranty_end_date: '',
|
||||
software_start_date: '',
|
||||
software_end_date: '',
|
||||
status: 1,
|
||||
note: ''
|
||||
note: '',
|
||||
settings: {
|
||||
enable_material_code: false,
|
||||
enable_points: false
|
||||
}
|
||||
},
|
||||
openCreateModal() {
|
||||
this.editing = false;
|
||||
this.currentCompany = { id: '', name: '', code: '', tax_id: '', contact_name: '', contact_phone: '', contact_email: '', valid_until: '', status: 1, note: '' };
|
||||
this.currentCompany = {
|
||||
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
|
||||
tax_id: '', contact_name: '', contact_phone: '',
|
||||
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;
|
||||
},
|
||||
openEditModal(company) {
|
||||
this.editing = true;
|
||||
this.currentCompany = { ...company };
|
||||
this.currentCompany = {
|
||||
...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
|
||||
}
|
||||
};
|
||||
this.originalStatus = company.status;
|
||||
this.showModal = true;
|
||||
},
|
||||
isDeleteConfirmOpen: false,
|
||||
deleteFormAction: ''
|
||||
deleteFormAction: '',
|
||||
isStatusConfirmOpen: false,
|
||||
originalStatus: 1,
|
||||
toggleFormAction: '',
|
||||
statusToggleSource: 'edit',
|
||||
showDetail: false,
|
||||
detailCompany: {
|
||||
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
|
||||
tax_id: '', contact_name: '', contact_phone: '', 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 },
|
||||
users_count: 0, machines_count: 0,
|
||||
contracts: []
|
||||
},
|
||||
openDetailSidebar(company) {
|
||||
this.detailCompany = {
|
||||
...company,
|
||||
settings: {
|
||||
enable_material_code: company.settings?.enable_material_code || false,
|
||||
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">
|
||||
@@ -103,6 +196,9 @@
|
||||
<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">
|
||||
{{ __('Customer 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">
|
||||
{{ __('Business Type') }}</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">
|
||||
{{ __('Status') }}</th>
|
||||
@@ -111,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">
|
||||
{{ __('Valid Until') }}</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>
|
||||
@@ -131,9 +227,11 @@
|
||||
</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">{{
|
||||
$company->name }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<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">{{
|
||||
$company->name }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-mono font-bold text-slate-500 dark:text-slate-400 mt-0.5 tracking-widest uppercase">{{
|
||||
$company->code }}</span>
|
||||
@@ -141,14 +239,28 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="flex items-center gap-2 min-w-[100px] justify-center">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest w-8 text-right">{{ __('Original:') }}</span>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-bold {{ $company->original_type === 'buyout' ? 'bg-amber-500/10 text-amber-600' : 'bg-blue-500/10 text-blue-600' }} uppercase tracking-wider">
|
||||
{{ __($company->original_type === 'buyout' ? 'Buyout' : 'Lease') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 min-w-[100px] justify-center">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest w-8 text-right">{{ __('Current:') }}</span>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-bold {{ $company->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' }} uppercase tracking-wider">
|
||||
{{ __($company->current_type === 'buyout' ? 'Buyout' : 'Lease') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center py-6">
|
||||
@if($company->status)
|
||||
<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">
|
||||
<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-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700 tracking-widest uppercase">
|
||||
<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
|
||||
@@ -166,15 +278,77 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-center">
|
||||
<span
|
||||
class="text-[13px] font-bold font-display tracking-widest {{ $company->valid_until && $company->valid_until->isPast() ? 'text-rose-500' : 'text-slate-600 dark:text-slate-300' }}">
|
||||
{{ $company->valid_until ? $company->valid_until->format('Y/m/d') : __('Permanent') }}
|
||||
</span>
|
||||
<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>
|
||||
@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">
|
||||
<div class="flex items-center justify-end gap-x-2">
|
||||
<button @click="openEditModal({{ json_encode($company) }})"
|
||||
@if($company->status)
|
||||
<button type="button"
|
||||
@click="toggleFormAction = '{{ route('admin.permission.companies.status.toggle', $company->id) }}'; statusToggleSource = 'list'; isStatusConfirmOpen = true"
|
||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 transition-all border border-transparent hover:border-amber-500/20"
|
||||
title="{{ __('Disable') }}">
|
||||
<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="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
@else
|
||||
<button type="button"
|
||||
@click="toggleFormAction = '{{ route('admin.permission.companies.status.toggle', $company->id) }}'; $nextTick(() => $refs.statusToggleForm.submit())"
|
||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20"
|
||||
title="{{ __('Enable') }}">
|
||||
<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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 0 1 0 1.971l-11.54 6.347c-.75.412-1.667-.13-1.667-.986V5.653z" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
<button @click="statusToggleSource = 'edit'; openEditModal({{ json_encode($company) }})"
|
||||
class="p-2 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">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
stroke-width="2.5">
|
||||
@@ -191,6 +365,14 @@
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" @click="openDetailSidebar({{ json_encode($company) }})"
|
||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-indigo-500 hover:bg-indigo-500/5 transition-all border border-transparent hover:border-indigo-500/20"
|
||||
title="{{ __('View Details') }}">
|
||||
<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="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -251,8 +433,16 @@
|
||||
</div>
|
||||
|
||||
<form
|
||||
x-ref="companyForm"
|
||||
:action="editing ? '{{ url('admin/permission/companies') }}/' + currentCompany.id : '{{ route('admin.permission.companies.store') }}'"
|
||||
method="POST" class="space-y-6">
|
||||
method="POST" class="space-y-6"
|
||||
@submit.prevent="
|
||||
if (editing && currentCompany.status == '0' && originalStatus == '1') {
|
||||
isStatusConfirmOpen = true;
|
||||
} else {
|
||||
$el.submit();
|
||||
}
|
||||
">
|
||||
@csrf
|
||||
<input type="hidden" name="_method" :value="editing ? 'PUT' : 'POST'">
|
||||
|
||||
@@ -264,16 +454,63 @@
|
||||
__('Company Information') }}</h4>
|
||||
</div>
|
||||
|
||||
<!-- Business Type Selector -->
|
||||
<div class="p-4 rounded-2xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 space-y-4">
|
||||
<label class="text-[11px] font-black text-slate-500 uppercase tracking-widest pl-1">
|
||||
{{ __('Business Type') }}
|
||||
<span class="text-rose-500 ml-0.5">*</span>
|
||||
</label>
|
||||
|
||||
<!-- 新增模式:顯示切換按鈕 -->
|
||||
<div x-show="!editing" class="flex p-1.5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 w-fit">
|
||||
<button type="button" @click="currentCompany.original_type = 'lease'"
|
||||
:class="currentCompany.original_type === 'lease' ? 'bg-blue-500 text-white shadow-lg shadow-blue-500/20' : 'text-slate-400 hover:text-slate-600'"
|
||||
class="px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
|
||||
{{ __('Lease') }}
|
||||
</button>
|
||||
<button type="button" @click="currentCompany.original_type = 'buyout'"
|
||||
:class="currentCompany.original_type === 'buyout' ? 'bg-amber-500 text-white shadow-lg shadow-amber-500/20' : 'text-slate-400 hover:text-slate-600'"
|
||||
class="px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
|
||||
{{ __('Buyout') }}
|
||||
</button>
|
||||
<input type="hidden" name="original_type" :value="currentCompany.original_type">
|
||||
</div>
|
||||
|
||||
<!-- 編輯模式:顯示原始類型固定值 + 當前類型切換 -->
|
||||
<div x-show="editing" class="flex flex-col gap-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[11px] font-black text-slate-400 uppercase tracking-widest">{{ __('Original Type') }}: <span class="text-rose-500 ml-0.5">*</span></span>
|
||||
<span class="px-4 py-1.5 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs font-bold uppercase tracking-widest text-slate-600 dark:text-slate-400" x-text="currentCompany.original_type === 'buyout' ? '{{ __('Buyout') }}' : '{{ __('Lease') }}'"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[11px] font-black text-slate-400 uppercase tracking-widest">{{ __('Current Type') }}: <span class="text-rose-500 ml-0.5">*</span></span>
|
||||
<div class="flex p-1.5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 w-fit">
|
||||
<button type="button" @click="currentCompany.current_type = 'lease'"
|
||||
:class="currentCompany.current_type === 'lease' ? 'bg-blue-500 text-white shadow-lg shadow-blue-500/20' : 'text-slate-400 hover:text-slate-600'"
|
||||
class="px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
|
||||
{{ __('Lease') }}
|
||||
</button>
|
||||
<button type="button" @click="currentCompany.current_type = 'buyout'"
|
||||
:class="currentCompany.current_type === 'buyout' ? 'bg-amber-500 text-white shadow-lg shadow-amber-500/20' : 'text-slate-400 hover:text-slate-600'"
|
||||
class="px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
|
||||
{{ __('Buyout') }}
|
||||
</button>
|
||||
<input type="hidden" name="current_type" :value="currentCompany.current_type">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||||
__('Company Name') }}</label>
|
||||
__('Company Name') }} <span class="text-rose-500 ml-0.5">*</span></label>
|
||||
<input type="text" name="name" x-model="currentCompany.name" required
|
||||
class="luxury-input w-full" placeholder="{{ __('e.g. Taiwan Star') }}">
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||||
__('Company Code') }}</label>
|
||||
__('Company Code') }} <span class="text-rose-500 ml-0.5">*</span></label>
|
||||
<input type="text" name="code" x-model="currentCompany.code" required
|
||||
class="luxury-input w-full" placeholder="{{ __('e.g. TWSTAR') }}">
|
||||
</div>
|
||||
@@ -286,13 +523,53 @@
|
||||
<input type="text" name="tax_id" x-model="currentCompany.tax_id"
|
||||
class="luxury-input w-full">
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||||
__('Contract Until (Optional)') }}</label>
|
||||
<input type="date" name="valid_until" x-model="currentCompany.valid_until"
|
||||
class="luxury-input w-full">
|
||||
<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="currentCompany.current_type === 'lease'"
|
||||
class="luxury-input w-full px-2">
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||||
__('End Date') }}</label>
|
||||
<input type="date" name="end_date" x-model="currentCompany.end_date"
|
||||
class="luxury-input w-full px-2">
|
||||
</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 -->
|
||||
@@ -315,8 +592,20 @@
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||||
__('Password') }}</label>
|
||||
<input type="password" name="admin_password" class="luxury-input w-full"
|
||||
placeholder="{{ __('Min 8 characters') }}">
|
||||
<div x-data="{ show: false }" class="relative items-center">
|
||||
<input :type="show ? 'text' : 'password'" name="admin_password" class="luxury-input w-full pr-12"
|
||||
placeholder="{{ __('Min 8 characters') }}">
|
||||
<button type="button" @click="show = !show"
|
||||
class="absolute inset-y-0 end-0 flex items-center z-20 px-4 cursor-pointer text-slate-400 hover:text-cyan-500 transition-colors">
|
||||
<svg x-show="!show" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<svg x-show="show" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -341,7 +630,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<div class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800 relative z-10">
|
||||
<div class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800 relative z-20">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-6 w-1 bg-amber-500 rounded-full"></div>
|
||||
<h4 class="text-xs font-black text-slate-800 dark:text-white uppercase tracking-widest">{{
|
||||
@@ -368,17 +657,10 @@
|
||||
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||||
__('Status') }}</label>
|
||||
<x-searchable-select
|
||||
id="company-status"
|
||||
name="status"
|
||||
x-model="currentCompany.status"
|
||||
:hasSearch="false"
|
||||
x-init="$watch('currentCompany.status', (value) => {
|
||||
$nextTick(() => {
|
||||
const inst = HSSelect.getInstance($el);
|
||||
if (inst) {
|
||||
inst.setValue(String(value));
|
||||
}
|
||||
});
|
||||
})"
|
||||
>
|
||||
<option value="1">{{ __('Active') }}</option>
|
||||
<option value="0">{{ __('Disabled') }}</option>
|
||||
@@ -392,6 +674,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Toggles Section -->
|
||||
<div class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800 relative z-[5]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-6 w-1 bg-cyan-500 rounded-full"></div>
|
||||
<h4 class="text-xs font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ __('Feature Settings') }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Material Code Toggle -->
|
||||
<div class="flex items-center justify-between p-4 rounded-2xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 transition-all hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<div class="space-y-0.5">
|
||||
<label class="text-sm font-black text-slate-700 dark:text-slate-200 uppercase tracking-wide">{{ __('Enable Material Code') }}</label>
|
||||
<p class="text-xs text-slate-400 font-medium">{{ __('Show material code field in products') }}</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="hidden" name="settings[enable_material_code]" value="0">
|
||||
<input type="checkbox" name="settings[enable_material_code]" value="1" x-model="currentCompany.settings.enable_material_code" class="peer sr-only">
|
||||
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Points Toggle -->
|
||||
<div class="flex items-center justify-between p-4 rounded-2xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 transition-all hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<div class="space-y-0.5">
|
||||
<label class="text-sm font-black text-slate-700 dark:text-slate-200 uppercase tracking-wide">{{ __('Enable Points') }}</label>
|
||||
<p class="text-xs text-slate-400 font-medium">{{ __('Show points rules in products') }}</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="hidden" name="settings[enable_points]" value="0">
|
||||
<input type="checkbox" name="settings[enable_points]" value="1" x-model="currentCompany.settings.enable_points" class="peer sr-only">
|
||||
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex justify-end gap-x-4 pt-8">
|
||||
<button type="button" @click="showModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
|
||||
@@ -405,6 +724,328 @@
|
||||
</div>
|
||||
|
||||
<x-delete-confirm-modal :message="__('Are you sure to delete this customer?')" />
|
||||
<x-status-confirm-modal :title="__('停用客戶確認')" :message="__('停用此客戶將會連帶停用該客戶下的所有帳號,確定要繼續嗎?')" />
|
||||
|
||||
<form x-ref="statusToggleForm" :action="toggleFormAction" method="POST" class="hidden">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
</form>
|
||||
|
||||
<!-- Details Sidebar -->
|
||||
<template x-teleport="body">
|
||||
<div x-show="showDetail" class="fixed inset-0 z-[150]" x-cloak>
|
||||
<!-- Overlay -->
|
||||
<div x-show="showDetail"
|
||||
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"
|
||||
@click="showDetail = false"
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity"></div>
|
||||
|
||||
<!-- Sidebar Content -->
|
||||
<div class="fixed inset-y-0 right-0 max-w-full flex">
|
||||
<div class="w-screen max-w-md"
|
||||
x-show="showDetail"
|
||||
x-transition:enter="transform transition ease-in-out duration-500"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transform transition ease-in-out duration-500"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="translate-x-full">
|
||||
|
||||
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
|
||||
<!-- 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" 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>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showDetail = false" class="p-2 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-slate-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<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>
|
||||
<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 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Current Status') }}</span>
|
||||
<template x-if="detailCompany.status">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]"></span>
|
||||
<span class="text-sm font-black text-emerald-500 uppercase tracking-widest">{{ __('Active') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!detailCompany.status">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]"></span>
|
||||
<span class="text-sm font-black text-rose-500 uppercase tracking-widest">{{ __('Disabled') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</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 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>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature Settings Section -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-black text-indigo-500 uppercase tracking-[0.3em]">{{ __('Feature Settings') }}</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-white dark:bg-slate-800/20 p-4 rounded-2xl border border-slate-100 dark:border-slate-800/50 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 text-indigo-500">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 11h.01M7 15h.01M13 7h.01M13 11h.01M13 15h.01M17 7h.01M17 11h.01M17 15h.01" /></svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ __('Material Code') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-black uppercase tracking-widest"
|
||||
:class="detailCompany.settings?.enable_material_code ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-100 dark:bg-slate-800 text-slate-400'">
|
||||
<span x-text="detailCompany.settings?.enable_material_code ? '{{ __('Enabled') }}' : '{{ __('Disabled') }}'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800/20 p-4 rounded-2xl border border-slate-100 dark:border-slate-800/50 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-amber-50 dark:bg-amber-500/10 text-amber-500">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||
<span class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ __('Points Rule') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-black uppercase tracking-widest"
|
||||
:class="detailCompany.settings?.enable_points ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-100 dark:bg-slate-800 text-slate-400'">
|
||||
<span x-text="detailCompany.settings?.enable_points ? '{{ __('Enabled') }}' : '{{ __('Disabled') }}'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Basic Info Section -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Basic Information') }}</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">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Tax ID') }}</span>
|
||||
<div class="text-sm font-mono font-bold text-slate-700 dark:text-slate-300" x-text="detailCompany.tax_id || '--'"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 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">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Contact Name') }}</span>
|
||||
<div class="text-sm font-bold text-slate-700 dark:text-slate-300" x-text="detailCompany.contact_name || '--'"></div>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Phone') }}</span>
|
||||
<div class="text-sm font-mono font-bold text-slate-700 dark:text-slate-300" x-text="detailCompany.contact_phone || '--'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Email') }}</span>
|
||||
<div class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate" x-text="detailCompany.contact_email || '--'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistics Section -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-black text-amber-500 uppercase tracking-[0.3em]">{{ __('Statistics') }}</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-amber-50 dark:bg-amber-500/5 p-6 rounded-2xl border border-amber-100 dark:border-amber-500/10 text-center">
|
||||
<p class="text-2xl font-black text-slate-800 dark:text-white" x-text="detailCompany.users_count || 0"></p>
|
||||
<p class="text-xs font-black text-slate-400 uppercase tracking-widest mt-1">{{ __('Users') }}</p>
|
||||
</div>
|
||||
<div class="bg-cyan-50 dark:bg-cyan-500/5 p-6 rounded-2xl border border-cyan-100 dark:border-cyan-500/10 text-center">
|
||||
<p class="text-2xl font-black text-slate-800 dark:text-white" x-text="detailCompany.machines_count || 0"></p>
|
||||
<p class="text-xs font-black text-slate-400 uppercase tracking-widest mt-1">{{ __('Machines') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<section class="space-y-4 pb-8" x-show="detailCompany.note">
|
||||
<h3 class="text-xs font-black text-slate-400 uppercase tracking-[0.3em]">{{ __('Notes') }}</h3>
|
||||
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
|
||||
<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 -->
|
||||
<div class="p-8 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<button @click="showDetail = false" class="w-full btn-luxury-ghost py-4 rounded-xl font-black uppercase tracking-[0.2em] text-xs">
|
||||
{{ __('Close Panel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
</style>
|
||||
|
||||
@endsection
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user