Compare commits

..

22 Commits

Author SHA1 Message Date
f49938d1a7 [DOCS] 補齊 B024, B027, B055 API 技術規格與文檔定義
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m1s
1. 更新技術手冊 (SKILL.md) 與 API 規格文件 (api-docs.php),補齊 B024, B027, B055 之定義。
2. 修正文件序號跳號問題,確保 B017 序號正確銜接。
3. 詳細說明 B055 之指令 ID 遠端出貨運作機制。
2026-04-14 17:36:56 +08:00
2702e5a655 [REFACTOR] 標準化 IoT API 方法並修正日期格式
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 57s
1. 將 B014 (參數下載) 與 B017 (貨道同步) 路由從 POST 改為 GET。
2. 移除 B017 冗餘的 machine 參數,改由 Bearer Token 自動識別。
3. 修正 B017 回傳資料中 expiry_date 的 ISO 8601 格式問題,統一為 Y-m-d。
4. 同步更新所有技術規格文件 (.agents/ 規範) 與 API 說明配置 (config/api-docs.php)。
2026-04-14 16:46:35 +08:00
6382709b90 [DOCS] 更新開發進度表
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 55s
1. 標記「遠端管理」模組為已完成。
2. 標記「儀表板」模組為已完成。
2026-04-14 16:18:05 +08:00
66f7c1ffb8 [FEAT] 實作 B017 貨道庫存全量同步 API 與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
1. 實作 B017 (reload_msg) 端點,支援貨道庫存、效期與批號的全量同步。
2. 將 B017 回傳欄位映射至 App 需求:slot_no -> tid, stock -> num。
3. 新增 expiry_date (效期) 與 batch_no (批號) 欄位支援。
4. 實作指令狀態閉環邏輯,成功同步後自動將相關 reload_stock 指令標記為成功。
5. 將指令備註欄位多語系化,新增「庫存已與機台同步」繁中、英文、日文翻譯。
6. 更新 API 技術規格文件 (.agents/skills) 與系統 API 文件配置 (config/api-docs.php)。
2026-04-14 15:06:51 +08:00
32fa28dc0f [DOCS]:初始化 MQTT 架構實作計畫與相關技術規範
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m12s
1. 新增 MQTT 即時通訊與 Topic 規範文件 (.agents/skills/mqtt-communication-specs/SKILL.md)。
2. 建立 MQTT 基礎架構實作計畫文件 (docs/mqtt-implementation-plan.md)。
3. 更新全域開發框架規範 (framework.md),納入 Go Gateway 與 EMQX 架構說明。
4. 重構 IoT 通訊處理規範 (iot-communication/SKILL.md),支援 HTTP 與 MQTT 雙軌管線。
5. 更新背景 API 規範 (api-rules.md) 與技能觸發規則 (skill-trigger.md) 以符合新架構。
2026-04-14 13:02:08 +08:00
daf8b1ebcc [FEAT] 強化機台技術員 Token 安全性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
1. 為 B000 登入發放之技術員 Token 增加 8 小時有效期限,防止 Token 永久有效之風險。
2026-04-13 17:24:57 +08:00
8f008ffb61 [FEAT] 實作 B014 機台參數下載 API 與 B000 登入認證強化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 強化 B000 登入接口:驗證成功後回傳 Sanctum Token 供後續初始化使用。
2. 實作 B014 (getSettings) API:整合機台、金流與發票設定,並映射至 Android App 預期欄位。
3. 強化安全性:B014 API 掛載 auth:sanctum 並執行 RBAC 權限檢查。
4. 更新 API 說明文件 (iot-spec.md, api-docs.php) 及技術規範 (SKILL.md)。
2026-04-13 17:04:52 +08:00
729890d7c7 [DOCS] 更新通訊系統與全域工具列開發藍圖
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
1. 更新 docs/future_todo.md:詳列第一階段核心工具(工具列、公告系統、快捷入口、身分模擬)與第二階段行銷功能(盲盒抽獎)的開發時程。
2026-04-13 16:19:37 +08:00
ad256d3d3b [FEAT] 廣告排程功能與 UI 優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 新增廣告排程功能,支援設定發布時間與下架時間。
2. 整合 Flatpickr 時間選擇器,提供與機台日誌一致的極簡奢華風 UI。
3. 優化廣告列表中的數字字體,套用 font-mono 與 tabular-nums,與客戶管理模組風格同步。
4. 修正 Alpine.js 資料同步邏輯,確保編輯模式下排程時間能正確回填。
2026-04-13 11:44:26 +08:00
5415b14a53 [DOCS] 更新 .gitignore 以排除 pptx 目錄
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m10s
1. 在 .gitignore 中新增 /docs/pptx 路徑,避免產出的簡報檔案意外進入版本控制。
2026-04-13 11:01:21 +08:00
c97776892e [FEAT] 優化客戶合約管理介面與修復日期偏移問題
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 整合「客戶詳情」與「合約歷程」至單一側邊欄,改用分頁 (Tabs) 切換介面。
2. 優化清單「服務期程」顯示:根據客戶模式(租賃/買斷)動態顯示對應期間,並使用完整文字標籤取代縮寫。
3. 修復日期 Bug:在 Company 模型指定日期序列化格式為 Y-m-d,解決時區轉換導致的日期減少一天問題。
4. 新增合約歷程資料表模型、遷移檔以及對應的多語系翻譯(中、英、日)。
5. 移除清單操作列中重複的合約歷程圖示。
2026-04-08 17:41:26 +08:00
a599b14df1 [FEAT] 優化機台硬體通訊協議與管理介面互動性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m6s
1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。
2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。
3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。
4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。
5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
2026-04-08 14:52:00 +08:00
c343df34ee [FEAT] 實作 B012 商品同步 API 與統一圖片絕對網址格式
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
1. 實作 B012 API:新增 /api/v1/app/machine/products/B012 端點,支援 GET (全量) 與 PATCH (增量) 同步邏輯。
2. 統一圖片 URL:在 B005 與 B012 API 中使用 asset() 確保回傳絕對網址 (Absolute URL),解決 App 端下載相對路徑的問題。
3. 文件更新:同步更新 SKILL.md 的欄位定義,並在 api-docs.php 補上 B012 的正式規格說明。
4. 資料庫變更:新增 machine_slots 表的 type 欄位與相關註解遷移。
5. 格式優化:為技術規格文件中的 API 欄位與狀態碼加上反引號,提升文件中心可讀性。
2026-04-07 17:05:28 +08:00
253ae8afd4 [FEAT] 實作機台序號編輯功能與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
1. 更新機台控制器 (MachineController),在更新時執行 serial_no 的必填與唯一性驗證。
2. 修改機台管理首頁 (index.blade.php),在快速編輯彈窗中加入機台序號輸入欄位。
3. 修正基礎設定中機台編輯頁面 (edit.blade.php),將原本唯讀的機台序號欄位改為可編輯輸入框,並加入必填標記。
4. 補齊並統一繁體中文、英文、日文翻譯檔中關於「機台序號」的翻譯 Key。
2026-04-07 14:55:24 +08:00
f2147ae6c4 [FEAT] 完善 IoT API 規范化、機台管理介面優化與 B005 改為 GET
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
1. 將 B005 (廣告同步) 從 POST 改為 GET,符合 RESTful 規範。
2. 完善 B009 (庫存回報) 回應規格,加入業務代碼 (200 OK)。
3. API 文件 UI 優化:新增 Method Badge (方法標籤),並修正 JSON 中文/斜線轉義問題。
4. 機台管理介面優化:實作「唯讀庫存與效期」面板,並將日誌圖示改為「👁️」。
5. 標準化 ID 識別邏輯:資料表全面移除對 sku 的依賴,改以 id 為主、barcode 為輔。
6. 新增 Migration:正式移除 sku 欄位並同步 barcode 指向。
7. 更新多語系支援 (zh_TW, en, ja)。
2026-04-07 14:37:57 +08:00
b60afc3abe [FIX] 修正與標準化 B005 廣告下載 API 以相容既有 Android App
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
1. 修改 MachineController@getAdvertisements 回傳欄位,將 t070v02 改為播放秒數,t070v03 改為位置代碼 (Flag)。
2. 根據 Android App 原始碼分析結果,調整位置對應:3 為待機廣告 (HomeActivity),1 為販賣頁廣告 (FontendActivity)。
3. 在 AdvertisementController@getMachineAds 增加 sort_order 排序,確保後台管理介面視圖與 API 輸出順序一致。
4. 更新廣告下載 API 的技術文件 (SKILL.md),明確標記欄位用途與位置代碼。
5. 在 routes/api.php 補上 B005 與 B009 的路由定義。
2026-04-07 11:41:24 +08:00
bbdc5bad9f [FEAT] 重構機台狀態判定邏輯並優化全站多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m18s
1. 重構機台在線狀態判定機制:移除資料庫 status 欄位,改由 Model 根據心跳時間動態計算。
2. 修正儀表板 (Dashboard) 與機台管理頁面的多語系顯示問題,解決換行導致翻譯失效的 Bug。
3. 修正個人檔案頁面的麵包屑 (Breadcrumbs) 導航,補齊「個人設定」層級。
4. 更新 IoT API (B010, B600) 的認證機制與日誌處理邏輯。
5. 同步更新繁中、英文、日文語言檔,確保 UI 標籤一致性。
2026-04-07 10:21:07 +08:00
08b6c60d2e [FEAT] 實作 B000 機台登入 API 並同步 API 規格文件
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 45s
1. 於 routes/api.php 新增 B000 登入驗證路由,並設定速率限制。
2. 更新 .agents/skills/api-technical-specs/SKILL.md,補上 B000 規格並根據 Java App 實作精簡 B010 指令碼。
3. 同步更新 config/api-docs.php 中的 API 說明文件。
4. 調整 RemoteController 指令歷史紀錄上限為 5 筆以優化效能。
5. 統一機台編輯頁面之圖片上傳文字說明與標題。
6. 補齊多語系翻譯檔案 (zh_TW, en, ja) 出現的新字串。
2026-04-02 17:24:13 +08:00
e085058d63 [FEAT] 遠端指令中心優化與規格同步
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m11s
1. 實作遠端指令去重機制 (Supersede):避免重複下達相同待執行指令。
2. 修正遠端指令發送後的 Toast 提示邏輯,確保頁面跳轉後正確顯示回饋。
3. 增加 RemoteCommand 操作者 (user_id) 紀錄與狀態列舉擴充 (superseded)。
4. 修復機台列表「最後頁面」欄位對照錯誤,同步更新 Machine Model 與 API 規格。
5. 優化遠端指令中心 UI:放大卡片字體、調整側面欄間距,符合極簡奢華風規範。
6. 更新 API 技術規格書 (SKILL.md) 與 config/api-docs.php,補全所有機台代碼 (66-611) 與指令。
7. 補全繁體中文、英文、日文多語系翻譯檔案。
2026-04-02 14:57:41 +08:00
e7ad7e3dc3 [FEAT] 整合遠端管理指揮中心與 UI 佈局優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 實作「遠端管理指揮中心」,整合重啟、結帳、鎖定、找零、出貨等指令至單一介面。
2. 對接 B010 心跳 API 與 B017 庫存 API,實作異步指令下發與效期/批號同步邏輯。
3. 修正 sidebar-menu.blade.php 中的舊版路由連結,解決 RouteNotFoundException 錯誤。
4. 修正 index.blade.php 中的 AJAX 請求名稱,補上 admin. 前綴以符合路由分群。
5. 優化主內容區頂部間距,將 pt-10 縮減為 pt-5,提昇介面緊湊度。
2026-04-01 16:59:29 +08:00
3dbb394862 [REFACTOR] 移除機台管理與庫存設定中的貨道同步套用功能與日誌面板冗餘分頁
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
1. 移除 MachineController 貨道更新 API 的 apply_all_same_product 驗證規則。
2. 簡化 MachineService 的 updateSlot 邏輯,取消「同步套用至同機台其他相同商品」的批次異動功能以確保資料準確性。
3. 清理 index.blade.php 機台管理頁面中的「貨道狀態 (Slot Status)」分頁、相關 Alpine.js 函式與專用的貨道編輯 Modal。
4. 修正 stock.blade.php 庫存管理介面,移除編輯 Modal 內的同步切換開關。
2026-04-01 16:10:40 +08:00
969e4df629 [STYLE] 修復機台庫存管理功能並全面升級極簡奢華風 UI
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
1. [FIX] 修復 MachineController 500 錯誤:注入缺失的 MachineService 執行個體。
2. [STYLE] 貨道卡片重構:改為垂直堆疊佈局,移除冗餘標籤,並優化庫存 (x/y) 與效期格式。
3. [STYLE] 極致化間距調優:壓縮全域 Padding 與 Gap,並將貨道編號絕對定位於頂部,提升顯示密度。
4. [FIX] 穩定性修復:解決 Alpine.js 在返回列表時的 selectedMachine 空值存取報錯。
5. [STYLE] UI 細節修飾:隱藏輸入框微調箭頭,強化編號字體粗細與位置精準度。
6. [DOCS] 翻譯同步:更新 zh_TW, en, ja 翻譯檔中關於庫存與貨道的語系 Key。
7. [FEAT] 整合遠端管理模組:新增並導航至 resources/views/admin/remote/stock.blade.php。
2026-04-01 15:26:21 +08:00
71 changed files with 8596 additions and 2704 deletions

View File

@@ -56,25 +56,32 @@ trigger: always_on
### 4.2 終端類 (IoT / Machine) — 須嚴格遵守 PDF 規格 ### 4.2 終端類 (IoT / Machine) — 須嚴格遵守 PDF 規格
* **API 識別碼 (workid)**: URL 中的 `{workid}` 參數固定為該 API 的功能代碼 (如 `B010`, `B017`, `B600`),不隨機台改變。 * **API 識別碼 (workid)**: URL 中的 `{workid}` 參數固定為該 API 的功能代碼 (如 `B010`, `B017`, `B600`),不隨機台改變。
* **機台識別方式**: * **機台識別方式**:
1. **Header**: 透過 `Authorization: Bearer <api_token>` 識別。 1. **Header (推薦)**: 透過 `Authorization: Bearer <api_token>` 識別。針對 B017 等端點,雲端將自動關聯對應機台,**不需**額外帶入機台識別參數。
2. **Request Body**: 透過 `machine``serial_no` 等欄位識別具體機台 2. **Request Body (相容/特定模式)**: 透過 `machine``serial_no` 等欄位識別。主要用於 B000 登入或尚未取得 Token 的引導階段 (如 B014)
* **主要 Endpoint 範例**: * **主要 Endpoint 範例**:
* **心跳上報 (B010)**: `POST /api/app/machine/status/B010` * **心跳上報 (B010)**: `POST /api/app/machine/status/B010`
* **交易回傳 (B600)**: `POST /api/app/B600` (Body 欄位 `req2` 為機台編號) * **交易回傳 (B600)**: `POST /api/app/B600` (Body 欄位 `req2` 為機台編號)
* **貨道庫存 (B017)**: `POST /api/app/machine/reload_msg/B017` * **貨道庫存 (B017)**: `GET /api/app/machine/reload_msg/B017`
* **遠端出貨 (B055)**: `POST /api/app/machine/dispense/B055` * **遠端出貨 (B055)**: `POST /api/app/machine/dispense/B055`
--- ---
## ⚡ 5. 高併發處理與隊列 ## ⚡ 5. IoT 高併發流向與 MQTT Gateway 整合
為了系統穩定性,以下 API **嚴禁直寫資料庫**,必須進入 **Redis Queue** 異步處理 為了系統穩定性與高吞吐量,機台通訊的架構依循以下規範,**嚴禁直寫資料庫**
1. **B010**: 心跳上傳(每 5-10 秒一次)。
2. **B600 / B602**: 交易與出貨紀錄。
3. **B220**: 零錢機庫存變動。
4. **B710**: 計時器狀態同步。
後端應立即回傳 `202 Accepted` 或業務定義的成功碼,由 Job 背景完成數據持久化。 ### 5.1 MQTT 通訊端點 (高頻與事件驅動)
以下高頻或即時事件,未來將**全面改採 MQTT 協議**,透過 EMQX 與 Go Gateway 橋接:
1. **B010 (心跳)**:機台發布至 `machine/{serial_no}/heartbeat`
2. **B013 (錯誤與狀態)**:機台發布至 `machine/{serial_no}/error`
3. **B600 / B602 (交易紀錄)**:機台發布至 `machine/{serial_no}/transaction`
處理管線:
`機台 ➜ EMQX ➜ Go Gateway ➜ Redis List (mqtt_incoming_jobs) ➜ Laravel daemon (mqtt:listen) ➜ Job 異步寫入 DB`
### 5.2 HTTP 通訊端點 (資料拉取與特殊事件)
基於歷史相容、大檔傳輸(如 `B012 商品同步`)或高度安全性(如 `B014 金鑰下載`)的端點,維持使用 HTTP REST API。
若此類 API 產生寫入行為,後端應盡可能立即回傳 `202 Accepted`,並透過 Laravel Job 在背景完成數據持久化。
--- ---

View File

@@ -6,19 +6,28 @@ trigger: always_on
## 1. 專案概述 ## 1. 專案概述
* **目標**打造一個強大且穩定的智能販賣機後台管理系統Cloud 平台),負責管理機台、商品、銷售數據以及提供給端點機台串接的 API。 * **目標**打造一個強大且穩定的智能販賣機後台管理系統Cloud 平台),負責管理機台、商品、銷售數據以及提供給端點機台串接的 API。
* **核心架構**:採用 **傳統單體式架構 (Monolithic Architecture)** Laravel Blade 模板引擎進行伺服器端渲染 (SSR)。 * **核心架構**:採用 **Monorepo 單體式架構**,以 Laravel 為核心進行伺服器端渲染 (SSR) 與 API 服務,並搭配 **Go MQTT Gateway** 作為高併發 IoT 通訊的前置接收層。兩者透過 **Redis** 進行異步橋接,確保職責分離與系統穩定性
* **工作流程**:後端處理業務邏輯與資料庫存取,並透過 Blade 引擎渲染包含 Tailwind CSS 類別的 HTML。前端互動行為由輕量級 Alpine.js 負責UI 元件以 Preline UI 為主體。 * **工作流程**:後端處理業務邏輯與資料庫存取,並透過 Blade 引擎渲染包含 Tailwind CSS 類別的 HTML。前端互動行為由輕量級 Alpine.js 負責UI 元件以 Preline UI 為主體。
## 2. 技術棧 (Tech Stack) ## 2. 技術棧 (Tech Stack)
### 2.1 後端核心 (Laravel)
* **後端框架**PHP 8.5 / Laravel 12 * **後端框架**PHP 8.5 / Laravel 12
* **核心組件**Redis (用於高併發 IoT 隊列與快取,為系統穩定之必要條件) * **核心組件**Redis (用於高併發 IoT 隊列、MQTT 橋接與快取,為系統穩定之必要條件)
* **資料庫**MySQL 8.0
* **開發環境**Laravel Sail (Docker / WSL2)
### 2.2 IoT 通訊層 (MQTT Gateway)
* **MQTT Broker**EMQX 5 (負責維持機台長連線與訊息路由)
* **Gateway 語言**Go (負責訂閱 MQTT Topic、預處理訊息、轉發至 Redis)
* **橋接機制**Redis List (`mqtt_incoming_jobs`),由 Laravel 常駐指令 (`mqtt:listen`) 消費
### 2.3 前端
* **前端視圖 (View)**Laravel Blade * **前端視圖 (View)**Laravel Blade
* **前端互動 (JS)**Alpine.js (專注於行為,不負責渲染) * **前端互動 (JS)**Alpine.js (專注於行為,不負責渲染)
* **介面與樣式 (CSS)**Tailwind CSS + Preline UI (直接寫作於 Blade 模板中)。 * **介面與樣式 (CSS)**Tailwind CSS + Preline UI (直接寫作於 Blade 模板中)。
* **重要規範**Preline UI 僅作為「原子組件」與「JS 互動邏輯」的參考庫。整體的「佈局」與「美學」必須嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。 * **重要規範**Preline UI 僅作為「原子組件」與「JS 互動邏輯」的參考庫。整體的「佈局」與「美學」必須嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
* **前端建置工具**Vite * **前端建置工具**Vite
* **資料庫**MySQL 8.0
* **開發環境**Laravel Sail (Docker / WSL2)
## 3. 目錄結構與慣例 ## 3. 目錄結構與慣例
@@ -29,31 +38,111 @@ trigger: always_on
* **Routes**`routes/web.php` 用於後台管理介面;`routes/api.php` 提供外部或機台調用介面 (需 V1 版本化)。 * **Routes**`routes/web.php` 用於後台管理介面;`routes/api.php` 提供外部或機台調用介面 (需 V1 版本化)。
* **Services** (建議)`app/Services/{Domain}/`,將商業邏輯與資料異動封裝於 Service 中。 * **Services** (建議)`app/Services/{Domain}/`,將商業邏輯與資料異動封裝於 Service 中。
* **Traits**`app/Traits/ApiResponse.php` 用於統一 API JSON 回傳格式。 * **Traits**`app/Traits/ApiResponse.php` 用於統一 API JSON 回傳格式。
* **Jobs**`app/Jobs/{Domain}/`**高併發 IoT 場景之必要實作**。所有日誌、心跳上報必須進入 Redis Queue 進行背景異步處理,嚴禁在 API 直連 DB 寫入日誌 * **Jobs**`app/Jobs/{Domain}/`用於異步處理 IoT 資料寫入、通知發送等背景任務
* **Console Commands**`app/Console/Commands/`,包含 MQTT 橋接守護進程 (`mqtt:listen`) 等常駐指令。
### 3.2 前端 (Blade / Tailwind / Alpine) ### 3.2 前端 (Blade / Tailwind / Alpine)
* **Views (頁面)**:位於 `resources/views/`。通常依功能建立資料夾(如 `resources/views/admin/machines/index.blade.php`)。 * **Views (頁面)**:位於 `resources/views/`。通常依功能建立資料夾(如 `resources/views/admin/machines/index.blade.php`)。
* **Layouts (版面)**:位於 `resources/views/layouts/`。定義全站的共用版面結構(如 header, sidebar, footer * **Layouts (版面)**:位於 `resources/views/layouts/`。定義全站的共用版面結構(如 header, sidebar, footer
* **Components (組件)**:位於 `resources/views/components/`。封裝可重用的 Blade 元件(如 Button, Modal, Table支援透過 `<x-button>` 語法呼叫。 * **Components (組件)**:位於 `resources/views/components/`。封裝可重用的 Blade 元件(如 Button, Modal, Table支援透過 `<x-button>` 語法呼叫。
## 4. 開發標準 (Coding Standards) ### 3.3 MQTT Gateway (Go)
Go 專案以 Monorepo 形式置於專案根目錄下的 `mqtt-gateway/` 資料夾,獨立於 Laravel 程式碼:
* **進入點**`mqtt-gateway/main.go`
* **模組管理**`mqtt-gateway/go.mod` / `go.sum`
* **內部分層**
* `mqtt-gateway/internal/handler/` — 各 Topic 的訊息處理邏輯(如 heartbeat、transaction、error
* `mqtt-gateway/internal/bridge/` — Redis 橋接層,負責將處理後的 JSON 推入 `mqtt_incoming_jobs` List。
* `mqtt-gateway/config/` — 環境變數與 EMQX / Redis 連線設定。
> [!CAUTION]
> Go Gateway 的職責僅限於「接收、驗證、轉發」。**嚴禁**在 Go 中實作任何商業邏輯(如庫存扣減、通知發送),所有業務處理必須統一在 Laravel Service 層完成。
## 4. IoT 通訊架構 (MQTT + HTTP 雙軌制)
本系統的機台通訊採用 **MQTT 與 HTTP 雙軌並行** 的策略,依據通訊特性選擇最適合的協議。
### 4.1 整體資料流向
```
機台 (Android APP)
├─ [高頻/即時] MQTT 長連線 ──→ EMQX Broker ──→ Go Gateway ──→ Redis List ──→ Laravel mqtt:listen ──→ Job ──→ MySQL
└─ [低頻/大檔] HTTP REST ──→ Laravel API Controller ──→ (必要時) Job ──→ MySQL
```
### 4.2 MQTT 通訊端點 (高頻與事件驅動)
以下端點因高頻率或即時性需求,採用 MQTT 協議通訊:
| API 代碼 | Topic 格式 | 用途 | QoS |
| :--- | :--- | :--- | :--- |
| B010 | `machine/{serial_no}/heartbeat` | 心跳上報 (每 10 秒) | 0 |
| B013 | `machine/{serial_no}/error` | 故障與異常狀態上報 | 1 |
| B600 | `machine/{serial_no}/transaction` | 交易紀錄回傳 | 1 |
**雲端→機台指令下發**:透過 `machine/{serial_no}/command` Topic 推送,取代原本 B010 Response 中的 `status` 欄位輪詢機制,實現毫秒級即時指令。
### 4.3 HTTP 通訊端點 (資料拉取與敏感操作)
以下端點因資料量大、安全性要求高或為 Request/Response 模式,維持使用 HTTP REST API
| API 代碼 | 用途 | 維持 HTTP 的原因 |
| :--- | :--- | :--- |
| B000 | 維運人員登入 | 無狀態認證HTTP 更自然 |
| B012 | 商品配置同步 | 大量資料的 GET 拉取 |
| B014 | 金鑰與參數下載 | 高安全性敏感操作,需嚴格 RBAC |
| B009 | 貨道庫存回報 | 低頻操作,由維運人員觸發 |
### 4.4 Redis 橋接機制 (Go ↔ Laravel)
Go Gateway 與 Laravel 之間透過 Redis List 進行單向異步橋接:
* **Redis Key**`mqtt_incoming_jobs`
* **Go 端 (生產者)**:將 MQTT 收到的 Payload 包裝成標準 JSON 後,執行 `RPUSH mqtt_incoming_jobs {json}`
* **Laravel 端 (消費者)**:常駐指令 `php artisan mqtt:listen` 持續執行 `BLPOP mqtt_incoming_jobs`,取得 JSON 後解碼並分派至對應的 Laravel Job (如 `ProcessHeartbeat`, `ProcessTransaction`)。
**JSON 橋接格式規範**
```json
{
"type": "heartbeat",
"serial_no": "M-001",
"received_at": "2026-04-14T09:00:00+08:00",
"payload": {
"current_page": 1,
"firmware_version": "1.0.5",
"temperature": 25.5
}
}
```
> [!IMPORTANT]
> **為何不讓 Go 直接寫入 Laravel Queue** 因為 Laravel Queue 的 Payload 包含 PHP 序列化物件字串 (`serialize()`)Go 無法安全產生此格式。透過獨立的 Redis List + 純 JSON可徹底解耦兩端的技術依賴。
### 4.5 MQTT 連線認證
機台連線 EMQX 時,使用 `serial_no` 作為 Username、`api_token` 作為 Password。驗證流程
1. **Laravel 端 (Token 派發時)**B014 下發 `api_token` 時,同步執行 `Redis::set("machine_auth:{serial_no}", hash(api_token))`
2. **EMQX 端 (連線驗證時)**:配置 Redis Auth Plugin直接查詢 Redis 進行極速驗證 (毫秒級),不經過 MySQL。
3. **Token 更新/撤銷時**Laravel 更新或刪除機台 Token 時,必須同步更新或刪除 Redis 中的對應快取。
## 5. 開發標準 (Coding Standards)
* **命名規範** * **命名規範**
* Controllers: `PascalCaseController.php` (例如 `MachineController.php`) * Controllers: `PascalCaseController.php` (例如 `MachineController.php`)
* Models: `PascalCase.php` (例如 `Machine.php`) * Models: `PascalCase.php` (例如 `Machine.php`)
* Blade Views: `kebab-case.blade.php` 或按資源名稱 (例如 `index.blade.php`, `create.blade.php`) * Blade Views: `kebab-case.blade.php` 或按資源名稱 (例如 `index.blade.php`, `create.blade.php`)
* Routes uri: `kebab-case` (例如 `/machine-logs`) * Routes uri: `kebab-case` (例如 `/machine-logs`)
* Go 檔案: `snake_case.go` (例如 `heartbeat_handler.go`)
* **回傳格式** * **回傳格式**
* Web 路由:回傳 `view()`,表單驗證失敗時直接使用 Laravel 內建的 redirect with errors。 * Web 路由:回傳 `view()`,表單驗證失敗時直接使用 Laravel 內建的 redirect with errors。
* API 路由:回傳標準 JSON 格式的 `JsonResponse` * API 路由:回傳標準 JSON 格式的 `JsonResponse`
## 5. UI 與前端開發指南 ## 6. UI 與前端開發指南
* **樣式撰寫**:全面使用 Tailwind CSS utility classes**避免撰寫自訂 CSS**(除非少數特定動畫或覆寫)。 * **樣式撰寫**:全面使用 Tailwind CSS utility classes**避免撰寫自訂 CSS**(除非少數特定動畫或覆寫)。
* **UI 元件庫**:遵循 **Preline UI** 的類別與 HTML 結構進行開發。 * **UI 元件庫**:遵循 **Preline UI** 的類別與 HTML 結構進行開發。
* **前端腳本** * **前端腳本**
* 優先使用 **Alpine.js** (`x-data`, `x-show`, `@click` 等) 在 HTML 標籤內完成簡單的 DOM 狀態切換與互動邏輯。 * 優先使用 **Alpine.js** (`x-data`, `x-show`, `@click` 等) 在 HTML 標籤內完成簡單的 DOM 狀態切換與互動邏輯。
* 避免在 Blade 內撰寫冗長的 `<script>` Vanilla JS若邏輯過於複雜可將 Alpine state 獨立成 js 檔案再於 Vite 引入,但原則上保持輕量。 * 避免在 Blade 內撰寫冗長的 `<script>` Vanilla JS若邏輯過於複雜可將 Alpine state 獨立成 js 檔案再於 Vite 引入,但原則上保持輕量。
## 6. 多語系 I18n 規範 (Multi-language Standards) ## 7. 多語系 I18n 規範 (Multi-language Standards)
* **視圖開發**:所有使用者可見的文字、按鈕、提示訊息,必須使用 Laravel 的 `@lang('key')``__('key')` 函式包裹。 * **視圖開發**:所有使用者可見的文字、按鈕、提示訊息,必須使用 Laravel 的 `@lang('key')``__('key')` 函式包裹。
* **語系 Key 命名**:語系 Key 必須採用 **英文原始詞彙 (English phrases)** 作為 Key 名稱為原則,以提高代碼可讀性並作為預設回退(除非該字串過長,才建議使用點號分隔的 key * **語系 Key 命名**:語系 Key 必須採用 **英文原始詞彙 (English phrases)** 作為 Key 名稱為原則,以提高代碼可讀性並作為預設回退(除非該字串過長,才建議使用點號分隔的 key
* 範例:使用 `__('Account Settings')` * 範例:使用 `__('Account Settings')`
@@ -61,7 +150,7 @@ trigger: always_on
* 主語系檔案位於 `lang/` 目錄。 * 主語系檔案位於 `lang/` 目錄。
* 開發新功能時,必須同步更新以下三個 JSON 翻譯檔:`zh_TW.json` (主要)、`en.json` (預設)、`ja.json` (日文)。 * 開發新功能時,必須同步更新以下三個 JSON 翻譯檔:`zh_TW.json` (主要)、`en.json` (預設)、`ja.json` (日文)。
## 7. AI 協作規則 (給 Antigravity AI) ## 8. AI 協作規則 (給 Antigravity AI)
* **角色設定**:你是一位專業的全端開發工程師助手。 * **角色設定**:你是一位專業的全端開發工程師助手。
* **代碼生成指令** * **代碼生成指令**
* 所有的解釋說明請使用 **繁體中文**。 * 所有的解釋說明請使用 **繁體中文**。
@@ -70,23 +159,24 @@ trigger: always_on
* **【多語系強制要求】** 任何新增的 Blade UI 區塊,禁止硬編碼 (Hard-coded) 中文或英文。必須使用 `__('...')` 並同步在 `lang/*.json` 補上翻譯。 * **【多語系強制要求】** 任何新增的 Blade UI 區塊,禁止硬編碼 (Hard-coded) 中文或英文。必須使用 `__('...')` 並同步在 `lang/*.json` 補上翻譯。
* 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。 * 生成 UI 區塊時,必須優先參考與產生 **Preline UI** 風格與結構的標記語法。
* 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。 * 開發新功能時,請建立標準的 Controller 搭配對應的 `resources/views/.../` 目錄。
* **【Go Gateway 開發】** 修改 `mqtt-gateway/` 內的 Go 程式碼時嚴禁加入商業邏輯。Go 僅負責訊息接收、格式轉換與 Redis 轉發。
## 8. 運行機制 (Docker / Sail) ## 9. 運行機制 (Docker / Sail)
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令: 由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
* **啟動環境**`./vendor/bin/sail up -d` * **啟動環境**`./vendor/bin/sail up -d`(將同時啟動 Laravel、MySQL、Redis、EMQX、Go Gateway
* **執行 PHP 指令**`./vendor/bin/sail php -v` * **執行 PHP 指令**`./vendor/bin/sail php -v`
* **執行 Artisan 指令**`./vendor/bin/sail artisan route:list` * **執行 Artisan 指令**`./vendor/bin/sail artisan route:list`
* **執行 Composer**`./vendor/bin/sail composer install` * **執行 Composer**`./vendor/bin/sail composer install`
* **執行 Node/NPM**`./vendor/bin/sail npm run dev` * **執行 Node/NPM**`./vendor/bin/sail npm run dev`
## 8. 部署與查修環境 (CI/CD) ## 10. 部署與查修環境 (CI/CD)
* **自動化部署**:專案具備基於 Gitea Actions 的 CI/CD 自動化部署流程 (`.gitea/workflows/`)。 * **自動化部署**:專案具備基於 Gitea Actions 的 CI/CD 自動化部署流程 (`.gitea/workflows/`)。
* **Demo 環境 (對應 `demo` 分支)** * **Demo 環境 (對應 `demo` 分支)**
* 透過 `deploy-demo.yaml`,合併或推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw` * 透過 `deploy-demo.yaml`,合併或推送到 `demo` 分支會自動部署至 `demo-cloud.taiwan-star.com.tw`
* 登入伺服器查修:`ssh gitea_work`,路徑為 `/var/www/star-cloud-demo` * 登入伺服器查修:`ssh gitea_work`,路徑為 `/var/www/star-cloud-demo`
## 9. 瀏覽器測試規範 (Browser Testing) ## 11. 瀏覽器測試規範 (Browser Testing)
當需要進行瀏覽器自動化測試或手動驗證時,必須遵守以下連線資訊: 當需要進行瀏覽器自動化測試或手動驗證時,必須遵守以下連線資訊:
* **本地測試網址**`http://localhost:8090/` (注意:非 8000 或 8080) * **本地測試網址**`http://localhost:8090/` (注意:非 8000 或 8080)

View File

@@ -14,7 +14,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 觸發詞 / 情境 | 對應 Skill | 路徑 | | 觸發詞 / 情境 | 對應 Skill | 路徑 |
|---|---|---| |---|---|---|
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` | | 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報, MQTT, Topic, Broker, EMQX | **IoT 通訊與高併發處理規範 / MQTT 通訊規範** | `.agents/skills/iot-communication/SKILL.md` <br> `.agents/skills/mqtt-communication-specs/SKILL.md` |
| B010, B017, B600, B055, API 規格, 通訊協議, 狀態碼, 頁面碼, 範例, JSON | **API 技術規格與通訊協議規範** | `.agents/skills/api-technical-specs/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` | | 介面, 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` | | 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |

View File

@@ -8,68 +8,349 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。 本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。
## 1. 核心命名原則 ## 1. 核心命名原則
- **語意化優先**:捨棄舊版 `M_` 前綴,統一使用 snake_case (如 `firmware_version`)。 - **語意化優先**:捨棄舊版 M_ 前綴,統一使用 snake_case (如 firmware_version)。
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。 - **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
## 2. 身份認證 (Authentication) ## 2. 身份認證 (Authentication)
- **Bearer Token**:所有 API 必須在 Header 帶入 `Authorization: Bearer <api_token>` 本系統採用兩階段認證模式:
- **身分綁定**:後端透過 Token 自動識別 `machine_id`,禁止在 Body 帶入 `machine``key` 欄位。
### 2.1 維運人員認證 (User Authentication)
- **核發端點**B000 (登入)。
- **使用端點**B014 (參數下載)。
- **方式**:使用 Laravel Sanctum 核發之 **User Token**
- **Header**`Authorization: Bearer <user_token>`
### 2.2 機台通訊認證 (Machine Authentication)
- **適用 API**B010, B012, B013, B600 等後續通訊。
- **方式**:使用機台專屬之 **api_token**
- **Header**`Authorization: Bearer <api_token>`
--- ---
## 3. 機台核心 API (IoT Endpoints) ## 3. 機台核心 API (IoT Endpoints)
### 3.1 B010: 心跳上報與狀態同步 ### 3.1 B000: 機台本地管理員同步登入
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令 用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點
- **URL**: `POST /api/v1/app/machine/status/B010` - **URL**: POST /api/v1/app/admin/login/B000
- **Request Body:** - **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 | | 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| `current_page` | Integer | 是 | 當前頁面代碼 (見下表) | `1` | | machine | String | 是 | 機台編號 (serial_no) | M-001 |
| `firmware_version` | String | 是 | 韌體版本號 | `1.0.5` | | Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
| `model` | String | 是 | 機台型號 | `STAR-V1` | | Su_Password | String | 是 | 密碼 | password123 |
| `temperature` | Float | 否 | 環境溫度 | `25.5` | | ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
| `door_status` | Integer | 否 | 門狀態 (0:關 / 1:開) | `0` | | type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
| `log` | String | 否 | 事件日誌簡述 | `Door opened` |
| `log_level` | String | 否 | info, warn, error | `info` |
| `log_payload` | Object | 否 | 額外日誌 JSON 對象 | `{"code":500}` |
- **Response Body:** - **Response Body:**
> [!IMPORTANT]
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
| 參數 | 類型 | 說明 | 範例 | | 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `success` | Boolean | 請求是否處理成功 | `true` | | message | String | 驗證結果 (Success 或 Failed) | Success |
| `code` | Integer | 內部業務狀態碼 | `200` | | token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
| `message` | String | 回應訊息 | `OK` |
| `status` | String | **雲端指令代碼** (見下表) | `49` | ---
### 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 代碼對照表 #### B010 代碼對照表
**頁面代碼 (current_page)** **頁面代碼 (current_page)**
- `0`: 離線 / `1`: 主頁面 / `2`: 販賣頁 / `3`: 管理頁 - 0: 離線 / 1: 主頁面 / 2: 販賣頁 / 3: 管理頁
- `4`: 補貨頁 / `5`: 教學頁 / `6`: 購買中 / `7`: 鎖定頁 - 4: 補貨頁 / 5: 教學頁 / 6: 購買中 / 7: 鎖定頁
- `60`: 出貨成功 / `61`: 貨道測試 / `62`: 付款選擇 - 60: 出貨成功 / 61: 貨道測試 / 62: 付款選擇
- `63`: 等待付款 / `64`: 出貨 / `65`: 收據簽單 - 63: 等待付款 / 64: 出貨 / 65: 收據簽單
- `612`: 出貨失敗 - 66: 通行碼 / 67: 取貨碼 / 68: 訊息顯示
- 69: 取消購買 / 610: 購買結束 / 611: 來店禮
- 612: 出貨失敗
**雲端指令代碼 (status)** **雲端指令代碼 (status)**
- `49`: reload B017 (貨道同步) - 49: reload B017 (貨道同步)
- `50`: reload B005 (基礎參數) - 51: reboot (重啟系統)
- `51`: reboot (重啟系統) - 60: reboot card machine (刷卡機重啟)
- `60`: reboot card machine (刷卡機重啟) - 61: checkout (觸發結帳)
- `61`: checkout (結帳) - 70: unlock (解鎖)
- `70`: unlock (解鎖) / `71`: lock (鎖定) - 71: lock (鎖定)
- `72`: sellCode reload B023 (即期品) - 85: reload B0552 (遠端出貨)
- `75`: exp reload B026 (效期)
- `79`: read B050 (參數讀取)
- `85`: reload B0552 (出貨腳本)
--- ---
### 3.2 B017: 貨道與庫存同步 (規劃中) ### 3.5 B012: 商品配置與商品主檔同步 (Unified Sync)
- **URL**: `POST /api/v1/app/machine/reload_msg/B017` 用於機台端獲取目前所有可販售商品的詳細配置。App 端應依據呼叫的方法決定數據處理方式。
- 說明:當機台收到 B010 回應 `status: 49` 時,應呼叫此 API 同步最新貨道佈局。
### 3.3 B600: 交易數據回傳 (規劃中) - **URL**: GET|PATCH /api/v1/app/machine/products/B012
- **URL**: `POST /api/v1/app/B600` - **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**: GET /api/v1/app/machine/setting/B014
- **Authentication**: **User Token** (Sanctum Header)
- **Request Body:** 無 (由 Query String 帶入 `machine` 參數)
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| 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 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。
---
### 3.8 B017: 貨道庫存同步 (Slot Synchronization)
用於機台端獲取目前所有貨道的最新庫存、效期與狀態。通常由 B010 回應 `status: 49` 觸發。
- **URL**: GET /api/v1/app/machine/reload_msg/B017
- **Authentication**: Bearer Token (Header)
- **Request Body:** 無 (由 Token 自動識別機台)
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true |
| code | Integer | 200 | 200 |
| data | Array | 貨道數據陣列 (依 slot_no 排序) | 見下表 |
**data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| tid | String | 貨道編號 | "1" |
| num | Integer | 當前庫存數量 | 10 |
| expiry_date | String | 商品效期 | "2026-12-31" |
| batch_no | String | 批號 | "B202604" |
| product_id | Integer | 商品 ID | 1 |
| capacity | Integer | 貨道容量上限 | 15 |
| status | String | 貨道狀態 ("1": 啟用, "0": 停用) | "1" |
> [!IMPORTANT]
> **同步機制**B017 為全量同步。App 收到回應後應更新本地資料庫對應貨道的數值。成功請求後,雲端會自動將相關的 `reload_stock` 指令標記為 `success`
---
### 3.9 B024: 取貨碼/通行碼驗證與消耗回報 (Access Code Verify & Report)
用於處理機台端的代碼取貨流程。包含驗證代碼有效性(驗證階段)與確認出貨完成後的狀態消耗(回報階段)。
- **URL**: POST|PUT /api/v1/app/sell/access-code/B024
- **Authentication**: Bearer Token (Header)
- **Request Body (POST - 驗證階段):**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| passCode | String | 是 | 使用者輸入的取貨碼/通行碼 | "12345678" |
- **Response Body (POST - 驗證成功 200):**
雲端將回傳該碼對應的權限或待出貨商品資訊。
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| res1 | String | 該代碼在雲端的關聯 ID | "99" |
| res2 | String | 操作模式 (1: 出貨, 2: 僅驗證) | "1" |
| res3 | String | 預計出貨商品 ID | "5" |
| res4 | String | 折扣金額或活動標籤 | "0.0" |
- **Request Body (PUT - 回報階段):**
當機台端確認實體出貨成功後,必須發送此請求以耗刷該代碼。
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| accessCodeId | String | 是 | 驗證階段取得的 res1 (ID) | "99" |
| status | String | 是 | 出貨結果 (1: 成功, 0: 失敗) | "1" |
---
### 3.10 B027: 贈品碼/優惠券驗證與消耗回報 (Free Gift Verify & Report)
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
- **URL**: POST|PUT /api/v1/app/sell/free-gift/B027
- **Authentication**: Bearer Token (Header)
- **運作模式**:
- **POST**: 提交 `passCode` 驗證該贈品券是否有效。成功後雲端會回傳對應活動 ID 與商品配置。
- **PUT**: 當該贈品0 元商品)出貨完成後,向雲端回報消耗,確保優惠券不會重複核銷。
> [!NOTE]
> B027 與 B024 的邏輯具備高度對稱性,區別在於 B027 通常綁定的是特定的行銷活動 (Campaign) 與 0 元出貨邏輯。
---
### 3.11 B055: 遠端指令出貨控制 (Remote Dispense / Force Open)
用於遠端手動驅動機台出貨。通常用於補償使用者、測試機台或客服協助開門的情景。
- **URL**: POST|PUT /api/v1/app/machine/dispense/B055
- **Authentication**: Bearer Token (Header)
- **運作模式**:
- **POST (查詢)**: 當 B010 收到 `status: 85` 時呼叫。雲端會回傳待執行的貨道編號與指令 ID。
- **PUT (回報)**: 實體出貨完成後回報結果,以便雲端將該指令標記為「已執行」。
- **Request Body (PUT - 回報階段):**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| id | String | 是 | 雲端下發的指令 ID | "20260414001" |
| type | String | 是 | 出貨類型代碼 (通常為 0) | "0" |
| stock | String | 是 | 出貨後的貨道剩餘數量 | "9" |

View File

@@ -9,13 +9,25 @@ description: 規範智能販賣機與 Cloud 平台間的高頻通訊處理流程
## 1. 處理管線 (Processing Pipeline) ## 1. 處理管線 (Processing Pipeline)
所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline 所有來自機台的非即時性資料(日誌、心跳、狀態上報)必須遵循以下 pipeline。依據通訊協議不同,進入點有兩條路徑
### 1.1 HTTP 管線 (低頻/大檔操作)
適用於 B000, B009, B012, B014, B017 等低頻、同步需求或大資料量的端點:
1. **API Controller (接收層)**:驗證 Request 合法性,隨即分派 (Dispatch) 任務至 Queue並回傳 `202 Accepted` 1. **API Controller (接收層)**:驗證 Request 合法性,隨即分派 (Dispatch) 任務至 Queue並回傳 `202 Accepted`
2. **Job (異步層)**:由背景 Worker 讀取隊列任務,呼叫對應 Service 處理。 2. **Job (異步層)**:由背景 Worker 讀取隊列任務,呼叫對應 Service 處理。
3. **Service (邏輯層)**:封裝商業邏輯,更新資料庫。 3. **Service (邏輯層)**:封裝商業邏輯,更新資料庫。
4. **Model (儲存層)**:執行資料存取。 4. **Model (儲存層)**:執行資料存取。
### 1.2 MQTT 管線 (高頻/即時操作)
適用於 B010 (心跳), B013 (異常), B600 (交易) 等高頻或即時性端點:
1. **Go Gateway (接收層)**:訂閱 EMQX Topic提取 `serial_no`,包裝成標準 JSON。
2. **Redis List (橋接層)**Go 執行 `RPUSH mqtt_incoming_jobs {json}`
3. **Laravel `mqtt:listen` (消費層)**:常駐指令 `BLPOP` 取出 JSON根據 `type` 分派至對應 Job。
4. **Job ➜ Service ➜ Model**:與 HTTP 管線後半段相同。
> [!TIP]
> 兩條管線的 **Job / Service / Model 層完全共用**,差異僅在「進入點」。這確保了業務邏輯不會因為通訊協議不同而分裂。
> [!IMPORTANT] > [!IMPORTANT]
> **嚴禁**在 API Controller 直接進行資料庫寫入操作(針對機台發訊端點)。 > **嚴禁**在 API Controller 直接進行資料庫寫入操作(針對機台發訊端點)。
@@ -53,9 +65,14 @@ public function handle(MachineService $service): void
## 4. 速率限制 (Rate Limiting) ## 4. 速率限制 (Rate Limiting)
- 所有的 IoT API 必須在 `routes/api.php` 中使用 `throttle:api` 或自定義 Middleware。 ### 4.1 HTTP 端點
- 所有的 IoT HTTP API 必須在 `routes/api.php` 中使用 `throttle:api` 或自定義 Middleware。
- 針對單一機台 ID 應限制其每一分鐘的最高連線數,防止遭受攻擊或機台 Bug 導致的連線暴衝。 - 針對單一機台 ID 應限制其每一分鐘的最高連線數,防止遭受攻擊或機台 Bug 導致的連線暴衝。
### 4.2 MQTT 端點
- 限速由 **EMQX Broker** 的 Rate Limiting 功能負責(非 Laravel Middleware
- Go Gateway 層可額外實作簡易的 Token Bucket當某台機台每秒超過閾值時丟棄訊息並記錄 Warning Log。
## 5. 檢核項目 (Checklist) ## 5. 檢核項目 (Checklist)
- [ ] 是否使用了 `ApiResponse` Trait - [ ] 是否使用了 `ApiResponse` Trait
- [ ] 業務邏輯是否已封裝至 `App\Services` - [ ] 業務邏輯是否已封裝至 `App\Services`
@@ -64,13 +81,16 @@ public function handle(MachineService $service): void
## 6. API 規格定義 (API Specifications) ## 6. API 規格定義 (API Specifications)
> [!IMPORTANT] > [!IMPORTANT]
> **規格分離原則**:本技能僅規範「通訊處理邏輯」。關於具體的 API 欄位、參數命名、狀態代碼對照與範例,請務必參閱專屬技能規範: > **規格分離原則**:本技能僅規範「通訊處理邏輯」。關於具體的欄位定義與資料格式,請參閱對應的專屬技能規範:
> - **[API 技術規格與通訊協議規範](file:///home/mama/projects/star-cloud/.agents/skills/api-technical-specs/SKILL.md)** > - **HTTP 端點**[API 技術規格與通訊協議規範](file:///home/mama/projects/star-cloud/.agents/skills/api-technical-specs/SKILL.md)
> - **MQTT 端點**[MQTT 即時通訊與 Topic 規範](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md)
### 常見端點處理模式 ### 常見端點處理模式
1. **B010 (心跳)**:高頻點,必須進入 Redis Queue。更新 `last_heartbeat_at` 與感測器快照。 1. **B010 (心跳)**:高頻點,**MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heard_at` 與感測器快照。
2. **B600 (交易)**:高價值點,必須進入任務隊列並支援重試。建立 `Transaction` 紀錄 2. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警
3. **B017 (貨道)**:回覆較大資料量,應確保 Service 層具備緩存 (Cache) 機制 3. **B600 (交易)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。建立 `Transaction` 紀錄並支援重試
4. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。
5. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。
--- ---

View File

@@ -0,0 +1,100 @@
---
name: MQTT 即時通訊與 Topic 規範
description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊拓撲、主題 (Topic) 結構、資料載體 (Payload) 格式與安全性認證機制。
---
# MQTT 即時通訊與 Topic 規範 (MQTT Protocol Specs)
本文件定義機台與 Star Cloud 之間的高併發即時通訊標準。MQTT 主要用於處理高頻率且對即時性要求高的訊息(如心跳、遠端指令),與 HTTP REST API 形成雙軌互補架構。
## 1. 連線基礎設定 (Connection Basics)
### 1.1 Broker 資訊
- **協議版本**MQTT v3.1.1 (相容性最高)
- **預設埠號**1883 (TCP / 測試用), 8883 (SSL/TLS / 正式用)
- **Keep-Alive**:建議設定為 30 ~ 60 秒。
### 1.2 身份認證 (Authentication)
機台連線時必須提供以下憑據:
- **Username**:機台編號 (`serial_no`),例如 `M-001`
- **Password**:機台正式 Token (`api_token`),由 B014 API 取得。
- **Client ID**:建議格式為 `SC_{serial_no}_{random_suffix}`,確保唯一性。
---
## 2. 主題架構 (Topic Topology)
我們採用目錄式的層級結構,方便未來進行萬台設備的管理與 ACL 權限切分。
| 主題名稱 (Topic) | 方向 | QoS | 用途說明 |
| :--- | :--- | :--- | :--- |
| `machine/{serial_no}/heartbeat` | 設備 ➜ 雲端 | 0 | 每 10 秒上報心跳、溫度、目前的頁面碼。 |
| `machine/{serial_no}/error` | 設備 ➜ 雲端 | 1 | 發生硬體故障、卡貨或門未關時立即上報。 |
| `machine/{serial_no}/transaction` | 設備 ➜ 雲端 | 1 | 交易完成、出貨結果的回報。 |
| `machine/{serial_no}/command` | 雲端 ➜ 設備 | 1 | 雲端下發的即時指令(出貨、更新、重啟)。 |
---
## 3. 資料載體規範 (Payload Definitions)
所有 Payload 統一採用 **JSON** 格式,字母一律為 **snake_case**
### 3.1 心跳上報 (Heartbeat) - `machine/{id}/heartbeat`
比照原 B010 邏輯,但去除不必要的 HTTP Header 開銷。
```json
{
"current_page": 1,
"firmware_version": "1.0.5",
"temperature": 25.5,
"door_status": 0,
"timestamp": "2026-04-14T09:00:00+08:00"
}
```
### 3.2 異常上報 (Error/Event) - `machine/{id}/error`
比照原 B013 邏輯。
```json
{
"tid": 12,
"error_code": "0403",
"log": "Slot jammed at slot 12",
"timestamp": "2026-04-14T09:05:00+08:00"
}
```
### 3.3 雲端指令 (Downstream Commands) - `machine/{id}/command`
這是雲端主動下發給機台的訊息,取代原本 B010 Response 的輪詢等待。
```json
{
"command": "dispense",
"payload": {
"slot_no": 5,
"transaction_id": "T202604140001"
},
"message_id": "MSG_123456789"
}
```
**常用指令集:**
- `reboot`: 機台重啟。
- `reload_config`: 重新下載參數 (B014)。
- `reload_products`: 重新同步商品 (B012)。
- `dispense`: 遠端出貨指令 (B055)。
---
## 4. 安全與 QOS 規範
1. **存取控制 (ACL)**EMQX Broker 必須設定 ACL禁止機台 A 訂閱機台 B 的 Topic。機台僅能訂閱與發布包含自身 `serial_no` 的路徑。
2. **QoS 策略**
- **QoS 0**:適用於高頻率心跳,即使掉一兩次包也不影響系統判斷。
- **QoS 1**適用於交易與指令確保「至少送達一次」。App 端收到指令後應回覆回執。
3. **遺囑訊息 (Last Will and Testament)**
機台 Connect 時應設定 Last Will 於 `machine/{serial_no}/heartbeat`Payload 為 `{"status": "offline"}`。當連線異常中斷時,雲端能立刻得知。
---
## 5. 與 REST API 的同步關係
當 MQTT 通訊正常時,機台應停止定時呼叫 B010 HTTP API。若 MQTT 斷線超過 3 分鐘,則退回 (Fallback) 使用 HTTP 輪詢模式以維持基礎通訊。

1
.gitignore vendored
View File

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

View File

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

View File

@@ -105,7 +105,6 @@ class MachineSettingController extends AdminController
} }
$machine = Machine::create(array_merge($validated, [ $machine = Machine::create(array_merge($validated, [
'status' => 'offline',
'api_token' => \Illuminate\Support\Str::random(60), 'api_token' => \Illuminate\Support\Str::random(60),
'creator_id' => auth()->id(), 'creator_id' => auth()->id(),
'updater_id' => auth()->id(), 'updater_id' => auth()->id(),

View File

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

View File

@@ -12,28 +12,31 @@ class DashboardController extends Controller
{ {
// 每頁顯示筆數限制 (預設為 10) // 每頁顯示筆數限制 (預設為 10)
$perPage = (int) request()->input('per_page', 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'); $totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
$activeMachines = Machine::where('status', 'online')->count(); $activeMachines = Machine::online()->count();
$alertsPending = Machine::where('status', 'error')->count(); $offlineMachines = Machine::offline()->count();
$alertsPending = Machine::hasError()->count();
$memberCount = \App\Models\Member\Member::count(); $memberCount = \App\Models\Member\Member::count();
// 獲取機台列表 (分頁) // 獲取機台列表 (分頁)
$machines = Machine::when($request->search, function($query, $search) { $machines = Machine::when($request->search, function ($query, $search) {
$query->where(function($q) use ($search) { $query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%") $q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%"); ->orWhere('serial_no', 'like', "%{$search}%");
}); });
}) })
->latest() ->orderByDesc('last_heartbeat_at')
->paginate($perPage) ->paginate($perPage)
->withQueryString(); ->withQueryString();
return view('admin.dashboard', compact( return view('admin.dashboard', compact(
'totalRevenue', 'totalRevenue',
'activeMachines', 'activeMachines',
'offlineMachines',
'alertsPending', 'alertsPending',
'memberCount', 'memberCount',
'machines' 'machines'

View File

@@ -3,14 +3,18 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Services\Machine\MachineService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
class MachineController extends AdminController class MachineController extends AdminController
{ {
public function __construct(protected MachineService $machineService)
{
}
public function index(Request $request): View public function index(Request $request): View
{ {
$tab = $request->input('tab', 'list');
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 10);
$query = Machine::query(); $query = Machine::query();
@@ -23,27 +27,28 @@ class MachineController extends AdminController
}); });
} }
// 統一預加載貨道統計資料 (無論在哪一個頁籤) // 預加載統計資料
$machines = $query->withCount(['slots as total_slots']) $machines = $query->orderBy("last_heartbeat_at", "desc")
->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()]);
}])
// 只有在機台列表且有狀態篩選時才套用狀態過濾
->when($request->status && $tab === 'list', function ($q, $status) {
return $q->where('status', $status);
})
->orderBy("last_heartbeat_at", "desc")
->orderBy("id", "desc") ->orderBy("id", "desc")
->paginate($per_page) ->paginate($per_page)
->withQueryString(); ->withQueryString();
return view('admin.machines.index', compact('machines', 'tab')); return view('admin.machines.index', compact('machines'));
}
/**
* 更新機台基本資訊 (目前僅名稱)
*/
public function update(Request $request, Machine $machine)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$machine->update($validated);
return redirect()->route('admin.machines.index')
->with('success', __('Machine updated successfully.'));
} }
/** /**
@@ -51,9 +56,11 @@ class MachineController extends AdminController
*/ */
public function show(int $id): View public function show(int $id): View
{ {
$machine = Machine::with(['logs' => function ($query) { $machine = Machine::with([
'logs' => function ($query) {
$query->latest()->limit(50); $query->latest()->limit(50);
}])->findOrFail($id); }
])->findOrFail($id);
return view('admin.machines.show', compact('machine')); return view('admin.machines.show', compact('machine'));
} }
@@ -64,17 +71,21 @@ class MachineController extends AdminController
*/ */
public function logsAjax(Request $request, Machine $machine) public function logsAjax(Request $request, Machine $machine)
{ {
$per_page = $request->input('per_page', 10); $per_page = $request->input('per_page', 20);
$startDate = $request->get('start_date', now()->format('Y-m-d')); $startDate = $request->get('start_date');
$endDate = $request->get('end_date', now()->format('Y-m-d')); $endDate = $request->get('end_date');
$logs = $machine->logs() $logs = $machine->logs()
->when($request->level, function ($query, $level) { ->when($request->level, function ($query, $level) {
return $query->where('level', $level); return $query->where('level', $level);
}) })
->whereDate('created_at', '>=', $startDate) ->when($startDate, function ($query, $start) {
->whereDate('created_at', '<=', $endDate) return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->when($request->type, function ($query, $type) { ->when($request->type, function ($query, $type) {
return $query->where('type', $type); return $query->where('type', $type);
}) })
@@ -132,32 +143,24 @@ class MachineController extends AdminController
} }
/** /**
* AJAX: 更新貨道效期 * AJAX: 更新貨道資訊 (庫存、效期、批號)
*/ */
public function updateSlotExpiry(Request $request, Machine $machine) public function updateSlotExpiry(Request $request, Machine $machine)
{ {
$request->validate([ $validated = $request->validate([
'slot_no' => 'required|integer', 'slot_no' => 'required|integer',
'stock' => 'nullable|integer|min:0',
'expiry_date' => 'nullable|date', 'expiry_date' => 'nullable|date',
'apply_all_same_product' => 'boolean' 'batch_no' => 'nullable|string|max:50',
]); ]);
$slotNo = $request->slot_no; $this->machineService->updateSlot($machine, $validated, auth()->id());
$expiryDate = $request->expiry_date;
$applyAll = $request->apply_all_same_product ?? false;
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail(); session()->flash('success', __('Slot updated successfully.'));
$slot->update(['expiry_date' => $expiryDate]);
if ($applyAll && $slot->product_id) {
$machine->slots()
->where('product_id', $slot->product_id)
->update(['expiry_date' => $expiryDate]);
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => __('Expiry updated successfully.') 'message' => __('Slot updated successfully.')
]); ]);
} }

View File

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

View File

@@ -4,69 +4,131 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\Machine\RemoteCommand;
use Illuminate\Support\Facades\Auth;
class RemoteController extends Controller class RemoteController extends Controller
{ {
// 機台庫存 /**
public function stock() * 遠端管理指揮中心
*/
public function index(Request $request)
{ {
return view('admin.placeholder', [ $machines = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
'title' => '遠端修改機台庫存', $selectedMachine = null;
'description' => '遠端修改機台庫存數量', $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', [
public function restart() 'machines' => $machines,
{ 'selectedMachine' => $selectedMachine,
return view('admin.placeholder', [ 'history' => $history,
'title' => '遠端重啟機台', 'title' => __('Remote Command Center'),
'description' => '遠端重啟機台系統', 'subtitle' => __('Execute maintenance and operational commands remotely')
]); ]);
} }
// 卡機重啟 /**
public function restartCardReader() * 儲存遠端指令
*/
public function storeCommand(Request $request)
{ {
return view('admin.placeholder', [ $validated = $request->validate([
'title' => '遠端重啟刷卡機', 'machine_id' => 'required|exists:machines,id',
'description' => '遠端重啟刷卡機設備', '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 checkout()
{
return view('admin.placeholder', [
'title' => '遠端結帳',
'description' => '遠端執行結帳流程',
]);
} }
// 遠端鎖定頁 /**
public function lock() * 機台庫存管理 (現有功能保留)
*/
public function stock(Request $request)
{ {
return view('admin.placeholder', [ $machines = Machine::withCount([
'title' => '遠端鎖定頁', 'slots as slots_count',
'description' => '遠端鎖定機台頁面', '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();
$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);
} }
// 遠端找零 return view('admin.remote.stock', [
public function change() 'machines' => $machines,
{ 'selectedMachine' => $selectedMachine,
return view('admin.placeholder', [ 'history' => $history,
'title' => '遠端找零', 'title' => __('Stock & Expiry Management'),
'description' => '遠端執行找零功能', 'subtitle' => __('Real-time monitoring and adjustment of cargo lane inventory and expiration dates')
]);
}
// 遠端出貨
public function dispense()
{
return view('admin.placeholder', [
'title' => '遠端出貨',
'description' => '遠端控制商品出貨',
]); ]);
} }
} }

View 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
]);
}
}

View File

@@ -5,10 +5,15 @@ namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat; use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus; use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory; use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
class MachineController extends Controller class MachineController extends Controller
{ {
@@ -18,16 +23,126 @@ class MachineController extends Controller
public function heartbeat(Request $request) public function heartbeat(Request $request)
{ {
$machine = $request->get('machine'); $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); 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([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'message' => 'OK', 'message' => $message,
'status' => '49' // 某些硬體可能需要的成功碼 'status' => $status
], 202); // 202 Accepted ], 202); // 202 Accepted
} }
@@ -37,7 +152,7 @@ class MachineController extends Controller
public function recordRestock(Request $request) public function recordRestock(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no; $data['serial_no'] = $machine->serial_no;
\App\Jobs\Machine\ProcessRestockReport::dispatch($data); \App\Jobs\Machine\ProcessRestockReport::dispatch($data);
@@ -53,34 +168,53 @@ class MachineController extends Controller
/** /**
* B017: Get Slot Info & Stock (Synchronous) * B017: Get Slot Info & Stock (Synchronous)
*/ */
/**
* B017: Get Slot Info & Stock (Synchronous - Full Sync)
*/
public function getSlots(Request $request) public function getSlots(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$slots = $machine->slots()->with('product')->get();
// 依貨道編號排序 (Sorted by slot_no as requested)
$slots = $machine->slots()->with('product')->orderBy('slot_no')->get();
// 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應
// 同時處理 sent 與 pending 狀態,確保狀態機正確關閉
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->whereIn('status', ['pending', 'sent'])
->update([
'status' => 'success',
'executed_at' => now(),
'note' => __('Inventory synced with machine')
]);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'data' => $slots->map(function ($slot) { 'data' => $slots->map(function ($slot) {
return [ return [
'slot_no' => $slot->slot_no, 'tid' => $slot->slot_no,
'num' => (int)$slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
// 保留原始欄位以供除錯或未來擴充
'product_id' => $slot->product_id, 'product_id' => $slot->product_id,
'stock' => $slot->stock, 'capacity' => $slot->max_stock,
'capacity' => $slot->capacity, 'status' => $slot->is_active ? '1' : '0',
'price' => $slot->price,
'status' => $slot->status,
]; ];
}) })
]); ]);
} }
/** /**
* B710: Sync Timer status (Asynchronous) * B710: Sync Timer status (Asynchronous)
*/ */
public function syncTimer(Request $request) public function syncTimer(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessTimerStatus::dispatch($machine->serial_no, $data); ProcessTimerStatus::dispatch($machine->serial_no, $data);
@@ -93,7 +227,7 @@ class MachineController extends Controller
public function syncCoinInventory(Request $request) public function syncCoinInventory(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessCoinInventory::dispatch($machine->serial_no, $data); ProcessCoinInventory::dispatch($machine->serial_no, $data);
@@ -139,4 +273,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 預期的是包含單一物件的陣列
]);
}
} }

View File

@@ -16,7 +16,7 @@ class TransactionController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no; $data['serial_no'] = $machine->serial_no;
ProcessTransaction::dispatch($data); ProcessTransaction::dispatch($data);
@@ -34,7 +34,7 @@ class TransactionController extends Controller
public function recordInvoice(Request $request) public function recordInvoice(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no; $data['serial_no'] = $machine->serial_no;
ProcessInvoice::dispatch($data); ProcessInvoice::dispatch($data);

View File

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

View File

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

View File

@@ -38,7 +38,6 @@ class Machine extends Model
'serial_no', 'serial_no',
'model', 'model',
'location', 'location',
'status',
'current_page', 'current_page',
'door_status', 'door_status',
'temperature', 'temperature',
@@ -69,7 +68,88 @@ class Machine extends Model
'updater_id', '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 = [ protected $casts = [
'last_heartbeat_at' => 'datetime', 'last_heartbeat_at' => 'datetime',
@@ -106,6 +186,11 @@ class Machine extends Model
return $this->hasMany(MachineSlot::class); return $this->hasMany(MachineSlot::class);
} }
public function commands()
{
return $this->hasMany(RemoteCommand::class);
}
public function machineModel() public function machineModel()
{ {
return $this->belongsTo(MachineModel::class); return $this->belongsTo(MachineModel::class);
@@ -133,21 +218,20 @@ class Machine extends Model
'3' => 'Admin Page', '3' => 'Admin Page',
'4' => 'Replenishment Page', '4' => 'Replenishment Page',
'5' => 'Tutorial Page', '5' => 'Tutorial Page',
'60' => 'Purchasing', '6' => 'Purchasing',
'61' => 'Locked Page', '7' => 'Locked Page',
'62' => 'Dispense Failed', '60' => 'Dispense Success',
'301' => 'Slot Test', '61' => 'Slot Test',
'302' => 'Slot Test', '62' => 'Payment Selection',
'401' => 'Payment Selection', '63' => 'Waiting for Payment',
'402' => 'Waiting for Payment', '64' => 'Dispensing',
'403' => 'Dispensing', '65' => 'Receipt Printing',
'404' => 'Receipt Printing', '66' => 'Pass Code',
'601' => 'Pass Code', '67' => 'Pickup Code',
'602' => 'Pickup Code', '68' => 'Message Display',
'603' => 'Message Display', '69' => 'Cancel Purchase',
'604' => 'Cancel Purchase', '610' => 'Purchase Finished',
'605' => 'Purchase Finished', '611' => 'Welcome Gift',
'611' => 'Welcome Gift Status',
'612' => 'Dispense Failed', '612' => 'Dispense Failed',
]; ];

View File

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

View File

@@ -14,6 +14,7 @@ class MachineSlot extends Model
'machine_id', 'machine_id',
'product_id', 'product_id',
'slot_no', 'slot_no',
'type',
'max_stock', 'max_stock',
'stock', 'stock',
'expiry_date', 'expiry_date',

View File

@@ -11,16 +11,16 @@ class RemoteCommand extends Model
protected $fillable = [ protected $fillable = [
'machine_id', 'machine_id',
'command', 'user_id',
'command_type',
'payload', 'payload',
'status', 'status',
'response_payload', 'ttl',
'executed_at', 'executed_at',
]; ];
protected $casts = [ protected $casts = [
'payload' => 'array', 'payload' => 'array',
'response_payload' => 'array',
'executed_at' => 'datetime', 'executed_at' => 'datetime',
]; ];
@@ -28,4 +28,17 @@ class RemoteCommand extends Model
{ {
return $this->belongsTo(Machine::class); 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');
}
} }

View File

@@ -11,12 +11,19 @@ class Product extends Model
{ {
use HasFactory, SoftDeletes, TenantScoped; use HasFactory, SoftDeletes, TenantScoped;
/**
* Scope a query to only include active products.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
protected $fillable = [ protected $fillable = [
'company_id', 'company_id',
'category_id', 'category_id',
'name', 'name',
'name_dictionary_key', 'name_dictionary_key',
'sku',
'barcode', 'barcode',
'spec', 'spec',
'manufacturer', 'manufacturer',

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ class OrderItem extends Model
'order_id', 'order_id',
'product_id', 'product_id',
'product_name', 'product_name',
'sku', 'barcode',
'price', 'price',
'quantity', 'quantity',
'subtotal', 'subtotal',

View File

@@ -4,11 +4,62 @@ namespace App\Services\Machine;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog; use App\Models\Machine\MachineLog;
use App\Models\Machine\RemoteCommand;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Carbon\Carbon; use Carbon\Carbon;
class MachineService class MachineService
{ {
/**
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
*/
public const ERROR_CODE_MAP = [
// 出貨狀態類 (Prefix: 04 - BUY_STATUS)
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'],
'0402' => ['label' => 'Dispense successful', 'level' => 'info'],
'0403' => ['label' => 'Slot jammed', 'level' => 'error'],
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'],
'0406' => ['label' => 'Slot not found', 'level' => 'error'],
'0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'],
'0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'],
'0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'],
'040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'],
'0410' => ['label' => 'Elevator rising', 'level' => 'info'],
'0411' => ['label' => 'Elevator descending', 'level' => 'info'],
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'],
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'],
'0414' => ['label' => 'Pickup door closed', 'level' => 'info'],
'0415' => ['label' => 'Pickup door error', 'level' => 'error'],
'0416' => ['label' => 'Delivery door opened', 'level' => 'info'],
'0417' => ['label' => 'Delivery door open error', 'level' => 'error'],
'0418' => ['label' => 'Delivering product', 'level' => 'info'],
'0419' => ['label' => 'Delivery door closed', 'level' => 'info'],
'0420' => ['label' => 'Delivery door close error', 'level' => 'error'],
'0421' => ['label' => 'Hopper empty', 'level' => 'warning'],
'0422' => ['label' => 'Hopper overheated', 'level' => 'warning'],
'0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'],
'0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'],
'0426' => ['label' => 'Microwave door opened', 'level' => 'info'],
'0427' => ['label' => 'Microwave door error', 'level' => 'error'],
'04FF' => ['label' => 'Dispense stopped', 'level' => 'info'],
// 貨道狀態類 (Prefix: 02 - SLOT_STATUS)
'0201' => ['label' => 'Slot normal', 'level' => 'info'],
'0202' => ['label' => 'Product empty', 'level' => 'warning'],
'0203' => ['label' => 'Slot empty', 'level' => 'warning'],
'0206' => ['label' => 'Slot not closed', 'level' => 'warning'],
'0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'],
'0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'],
'0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'],
'0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'],
// 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS)
'5400' => ['label' => 'Machine normal', 'level' => 'info'],
'5401' => ['label' => 'Elevator sensor error', 'level' => 'error'],
'5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'],
'5403' => ['label' => 'Elevator failure', 'level' => 'error'],
];
/** /**
* Update machine heartbeat and status. * Update machine heartbeat and status.
* *
@@ -29,7 +80,6 @@ class MachineService
$model = $data['model'] ?? $machine->model; $model = $data['model'] ?? $machine->model;
$updateData = [ $updateData = [
'status' => 'online',
'temperature' => $temperature, 'temperature' => $temperature,
'current_page' => $currentPage, 'current_page' => $currentPage,
'door_status' => $doorStatus, 'door_status' => $doorStatus,
@@ -64,24 +114,50 @@ class MachineService
public function syncSlots(Machine $machine, array $slotsData): void public function syncSlots(Machine $machine, array $slotsData): void
{ {
DB::transaction(function () use ($machine, $slotsData) { 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) { foreach ($slotsData as $slotData) {
$slotNo = $slotData['slot_no'] ?? null; $slotNo = $slotData['slot_no'] ?? null;
if (!$slotNo) continue; if (!$slotNo) continue;
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first(); $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 = [ $updateData = [
'product_id' => $slotData['product_id'] ?? null, 'product_id' => $actualProductId,
'type' => $slotType,
'stock' => $slotData['stock'] ?? 0, 'stock' => $slotData['stock'] ?? 0,
'capacity' => $slotData['capacity'] ?? ($existingSlot->capacity ?? 10), 'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
'price' => $slotData['price'] ?? ($existingSlot->price ?? 0), 'is_active' => true,
'last_restocked_at' => now(),
]; ];
// 如果商品變了,或者這是一次明確的補貨回報,清空效期等待管理員更新 // 如果這是一次明確的補貨回報,建議更新時間並記錄
// 這裡我們暫定只要有 report 進來,就需要重新確認效期
$updateData['expiry_date'] = null;
if ($existingSlot) { if ($existingSlot) {
$existingSlot->update($updateData); $existingSlot->update($updateData);
} else { } 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). * Update machine slot stock (single slot).
* Legacy support for recordLog (Existing code). * Legacy support for recordLog (Existing code).
@@ -114,10 +281,10 @@ class MachineService
$start = Carbon::parse($date)->startOfDay(); $start = Carbon::parse($date)->startOfDay();
$end = Carbon::parse($date)->endOfDay(); $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 $machines = Machine::all(); // This is filtered by TenantScoped
$totalMachines = $machines->count(); $totalMachines = $machines->count();
$onlineCount = $machines->where('status', 'online')->count(); $onlineCount = Machine::online()->count();
$machineIds = $machines->pluck('id')->toArray(); $machineIds = $machines->pluck('id')->toArray();

View File

@@ -42,7 +42,7 @@ class TransactionService
$order->items()->create([ $order->items()->create([
'product_id' => $item['product_id'], 'product_id' => $item['product_id'],
'product_name' => $item['product_name'] ?? 'Unknown', 'product_name' => $item['product_name'] ?? 'Unknown',
'sku' => $item['sku'] ?? null, 'barcode' => $item['barcode'] ?? null,
'price' => $item['price'], 'price' => $item['price'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'], 'subtotal' => $item['price'] * $item['quantity'],

View File

@@ -8,6 +8,226 @@ return [
[ [
'name' => '機台核心通訊 (IoT Core)', 'name' => '機台核心通訊 (IoT Core)',
'apis' => [ 'apis' => [
[
'name' => 'B000: 維運人員登入認證 (Technician Login)',
'slug' => 'b000-tech-login',
'method' => 'POST',
'path' => '/api/v1/app/admin/login/B000',
'description' => '機台啟動引導的第一步。維運人員輸入個人帳密與機台編號進行認證,成功後核發臨時 Sanctum Token 供後續 B014 下載敏感設定使用。',
'headers' => [
'Content-Type' => 'application/json',
],
'parameters' => [
'username' => [
'type' => 'string',
'required' => true,
'description' => '維運人員帳號',
'example' => 'admin_test'
],
'password' => [
'type' => 'string',
'required' => true,
'description' => '維運人員密碼',
'example' => 'password123'
],
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號 (Serial No)',
'example' => 'SN202604130001'
],
],
'response_parameters' => [
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Success'
],
'token' => [
'type' => 'string',
'description' => '臨時身份認證 Token (Sanctum)',
'example' => '1|abcdefg...'
],
],
'request' => [
'username' => 'admin_test',
'password' => 'password123',
'machine' => 'SN202604130001'
],
'response' => [
'message' => 'Success',
'token' => '1|abcdefg...'
],
],
[
'name' => 'B014: 機台參數與金鑰下載 (Config Download)',
'slug' => 'b014-config-download',
'method' => 'GET',
'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' => [],
'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)', 'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)',
'slug' => 'b010-heartbeat', 'slug' => 'b010-heartbeat',
@@ -91,11 +311,10 @@ return [
'status' => [ 'status' => [
'type' => 'string', 'type' => 'string',
'description' => '雲端指令代碼。對照表: 'description' => '雲端指令代碼。對照表:
49: reload B017 (貨道同步), 50: reload B005 (基礎參數), 51: reboot (重啟) 49: reload B017 (貨道同步), 51: reboot (重啟系統)
60: reboot card machine (刷卡機重啟), 61: checkout (結帳) 60: reboot card machine (刷卡機重啟), 61: checkout (觸發結帳)
70: unlock (解鎖), 71: lock (鎖定), 72: sellCode reload B023 (即期品) 70: unlock (解鎖), 71: lock (鎖定), 85: reload B0552 (遠端出貨)
75: exp reload B026 (效期), 79: read B050 (參數讀取) 待定義: change (遠端找零 - 目前 Java App 尚無對接)',
81: sync timer status (B710), 85: reload B0552 (出貨腳本)',
'example' => '49' 'example' => '49'
], ],
], ],
@@ -119,6 +338,260 @@ return [
'status' => '49' 'status' => '49'
], ],
'notes' => '機台收到 B010 回應中的特定 `status` 代碼後,應根據對照表執行對應的指令動作或 API 呼叫 (如 B017)。若為空則代表無指令。' '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: 取貨門異常...等。'
],
[
'name' => 'B017: 貨道庫存同步 (Slot Synchronization)',
'slug' => 'b017-slot-sync',
'method' => 'GET',
'path' => '/api/v1/app/machine/reload_msg/B017',
'description' => '用於機台端獲獲取所有貨道的最新庫存、效期與狀態。通常由 B010 回傳 status: 49 時觸發。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '是否成功',
'example' => true
],
'data' => [
'type' => 'array',
'description' => '貨道數據陣列。',
'example' => [
[
'tid' => '1',
'num' => 10,
'expiry_date' => '2026-12-31',
'batch_no' => 'B2026',
'status' => '1'
]
]
],
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'data' => [
[
'tid' => '1',
'num' => 10,
'expiry_date' => '2026-12-31',
'batch_no' => 'B2026',
'product_id' => 1,
'capacity' => 15,
'status' => '1'
]
]
],
'notes' => 'B017 為全量同步。實作上後端會依據 slot_no 進行排序,並將相關指令狀態更新為已完成。'
],
[
'name' => 'B024: 取貨碼/通行碼驗證與消耗回報',
'slug' => 'b024-access-code',
'method' => 'POST/PUT',
'path' => '/api/v1/app/sell/access-code/B024',
'description' => '處理代碼取貨流程。POST 用於驗證碼有效性PUT 用於回報出貨成功並消耗代碼。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'passCode' => [
'type' => 'string',
'description' => '取貨碼 (POST)',
],
'accessCodeId' => [
'type' => 'string',
'description' => '代碼 ID (PUT)',
],
'status' => [
'type' => 'string',
'description' => '出貨狀態 (PUT: 1:成功, 0:失敗)',
],
],
'response_parameters' => [
'res1' => ['type' => 'string', 'description' => '雲端關聯 ID'],
'res3' => ['type' => 'string', 'description' => '預計出貨商品 ID'],
],
'request' => [
'passCode' => '12345678'
],
'response' => [
'success' => true,
'res1' => '99',
'res3' => '5'
],
],
[
'name' => 'B027: 贈品碼/優惠券驗證與消耗回報',
'slug' => 'b027-freebie-code',
'method' => 'POST/PUT',
'path' => '/api/v1/app/sell/free-gift/B027',
'description' => '處理贈品券與 0 元購活動。邏輯與 B024 相似但對象為行銷贈品。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'passCode' => [
'type' => 'string',
'description' => '贈品碼 (POST)',
],
],
'response_parameters' => [
'success' => ['type' => 'boolean', 'description' => '驗證結果'],
],
'request' => [
'passCode' => 'FREE888'
],
'response' => [
'success' => true,
'message' => 'Free gift verified'
],
],
[
'name' => 'B055: 遠端指令出貨控制 (Remote Dispense)',
'slug' => 'b055-remote-dispense',
'method' => 'POST/PUT',
'path' => '/api/v1/app/machine/dispense/B055',
'description' => '遠端手動驅動機台出貨。POST 用於獲取待處理指令PUT 用於回報出貨完成。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'id' => ['type' => 'string', 'description' => '指令 ID (PUT)'],
'stock' => ['type' => 'string', 'description' => '剩餘庫存 (PUT)'],
],
'request' => [],
'response' => [
'success' => true,
'data' => [
['slot_no' => '1', 'order_id' => 'RE-123']
]
],
] ]
] ]
] ]

View File

@@ -14,7 +14,6 @@ class MachineFactory extends Factory
return [ return [
'name' => 'Machine-' . fake()->unique()->numberBetween(101, 999), 'name' => 'Machine-' . fake()->unique()->numberBetween(101, 999),
'location' => fake()->address(), 'location' => fake()->address(),
'status' => fake()->randomElement(['online', 'offline', 'error']),
'temperature' => fake()->randomFloat(2, 2, 10), 'temperature' => fake()->randomFloat(2, 2, 10),
'firmware_version' => 'v' . fake()->randomElement(['1.0.0', '1.1.2', '2.0.1']), 'firmware_version' => 'v' . fake()->randomElement(['1.0.0', '1.1.2', '2.0.1']),
'serial_no' => 'SN-' . strtoupper(fake()->unique()->bothify('??###?')), 'serial_no' => 'SN-' . strtoupper(fake()->unique()->bothify('??###?')),

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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'");
}
}
};

View File

@@ -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');
});
}
};

View File

@@ -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();
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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']);
});
}
};

View File

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

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('company_contracts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained()->onDelete('cascade');
$table->string('type')->comment('buyout, lease');
$table->date('start_date')->nullable();
$table->date('end_date')->nullable();
$table->date('warranty_start_date')->nullable();
$table->date('warranty_end_date')->nullable();
$table->date('software_start_date')->nullable();
$table->date('software_end_date')->nullable();
$table->text('note')->nullable();
$table->foreignId('creator_id')->nullable()->constrained('users');
$table->timestamps();
$table->index(['company_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('company_contracts');
}
};

View File

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

View File

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

View File

@@ -15,22 +15,34 @@ gantt
excludes weekends excludes weekends
section Phase 1 基礎建設 section Phase 1 基礎建設
資料表 + IoT API + 異步管線 :active, p1, 2026-03-16, 5d 資料表 + IoT API + 異步管線 :done, p1, 2026-03-16, 5d
section Phase 2 核心營運 section Phase 2 核心營運
後台核心營運頁面整合 :p2, after p1, 50d 帳號權限 + 資料主檔 + 機台 + 遠端 + 儀表板 :done, p2a, 2026-03-23, 30d
MQTT 基礎架構 :active, mqtt, 2026-04-16, 3d
全域工具列與溝通系統 :todo, toolbar, 2026-04-21, 4d
銷售管理 :todo, p2d, after toolbar, 3d
倉庫管理 :todo, p2f, after p2d, 6d
section Phase 3 進階模組 section Phase 3 進階模組
進階分析與垂直模組 :p3, after p2, 30d 進階分析與垂直模組 :todo, p3, after p2f, 15d
``` ```
## 二、詳細時程對照表 ## 二、詳細時程對照表
| 階段 (Phase) | 關鍵任務摘要 | 預估天數 | 預計工作日期 | 狀態 | | 階段 (Phase) | 關鍵任務摘要 | 預估天數 | 預計工作日期 | 狀態 |
| :--- | :--- | :---: | :---: | :---: | | :--- | :--- | :---: | :---: | :---: |
| **Phase 1** | 28 張資料表 Migration + B010~B710 核心 API + Redis 異步 Job | **5 工作** | 03/16 ~ 03/20 | 進行中 | | **Phase 1** | 資料表 Migration + IoT 核心 API + Redis 異步 Job | **5 天** | 03/16 ~ 03/20 | ✅ 完成 |
| **Phase 2** | **後台核心營運頁面** (帳號權限、資料設定、機台、銷售、遠端、倉庫、儀表板) | **50 工作** | 03/23 ~ 05/29 | 規劃中 | | **Phase 2A** | 帳號與權限基礎 (帳號管理、子帳號、角色、RBAC) | **8 ** | 03/23 ~ 04/03 | ✅ 完成 |
| **Phase 3** | **進階垂直模組** (分析、稽核、會員、APP、Line、預約、特殊權限) | **30 工作** | 06/01 ~ 07/10 | 規劃中 | | **Phase 2B** | 基礎資料主檔 (商品、廣告、點數、識別證) | **5 ** | 04/06 ~ 04/10 | ✅ 完成 |
| **Phase 2C** | 機台管理 (列表、日誌、權限、稼動率、效期、維修) | **7 天** | 04/13 ~ 04/23 | ✅ 完成 |
| **Phase 2E** | 遠端管理 (庫存、重啟、出貨、鎖定等 7 項) | **8 天** | 04/30 ~ 05/11 | ✅ 完成 |
| **Phase 2G** | 儀表板 | **2 天** | 05/28 ~ 05/29 | ✅ 完成 |
| **MQTT** | EMQX + Go Gateway + Laravel 橋接 | **3 天** | 04/16 ~ 04/18 | 🔴 待開發 |
| **全域工具列** | 下載中心、通知、帳號模擬、公告系統、快捷入口 | **4 天** | 04/21 ~ 04/24 | 🟡 待開發 |
| **Phase 2D** | 銷售管理 (銷售紀錄、取貨碼、促銷、通行碼) | **3 天** | 04/25 ~ 04/29 | 🟢 待開發 |
| **Phase 2F** | 倉庫管理 (倉庫、庫存、調撥、採購、補貨) | **6 天** | 04/30 ~ 05/07 | 🟢 待開發 |
| **Phase 3** | 進階垂直模組 (分析、稽核、會員、APP、Line、預約) | **15 天** | 05/08 ~ 05/28 | 🔵 待開發 |
--- ---
@@ -39,7 +51,7 @@ gantt
> [!IMPORTANT] > [!IMPORTANT]
> 開發順序依**功能相依性**排列:先建帳號與權限基礎 → 再建商品等主檔資料 → 然後是依賴主檔的機台與銷售 → 接著是營運急需的遠端管理與倉庫管理 → 最後是匯總數據的儀表板。Phase 3 則從分析報表開始,逐步擴展至行銷與第三方聯動。 > 開發順序依**功能相依性**排列:先建帳號與權限基礎 → 再建商品等主檔資料 → 然後是依賴主檔的機台與銷售 → 接著是營運急需的遠端管理與倉庫管理 → 最後是匯總數據的儀表板。Phase 3 則從分析報表開始,逐步擴展至行銷與第三方聯動。
### ⚡ Phase 1基礎建設 (03/16 ~ 03/20) ### ⚡ Phase 1基礎建設 (03/16 ~ 03/20) ✅ 已完成
| 任務類別 | 內容 | 日期 | | 任務類別 | 內容 | 日期 |
| :--- | :--- | :---: | | :--- | :--- | :---: |
@@ -47,10 +59,27 @@ gantt
| IoT API 端點 | B010 心跳、B017 庫存、B055 出貨、B600/B601/B602 金流 | 03/18 - 03/19 | | IoT API 端點 | B010 心跳、B017 庫存、B055 出貨、B600/B601/B602 金流 | 03/18 - 03/19 |
| 異步管線 | Redis Queue Job + Service 層、B650 會員驗證 | 03/20 | | 異步管線 | Redis Queue Job + Service 層、B650 會員驗證 | 03/20 |
### 🔌 MQTT 基礎架構 (04/16 ~ 04/18) 🔴 待開發
| 日期 | 任務 |
| :---: | :--- |
| **04/16 (三)** | EMQX 佈署至 compose.yaml + Go Gateway 上行開發 |
| **04/17 (四)** | Go Gateway 上行完成 + 下行開發 |
| **04/18 (五)** | Laravel mqtt:listen + MqttCommandService + 端對端測試 |
### 🛠️ 全域工具列與溝通系統 (04/21 ~ 04/24) 🟡 待開發
| 日期 | 任務 |
| :---: | :--- |
| **04/21 (一)** | ☁️ 下載任務中心 |
| **04/22 (二)** | 🔔 通知中心 + ❓ 幫助/客服中心 |
| **04/23 (三)** | 🎭 帳號切換與身分模擬 + 📢 系統公告管理 |
| **04/24 (四)** | 🛡️ 登錄強制公告 + 🚀 儀表板快捷入口 |
### 🏛️ Phase 2核心營運子選單 (03/23 ~ 05/29) ### 🏛️ Phase 2核心營運子選單 (03/23 ~ 05/29)
共 51 項子選單,依功能相依性分為七個開發階段。 共 51 項子選單,依功能相依性分為七個開發階段。
#### 📌 A. 帳號與權限基礎 (03/23 ~ 04/03) #### 📌 A. 帳號與權限基礎 (03/23 ~ 04/03) ✅ 已完成
> 為何優先:帳號、角色、權限是所有後台模組的存取控管基礎,必須最先到位。 > 為何優先:帳號、角色、權限是所有後台模組的存取控管基礎,必須最先到位。
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 | | # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
@@ -72,7 +101,7 @@ gantt
| 15 | | 其他功能 | 04/03 | 其餘未分類功能之權限控管 | | 15 | | 其他功能 | 04/03 | 其餘未分類功能之權限控管 |
| 16 | | AI智能預測 | 04/03 | AI 預測功能的存取權限設定 | | 16 | | AI智能預測 | 04/03 | AI 預測功能的存取權限設定 |
#### 📌 B. 基礎資料主檔 (04/06 ~ 04/10) #### 📌 B. 基礎資料主檔 (04/06 ~ 04/10) ✅ 已完成
> 為何第二:商品主檔是機台貨道、倉庫、銷售等後續模組的共同基礎資料。 > 為何第二:商品主檔是機台貨道、倉庫、銷售等後續模組的共同基礎資料。
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 | | # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
@@ -83,7 +112,7 @@ gantt
| 20 | | 點數設定 | 04/10 | 點數發放規則與兌換比例設定 | | 20 | | 點數設定 | 04/10 | 點數發放規則與兌換比例設定 |
| 21 | | 識別證 | 04/10 | 員工/維修人員識別證管理 | | 21 | | 識別證 | 04/10 | 員工/維修人員識別證管理 |
#### 📌 C. 機台管理 (04/13 ~ 04/23) #### 📌 C. 機台管理 (04/13 ~ 04/23) ✅ 已完成
> 為何第三:機台是核心營運實體,須在商品主檔建好後才能綁定貨道。 > 為何第三:機台是核心營運實體,須在商品主檔建好後才能綁定貨道。
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 | | # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
@@ -107,7 +136,7 @@ gantt
| 32 | | 通行碼 | 04/29 | 通行碼發放與使用紀錄 | | 32 | | 通行碼 | 04/29 | 通行碼發放與使用紀錄 |
| 33 | | 來店禮 | 04/29 | 到店即贈的禮品活動設定 | | 33 | | 來店禮 | 04/29 | 到店即贈的禮品活動設定 |
#### 📌 E. 遠端管理 (04/30 ~ 05/11) #### 📌 E. 遠端管理 (04/30 ~ 05/11) ✅ 已完成
> 為何第五:營運最迫切需要的即時控制能力,直接串接 Phase 1 的 B010/B055 API。 > 為何第五:營運最迫切需要的即時控制能力,直接串接 Phase 1 的 B010/B055 API。
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 | | # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
@@ -136,7 +165,7 @@ gantt
| 49 | | 人員庫存 | 05/27 | 補貨人員攜帶庫存管理 | | 49 | | 人員庫存 | 05/27 | 補貨人員攜帶庫存管理 |
| 50 | | 回庫單 | 05/27 | 退回倉庫的商品登記與核銷 | | 50 | | 回庫單 | 05/27 | 退回倉庫的商品登記與核銷 |
#### 📌 G. 儀表板 (05/28 ~ 05/29) #### 📌 G. 儀表板 (05/28 ~ 05/29) ✅ 已完成
> 為何最後:儀表板匯總機台、銷售、遠端指令、倉庫等全部數據,必須等上游模組完成才有意義。 > 為何最後:儀表板匯總機台、銷售、遠端指令、倉庫等全部數據,必須等上游模組完成才有意義。
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 | | # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |

43
docs/future_todo.md Normal file
View File

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

View File

@@ -0,0 +1,191 @@
# Star Cloud MQTT 基礎架構實作計畫 (Phase 1)
本計畫旨在建立 Star Cloud 的高併發通訊基石,包含佈署 EMQX Broker、開發 Go MQTT Gateway並建立與 Laravel 之間的 Redis **雙向**異步橋接機制。
---
## User Review Required
> [!IMPORTANT]
> **雙向通訊架構**:本計畫不只處理「機台 ➜ 雲端」的上報,也同時建立「雲端 ➜ 機台」的指令下發通道。後台管理員在按下「遠端出貨」按鈕時Laravel 會將指令推入 Redis由 Go Gateway 轉發至 EMQX再即時送達機台 APP。
> [!WARNING]
> **資源配額**Go Gateway 雖然輕量,但在 Docker 環境中仍建議設定 `mem_limit` 避免極端情況下的資源爭搶。
---
## 系統架構總覽
```
機台 Android APP
├─ [上行] Publish ──→ EMQX ──→ Go Gateway ──→ Redis List (mqtt_incoming_jobs) ──→ Laravel mqtt:listen ──→ Job ──→ MySQL
└─ [下行] Subscribe ←── EMQX ←── Go Gateway ←── Redis List (mqtt_outgoing_commands) ←── Laravel MqttCommandService
```
---
## Proposed Changes
### 1. 基礎設施佈署 (Infrastructure)
#### [MODIFY] [compose.yaml](file:///home/mama/projects/star-cloud/compose.yaml)
- **新增 `emqx` 服務**
- Image: `emqx/emqx:5.10.3`開源版最終穩定版Apache 2.0 授權)
- Ports: `1883:1883` (MQTT), `8083:8083` (WebSocket), `18083:18083` (Dashboard)
- 加入 `sail` 網路
- 配置 Redis Auth 插件環境變數,指向 `star-cloud-redis`
- **新增 `mqtt-gateway` 服務**
- 使用 `mqtt-gateway/Dockerfile` 進行 Multi-stage build
- 連接至 `sail` 網路
- 依賴 `emqx``redis`
- 環境變數從 `.env` 讀取
#### [MODIFY] [.env](file:///home/mama/projects/star-cloud/.env)
- 新增以下環境變數:
```env
# MQTT / EMQX
MQTT_BROKER_HOST=emqx
MQTT_BROKER_PORT=1883
EMQX_DASHBOARD_PORT=18083
# Go Gateway
MQTT_GATEWAY_CLIENT_ID=star-cloud-gateway
MQTT_REDIS_HOST=star-cloud-redis
MQTT_REDIS_PORT=6379
```
---
### 2. Go MQTT Gateway 開發 (The Bridge)
#### [NEW] 完整目錄結構
```
mqtt-gateway/
├── Dockerfile ← Multi-stage build (builder + alpine)
├── go.mod
├── go.sum
├── main.go ← 進入點初始化、訊號監聽、graceful shutdown
├── config/
│ └── config.go ← 讀取環境變數 (EMQX_ADDR, REDIS_ADDR 等)
├── internal/
│ ├── handler/
│ │ ├── heartbeat_handler.go ← 處理 machine/+/heartbeat
│ │ ├── error_handler.go ← 處理 machine/+/error
│ │ └── transaction_handler.go ← 處理 machine/+/transaction
│ └── bridge/
│ ├── redis_consumer.go ← [下行] BLPOP mqtt_outgoing_commands轉發至 EMQX
│ └── redis_pusher.go ← [上行] RPUSH mqtt_incoming_jobs
```
#### [上行邏輯] 機台 ➜ 雲端
1. 訂閱 `machine/+/heartbeat`, `machine/+/error`, `machine/+/transaction`
2. 從 Topic 路徑提取 `serial_no`
3. 包裝成 `BridgePayload`
```json
{
"type": "heartbeat",
"serial_no": "M-001",
"payload": { "current_page": 1, "temperature": 25.5 },
"received_at": "2026-04-14T09:00:00+08:00"
}
```
4. 執行 `RPUSH mqtt_incoming_jobs {json}`
#### [下行邏輯] 雲端 ➜ 機台 (新增)
1. Go Gateway 啟動一條 Goroutine持續 `BLPOP mqtt_outgoing_commands`
2. 取得 JSON 後解析目標 `serial_no``command` 內容。
3. Publish 至 `machine/{serial_no}/command` (QoS 1)。
```json
{
"target": "M-001",
"command": "dispense",
"payload": { "slot_no": 5, "transaction_id": "T202604140001" },
"message_id": "MSG_123456789"
}
```
#### [NEW] mqtt-gateway/Dockerfile
```dockerfile
# Stage 1: Build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o gateway .
# Stage 2: Run
FROM alpine:3.20
COPY --from=builder /app/gateway /usr/local/bin/gateway
CMD ["gateway"]
```
---
### 3. Laravel 端實作
#### [NEW] app/Console/Commands/ListenMqttQueue.php
- Artisan Command: `mqtt:listen`
- 使用 `BLPOP mqtt_incoming_jobs` 阻塞式監聽
- 根據 `type` 分派至對應的 Laravel Job
- `heartbeat``ProcessHeartbeatJob`
- `error``ProcessMachineErrorJob`
- `transaction``ProcessTransactionJob`
#### [NEW] app/Services/Machine/MqttCommandService.php
- 提供 `sendCommand(string $serialNo, string $command, array $payload)` 方法
- 將指令 JSON 推入 Redis List `mqtt_outgoing_commands`
- 供 Controller 呼叫(例如後台管理員按下「遠端出貨」按鈕)
#### [NEW] app/Jobs/Machine/ (三個 Job)
- `ProcessHeartbeatJob.php`: 更新 `last_heard_at`、溫度、頁面碼
- `ProcessMachineErrorJob.php`: 寫入 `machine_logs`,觸發告警通知
- `ProcessTransactionJob.php`: 更新庫存、建立交易紀錄
#### [MODIFY] [MachineAuthController.php](file:///home/mama/projects/star-cloud/app/Http/Controllers/Api/V1/App/MachineAuthController.php)
- 在 B014 核發 `api_token` 後,同步寫入 Redis
`Redis::set("machine_auth:{$serial_no}", hash('sha256', $token));`
- Token 更新/撤銷時,同步刪除 Redis 中的對應 Key
---
## Architecture Decisions (Confirmed)
### ✅ EMQX 版本策略
- **統一鎖定**:開發與正式環境皆使用 `emqx/emqx:5.10.3`(開源版最後一個穩定版本)。
- **選擇理由**EMQX 從 5.9.0 起合併為統一版本6.x 改用 BSL 商業授權。`5.10.3` 是純開源 (Apache 2.0) 的最終穩定版,功能完整且免授權費用。
### ✅ 下行指令安全性(無需額外 OTP
App 連線 MQTT 時已使用 `api_token` 作為密碼完成身份認證,因此 **MQTT 連線本身即為認證通道**。安全性由以下三層保障:
1. **連線層**App 使用 `serial_no` + `api_token` 連線 EMQX未通過驗證的裝置無法訂閱任何 Topic。
2. **ACL 層**EMQX 存取控制確保只有 Go Gateway 能 Publish 到 `machine/{id}/command`,機台 App 無法偽造指令。
3. **冪等性層**:每條下行指令的 Payload 包含唯一的 `message_id`App 端應記錄已執行的 ID防止重複執行同一條指令。
> [!NOTE]
> **結論**:上行用 Token 當密碼、下行用 ACL 當門禁、`message_id` 當防重複鎖。三層防護已足夠,不需要額外的 OTP 機制。
---
## Verification Plan
### 1. 基礎設施驗證
- 執行 `./vendor/bin/sail up -d`
- 造訪 `http://localhost:18083` 確認 EMQX Dashboard 正常啟動
- 確認 Go Gateway Container 的 logs 顯示 "Connected to EMQX" 與 "Connected to Redis"
### 2. 上行通訊測試 (機台 ➜ 雲端)
- 使用 MQTTX 工具連接 `localhost:1883`
- 發送心跳 JSON 至 `machine/TEST-001/heartbeat`
- 檢查 Laravel 日誌確認 `mqtt:listen` 成功接收並分派 Job
- 檢查 MySQL `machines` 表的 `last_heard_at` 是否更新
### 3. 下行通訊測試 (雲端 ➜ 機台)
- 在 MQTTX 訂閱 `machine/TEST-001/command`
- 透過 Laravel Tinker 呼叫 `MqttCommandService::sendCommand('TEST-001', 'reboot', [])`
- 確認 MQTTX 收到 reboot 指令的 JSON
### 4. 壓力測試
- 使用 Go Script 模擬 500 台機台同時發送心跳
- 監控 Redis List 長度與 Laravel Worker 的處理速率

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

7
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -102,8 +102,8 @@
<input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required> <input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Serial Number') }}</label> <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" 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> <input type="text" name="serial_no" value="{{ old('serial_no', $machine->serial_no) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label> <label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label>
@@ -219,6 +219,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Right Column: Images & Primary System Settings --> <!-- 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"> <div class="w-full lg:w-96 space-y-8 order-1 lg:order-2 lg:sticky top-24">
@@ -230,7 +231,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /> <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> </svg>
</div> </div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Machine Photos') }}</h3> <h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Machine Images') }}</h3>
</div> </div>
<div class="space-y-8"> <div class="space-y-8">

View File

@@ -194,8 +194,7 @@
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Machine Settings') }}</h1> <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">{{ <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>
__('Management of operational parameters and models') }}</p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@if($tab === 'machines') @if($tab === 'machines')
@@ -499,8 +498,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" <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" /> 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> </svg>
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No <p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No accounts found') }}</p>
accounts found') }}</p>
</div> </div>
</td> </td>
</tr> </tr>
@@ -628,8 +626,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800"> 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 <div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center"> 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 <h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add Machine') }}</h3>
Machine') }}</h3>
<button @click="showCreateMachineModal = false" <button @click="showCreateMachineModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> 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"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -732,8 +729,7 @@
</div> </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"> 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">{{ <button type="button" @click="showCreateMachineModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
__('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button> <button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button>
</div> </div>
</form> </form>
@@ -757,8 +753,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800"> 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 <div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center"> 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 <h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add Machine Model') }}</h3>
Machine Model') }}</h3>
<button @click="showCreateModelModal = false" <button @click="showCreateModelModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> 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"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -774,16 +769,14 @@
<div class="px-8 py-8 space-y-6"> <div class="px-8 py-8 space-y-6">
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Model Name') }}</label>
__('Model Name') }}</label>
<input type="text" name="name" required class="luxury-input w-full" <input type="text" name="name" required class="luxury-input w-full"
placeholder="{{ __('Enter model name') }}"> placeholder="{{ __('Enter model name') }}">
</div> </div>
</div> </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"> 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">{{ <button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
__('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Create') }}</button> <button type="submit" class="btn-luxury-primary px-8">{{ __('Create') }}</button>
</div> </div>
</form> </form>
@@ -807,8 +800,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800"> 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 <div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center"> 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">{{ <h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Edit Machine Model') }}</h3>
__('Edit Machine Model') }}</h3>
<button @click="showEditModelModal = false" <button @click="showEditModelModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> 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"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -824,16 +816,14 @@
<div class="px-8 py-8 space-y-6"> <div class="px-8 py-8 space-y-6">
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Model Name') }}</label>
__('Model Name') }}</label>
<input type="text" name="name" x-model="currentModel.name" required <input type="text" name="name" x-model="currentModel.name" required
class="luxury-input w-full" placeholder="{{ __('Enter model name') }}"> class="luxury-input w-full" placeholder="{{ __('Enter model name') }}">
</div> </div>
</div> </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"> 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">{{ <button type="button" @click="showEditModelModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
__('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button> <button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button>
</div> </div>
</form> </form>
@@ -943,7 +933,7 @@
</div> </div>
<p <p
class="text-xs font-bold text-amber-700 dark:text-amber-300 leading-relaxed text-left flex-1"> class="text-xs font-bold text-amber-700 dark:text-amber-300 leading-relaxed text-left flex-1">
{{ __('Optimized for display. Supported formats: JPG, PNG, WebP (Max 10MB).') }} {{ __('PNG, JPG, WEBP up to 10MB') }}
</p> </p>
</div> </div>
</div> </div>
@@ -1103,8 +1093,7 @@
</section> </section>
</template> </template>
<section class="space-y-6"> <section class="space-y-6">
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware <h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware & Network') }}</h3>
& Network') }}</h3>
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<div <div
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80"> class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
@@ -1291,9 +1280,7 @@
<div <div
class='w-10 h-10 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin'> class='w-10 h-10 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin'>
</div> </div>
<span <span class='text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] animate-pulse'>{{ __('Syncing Permissions...') }}</span>
class='text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] animate-pulse'>{{
__('Syncing Permissions...') }}</span>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -2,8 +2,11 @@
@section('content') @section('content')
<div class="space-y-6" x-data="{ <div class="space-y-6" x-data="{
showModal: false, showModal: false,
showHistoryModal: false,
editing: false, editing: false,
sidebarView: 'detail',
currentCompany: { currentCompany: {
id: '', id: '',
name: '', name: '',
@@ -16,6 +19,10 @@
contact_email: '', contact_email: '',
start_date: '', start_date: '',
end_date: '', end_date: '',
warranty_start_date: '',
warranty_end_date: '',
software_start_date: '',
software_end_date: '',
status: 1, status: 1,
note: '', note: '',
settings: { settings: {
@@ -28,7 +35,10 @@
this.currentCompany = { this.currentCompany = {
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease', id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
tax_id: '', contact_name: '', contact_phone: '', tax_id: '', contact_name: '', contact_phone: '',
contact_email: '', start_date: '', end_date: '', status: 1, note: '', contact_email: '', start_date: '', end_date: '',
warranty_start_date: '', warranty_end_date: '',
software_start_date: '', software_end_date: '',
status: 1, note: '',
settings: { enable_material_code: false, enable_points: false } settings: { enable_material_code: false, enable_points: false }
}; };
this.showModal = true; this.showModal = true;
@@ -39,6 +49,10 @@
...company, ...company,
start_date: company.start_date ? company.start_date.substring(0, 10) : '', start_date: company.start_date ? company.start_date.substring(0, 10) : '',
end_date: company.end_date ? company.end_date.substring(0, 10) : '', end_date: company.end_date ? company.end_date.substring(0, 10) : '',
warranty_start_date: company.warranty_start_date ? company.warranty_start_date.substring(0, 10) : '',
warranty_end_date: company.warranty_end_date ? company.warranty_end_date.substring(0, 10) : '',
software_start_date: company.software_start_date ? company.software_start_date.substring(0, 10) : '',
software_end_date: company.software_end_date ? company.software_end_date.substring(0, 10) : '',
settings: { settings: {
enable_material_code: company.settings?.enable_material_code || false, enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false enable_points: company.settings?.enable_points || false
@@ -57,9 +71,13 @@
detailCompany: { detailCompany: {
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease', id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
tax_id: '', contact_name: '', contact_phone: '', contact_email: '', tax_id: '', contact_name: '', contact_phone: '', contact_email: '',
start_date: '', end_date: '', status: 1, note: '', start_date: '', end_date: '',
warranty_start_date: '', warranty_end_date: '',
software_start_date: '', software_end_date: '',
status: 1, note: '',
settings: { enable_material_code: false, enable_points: false }, settings: { enable_material_code: false, enable_points: false },
users_count: 0, machines_count: 0 users_count: 0, machines_count: 0,
contracts: []
}, },
openDetailSidebar(company) { openDetailSidebar(company) {
this.detailCompany = { this.detailCompany = {
@@ -69,15 +87,40 @@
enable_points: company.settings?.enable_points || false enable_points: company.settings?.enable_points || false
} }
}; };
this.sidebarView = 'detail';
this.showDetail = true; this.showDetail = true;
}, },
openHistorySidebar(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.sidebarView = 'history';
this.showDetail = true;
},
openFullHistory() {
this.showHistoryModal = true;
},
openHistory(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.showHistoryModal = true;
},
submitConfirmedForm() { submitConfirmedForm() {
if (this.statusToggleSource === 'list') { if (this.statusToggleSource === 'list') {
this.$refs.statusToggleForm.submit(); this.$refs.statusToggleForm.submit();
} else { } else {
this.$refs.companyForm.submit(); this.$refs.companyForm.submit();
} }
} },
}"> }">
<!-- Header --> <!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
@@ -164,7 +207,7 @@
{{ __('Accounts / Machines') }}</th> {{ __('Accounts / Machines') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Contract Period') }}</th> {{ __('Service Terms') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}</th> {{ __('Actions') }}</th>
@@ -235,21 +278,54 @@
</div> </div>
</div> </div>
</td> </td>
<td class="px-6 py-6 text-center text-slate-500 dark:text-slate-400"> <td class="px-6 py-6 border-b border-slate-50 dark:border-slate-800/50">
<div class="flex flex-col items-center gap-1.5 font-mono"> <div class="flex flex-col gap-2.5 min-w-[200px] mx-auto w-fit">
<div class="flex items-center gap-2 min-w-[130px] justify-center"> <!-- Contract Period (Only for Lease) -->
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{{ __('From:') }}</span> @if($company->current_type === 'lease')
<span class="text-[13px] font-bold tracking-tighter text-slate-600 dark:text-slate-300"> <div class="flex items-center gap-3 group/term">
{{ $company->start_date ? $company->start_date->format('Y-m-d') : '--' }} <span class="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-600 text-[10px] font-black border border-blue-500/20 group-hover/term:bg-blue-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Contract') }}
</span> </span>
</div> <div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<div class="flex items-center gap-2 min-w-[130px] justify-center"> <span class="text-slate-400">{{ $company->start_date ? $company->start_date->format('Y-m-d') : '--' }}</span>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{{ __('To:') }}</span> <span class="text-slate-300">~</span>
<span class="text-[13px] font-bold tracking-tighter {{ $company->end_date && $company->end_date->isPast() ? 'text-rose-500' : 'text-slate-800 dark:text-slate-200' }}"> <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') }} {{ $company->end_date ? $company->end_date->format('Y-m-d') : __('Permanent') }}
</span> </span>
</div> </div>
</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>
<td class="px-6 py-6 text-right"> <td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-x-2"> <div class="flex items-center justify-end gap-x-2">
@@ -447,11 +523,11 @@
<input type="text" name="tax_id" x-model="currentCompany.tax_id" <input type="text" name="tax_id" x-model="currentCompany.tax_id"
class="luxury-input w-full"> class="luxury-input w-full">
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3" x-show="currentCompany.current_type === 'lease'">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Start Date') }} <span class="text-rose-500 ml-0.5">*</span></label> __('Start Date') }} <span class="text-rose-500 ml-0.5">*</span></label>
<input type="date" name="start_date" x-model="currentCompany.start_date" required <input type="date" name="start_date" x-model="currentCompany.start_date" :required="currentCompany.current_type === 'lease'"
class="luxury-input w-full px-2"> class="luxury-input w-full px-2">
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -462,6 +538,38 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Buyout Specific Dates -->
<div x-show="currentCompany.current_type === 'buyout'" x-transition class="space-y-4 pt-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-[10px] font-black text-amber-600 uppercase tracking-widest pl-1">{{ __('Warranty Start') }}</label>
<input type="date" name="warranty_start_date" x-model="currentCompany.warranty_start_date"
class="luxury-input w-full px-2 border-amber-100 dark:border-amber-900/30">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-amber-600 uppercase tracking-widest pl-1">{{ __('Warranty End') }}</label>
<input type="date" name="warranty_end_date" x-model="currentCompany.warranty_end_date"
class="luxury-input w-full px-2 border-amber-100 dark:border-amber-900/30">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest pl-1">{{ __('Software Start') }}</label>
<input type="date" name="software_start_date" x-model="currentCompany.software_start_date"
class="luxury-input w-full px-2 border-indigo-100 dark:border-indigo-900/30">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest pl-1">{{ __('Software End') }}</label>
<input type="date" name="software_end_date" x-model="currentCompany.software_end_date"
class="luxury-input w-full px-2 border-indigo-100 dark:border-indigo-900/30">
</div>
</div>
</div>
<!-- Keep hidden start_date for buyout as well if needed by backend -->
<input type="hidden" name="start_date" :value="currentCompany.start_date" x-if="currentCompany.current_type === 'buyout' && !currentCompany.start_date">
</div>
</div> </div>
<!-- Admin Account Section (Account Creation) - Only show when creating --> <!-- Admin Account Section (Account Creation) - Only show when creating -->
@@ -652,7 +760,7 @@
<!-- Header --> <!-- Header -->
<div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900 sticky top-0 z-10"> <div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900 sticky top-0 z-10">
<div> <div>
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Customer Details') }}</h2> <h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight" x-text="sidebarView === 'history' ? '{{ __('Contract History Detail') }}' : '{{ __('Customer Details') }}'"></h2>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]" x-text="detailCompany.name"></p> <p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]" x-text="detailCompany.name"></p>
<span class="text-xs font-mono font-black text-cyan-500 px-1.5 py-0.5 bg-cyan-500/10 rounded" x-text="detailCompany.code"></span> <span class="text-xs font-mono font-black text-cyan-500 px-1.5 py-0.5 bg-cyan-500/10 rounded" x-text="detailCompany.code"></span>
@@ -666,7 +774,19 @@
</div> </div>
<!-- Body --> <!-- Body -->
<div class="flex-1 overflow-y-auto px-8 py-8 space-y-8 custom-scrollbar"> <div class="flex-1 overflow-y-auto px-8 pt-4 pb-8 space-y-5 custom-scrollbar">
<!-- Tab Switcher -->
<div class="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800/60 rounded-xl">
<button @click="sidebarView = 'detail'"
:class="sidebarView === 'detail' ? 'bg-white dark:bg-slate-700 text-slate-800 dark:text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'"
class="flex-1 py-2.5 px-3 text-xs font-black uppercase tracking-[0.15em] rounded-lg transition-all">{{ __('Customer Details') }}</button>
<button @click="sidebarView = 'history'"
:class="sidebarView === 'history' ? 'bg-white dark:bg-slate-700 text-slate-800 dark:text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'"
class="flex-1 py-2.5 px-3 text-xs font-black uppercase tracking-[0.15em] rounded-lg transition-all">{{ __('Contract History Detail') }}</button>
</div>
<!-- Detail View -->
<div x-show="sidebarView === 'detail'" class="space-y-8">
<!-- Validity & Status Section --> <!-- Validity & Status Section -->
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Account Status') }}</h3> <h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Account Status') }}</h3>
@@ -690,39 +810,57 @@
</div> </div>
<!-- Business Type --> <!-- Business Type -->
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80"> <div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 space-y-4">
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] mb-4">{{ __('Business Type') }}</h4> <div class="flex justify-between items-start pb-3 border-b border-slate-100/50 dark:border-slate-800/50">
<div class="space-y-4"> <div>
<div class="flex items-center justify-between"> <h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em]">{{ __('Contract Model') }}</h4>
<span class="text-[11px] font-bold text-slate-400">{{ __('Original:') }}</span>
<span class="px-2.5 py-1 rounded-lg text-[11px] font-bold uppercase tracking-widest"
:class="detailCompany.original_type === 'buyout' ? 'bg-amber-500/10 text-amber-600' : 'bg-blue-500/10 text-blue-600'"
x-text="detailCompany.original_type === 'buyout' ? '{{ __('Buyout') }}' : '{{ __('Lease') }}'"></span>
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-slate-400">{{ __('Current:') }}</span>
<span class="px-2.5 py-1 rounded-lg text-[11px] font-bold uppercase tracking-widest"
:class="detailCompany.current_type === 'buyout' ? 'bg-amber-500/10 text-amber-600 border border-amber-500/20' : 'bg-blue-500/10 text-blue-600 border border-blue-500/20'"
x-text="detailCompany.current_type === 'buyout' ? '{{ __('Buyout') }}' : '{{ __('Lease') }}'"></span>
</div>
</div> </div>
<span :class="detailCompany.current_type === 'lease' ? 'text-blue-500 bg-blue-500/10' : 'text-amber-500 bg-amber-500/10'"
class="text-[10px] font-black uppercase tracking-wider px-2.5 py-1 rounded-lg">
<span x-text="detailCompany.current_type === 'lease' ? '{{ __('Lease') }}' : '{{ __('Buyout') }}'"></span>
</span>
</div> </div>
<!-- Contract Period --> <!-- Lease Info Display -->
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80"> <template x-if="detailCompany.current_type === 'lease'">
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em] mb-4">{{ __('Contract Period') }}</h4> <div class="grid grid-cols-2 gap-4">
<div class="space-y-3 font-mono"> <div class="space-y-1">
<div class="flex items-center gap-3"> <p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ __('Start Date') }}</p>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter min-w-[32px]">{{ __('From:') }}</span> <p class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.start_date?.substring(0, 10) || '-'"></p>
<div class="text-[13px] font-bold tracking-tighter text-slate-700 dark:text-slate-200" x-text="detailCompany.start_date || '--'"></div>
</div> </div>
<div class="flex items-center gap-3"> <div class="space-y-1">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tighter min-w-[32px]">{{ __('To:') }}</span> <p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ __('End Date') }}</p>
<div class="text-[13px] font-bold tracking-tighter text-slate-800 dark:text-white" :class="detailCompany.end_date_expired ? 'text-rose-500' : ''" x-text="detailCompany.end_date || '{{ __('Permanent') }}'"></div> <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>
</div> </div>
</div> </div>
</template>
</div>
</div>
</section> </section>
<!-- Feature Settings Section --> <!-- Feature Settings Section -->
@@ -805,6 +943,82 @@
</section> </section>
</div> </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 --> <!-- Footer -->
<div class="p-8 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50"> <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"> <button @click="showDetail = false" class="w-full btn-luxury-ghost py-4 rounded-xl font-black uppercase tracking-[0.2em] text-xs">
@@ -815,7 +1029,7 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
<style> <style>

View File

@@ -11,9 +11,11 @@
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Connectivity Status') }}</h3> <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> <p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time status monitoring') }}</p>
</div> </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="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 class="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span>
</span> </span>
<span class="text-[10px] font-black uppercase tracking-wider">{{ __('LIVE') }}</span> <span class="text-[10px] font-black uppercase tracking-wider">{{ __('LIVE') }}</span>
@@ -35,14 +37,14 @@
<div class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.6)]"></div> <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> <span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Offline Machines') }}</span>
</div> </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>
<div class="flex items-center justify-between pr-10"> <div class="flex items-center justify-between pr-10">
<div class="flex items-center gap-x-4"> <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> <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> <span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Alerts Pending') }}</span>
</div> </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>
</div> </div>
@@ -51,8 +53,11 @@
<!-- Right: Big Total --> <!-- Right: Big Total -->
<div class="w-40 text-center"> <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
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] mt-4">{{ __('Total Connected') }}</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> </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> <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> <p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Monthly cumulative revenue overview') }}</p>
</div> </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"> <div
<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> 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> </div>
<div class="flex-1 flex flex-col space-y-4 justify-center"> <div class="flex-1 flex flex-col space-y-4 justify-center">
<!-- Today Stat Card --> <!-- 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="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"> <div
<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> 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>
<div> <div>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Today's Transactions") }}</p> <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> </div>
<div class="flex flex-col items-end gap-y-1"> <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> <p class="text-[9px] font-bold text-slate-300 dark:text-slate-500 uppercase tracking-tighter">{{ __('vs Yesterday') }}</p>
</div> </div>
</div> </div>
@@ -90,25 +108,39 @@
<!-- Previous Days Stats Row --> <!-- Previous Days Stats Row -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<!-- Yesterday Card --> <!-- 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"> <div class="flex justify-between items-start mb-2">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Yesterday") }}</p> <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"> <div
<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> 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>
</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> </div>
<!-- Before Yesterday Card --> <!-- 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"> <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> <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"> <div
<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> 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>
</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> </div>
</div> </div>
@@ -121,7 +153,8 @@
<div> <div>
<div class="flex items-center gap-x-3"> <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> <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()]) }} {{ __('Total items', ['count' => $machines->total()]) }}
</span> </span>
</div> </div>
@@ -131,12 +164,16 @@
<form action="{{ route('admin.dashboard') }}" method="GET" class="flex flex-wrap items-center gap-4"> <form action="{{ route('admin.dashboard') }}" method="GET" class="flex flex-wrap items-center gap-4">
<div class="relative group"> <div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none"> <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> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" class="py-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) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
</div> </div>
</form> </form>
@@ -146,12 +183,24 @@
<table class="w-full text-left border-separate border-spacing-y-0"> <table class="w-full text-left border-separate border-spacing-y-0">
<thead> <thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/30"> <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
<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> 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">
<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> {{ __('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">{{ __('Current Stock') }}</th> <th
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Last Signal') }}</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">
<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> {{ __('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> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/50"> <tbody class="divide-y divide-slate-50 dark:divide-slate-800/50">
@@ -159,24 +208,57 @@
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300"> <tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6"> <td class="px-6 py-6">
<div class="flex items-center gap-x-5"> <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"> <div
<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> 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>
<div class="flex flex-col"> <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
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN: {{ $machine->serial_no }})</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> </div>
</div> </div>
</td> </td>
<td class="px-6 py-6 text-center"> <td class="px-6 py-6 text-center">
@if($machine->status === 'online') @php
<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"> $cStatus = $machine->calculated_status;
{{ __('Online') }} @endphp
</span>
@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>
<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 @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"> <div
{{ __('Offline') }} 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">
</span> <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 @endif
</td> </td>
<td class="px-6 py-6 text-center"> <td class="px-6 py-6 text-center">
@@ -184,19 +266,26 @@
</td> </td>
<td class="px-6 py-6"> <td class="px-6 py-6">
<div class="flex flex-col items-center gap-y-2.5"> <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
<div class="h-full bg-rose-500 rounded-full shadow-[0_0_8px_rgba(244,63,94,0.4)]" style="width: 15.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> </div>
<span class="text-[11px] font-black text-rose-500 uppercase tracking-[0.2em]">15.5% {{ __('Low Stock') }}</span> <span class="text-[11px] font-black text-rose-500 uppercase tracking-[0.2em]">15.5% {{
__('Low Stock') }}</span>
</div> </div>
</td> </td>
<td class="px-6 py-6 text-center"> <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"> <div
{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->format('Y/m/d H:i') : '---' }} 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> </div>
</td> </td>
<td class="px-6 py-6 text-right"> <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> <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> </td>
</tr> </tr>
@empty @empty

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -61,7 +61,10 @@
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search roles...') }}"> <input type="text" name="search" value="{{ request('search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search roles...') }}"
@keydown.enter="$el.form.submit()">
</div> </div>
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@@ -79,6 +82,7 @@
</div> </div>
@endif @endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,947 @@
@extends('layouts.admin')
@section('content')
<script>
window.remoteControlApp = function(initialMachineId) {
return {
machines: @js($machines),
searchQuery: '',
selectedMachine: null,
commands: [],
viewMode: initialMachineId ? 'control' : 'history',
history: @js($history),
loading: false,
submitting: false,
// App Config & Meta
appConfig: {
storeUrl: @js(route('admin.remote.store-command')),
indexUrl: @js(route('admin.remote.index')),
csrfToken: document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
},
// Localized Strings
translations: @js([
'Please select a slot' => __('Please select a slot'),
'Search cargo lane' => __('Search cargo lane'),
'Stock:' => __('Stock:'),
'Loading...' => __('Loading...'),
'No active cargo lanes found' => __('No active cargo lanes found'),
'Empty' => __('Empty'),
'Machine Reboot' => __('Machine Reboot'),
'Card Reader Reboot' => __('Card Reader Reboot'),
'Remote Reboot' => __('Remote Reboot'),
'Lock Page Lock' => __('Lock Page Lock'),
'Lock Page Unlock' => __('Lock Page Unlock'),
'Remote Change' => __('Remote Change'),
'Remote Dispense' => __('Remote Dispense'),
'Adjust Stock & Expiry' => __('Adjust Stock & Expiry'),
'Pending' => __('Pending'),
'Sent' => __('Sent'),
'Success' => __('Success'),
'Failed' => __('Failed'),
'Superseded' => __('Superseded'),
'System' => __('System'),
'Superseded by new adjustment' => __('Superseded by new adjustment'),
'Superseded by new command' => __('Superseded by new command'),
'Slot' => __('Slot'),
'Stock' => __('Stock'),
'Expiry' => __('Expiry'),
'Batch' => __('Batch'),
'Amount' => __('Amount'),
'Command error:' => __('Command error:'),
'Command has been queued successfully.' => __('Command has been queued successfully.'),
'Just now' => __('Just now'),
'mins ago' => __('mins ago'),
'hours ago' => __('hours ago'),
]),
// Form States
lockStatus: false,
changeAmount: 100,
selectedSlot: '',
note: '',
async init() {
if (initialMachineId) {
const machine = this.machines.find(m => m.id == initialMachineId);
if (machine) {
await this.selectMachine(machine);
}
}
// Watch for machine data changes to rebuild slot select
this.$watch('selectedMachine.slots', () => {
this.$nextTick(() => this.updateSlotSelect());
});
},
updateSlotSelect() {
const wrapper = document.getElementById('slot-select-wrapper');
if (!wrapper) return;
// Clear previous and reset
const oldSelect = wrapper.querySelector('select');
if (oldSelect) {
try {
const instance = window.HSSelect.getInstance(oldSelect);
if (instance) instance.destroy();
} catch (e) {}
}
wrapper.innerHTML = '';
// If loading, show a skeleton or simple text
if (this.loading) {
wrapper.innerHTML = `<div class="py-5 px-6 rounded-xl bg-slate-50/50 dark:bg-slate-950/30 border border-slate-100 dark:border-slate-800 text-slate-400 text-sm font-bold animate-pulse">${this.translations['Loading...']}</div>`;
return;
}
if (!this.selectedMachine || !this.selectedMachine.slots || this.selectedMachine.slots.length === 0) {
wrapper.innerHTML = `
<div class="p-6 rounded-[1.5rem] bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-[0.1em] text-center border border-rose-500/10">
${this.translations['No active cargo lanes found']}
</div>
`;
return;
}
const selectEl = document.createElement('select');
selectEl.className = 'hidden';
selectEl.id = 'dynamic-slot-select-' + Date.now();
const config = {
"placeholder": this.translations['Please select a slot'] + "...",
"hasSearch": true,
"searchPlaceholder": this.translations['Search cargo lane'] + "...",
"isHidePlaceholder": false,
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses": "hs-select-toggle luxury-select-toggle",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[100] animate-luxury-in",
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate": '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>'
};
selectEl.setAttribute('data-hs-select', JSON.stringify(config));
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
placeholderOpt.textContent = this.translations['Please select a slot'] + "...";
placeholderOpt.dataset.title = this.translations['Please select a slot'] + "...";
selectEl.appendChild(placeholderOpt);
const sortedSlots = [...this.selectedMachine.slots].sort((a, b) => {
const aNo = parseInt(a.slot_no);
const bNo = parseInt(b.slot_no);
return isNaN(aNo) || isNaN(bNo) ? a.slot_no.localeCompare(b.slot_no) : aNo - bNo;
});
sortedSlots.forEach(slot => {
const opt = document.createElement('option');
opt.value = slot.slot_no;
const productName = slot.product ? slot.product.name : this.translations['Empty'];
const label = `[${slot.slot_no}] ${productName} (${this.translations['Stock:']} ${slot.stock})`;
opt.textContent = label;
opt.dataset.title = label;
if (slot.slot_no === this.selectedSlot) opt.selected = true;
selectEl.appendChild(opt);
});
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', (e) => { this.selectedSlot = e.target.value; });
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit(['select']);
}
},
confirmModal: {
show: false,
type: '',
params: {}
},
async selectMachine(machine) {
this.selectedMachine = machine;
this.viewMode = 'control';
this.loading = true;
this.commands = [];
// Update URL without refresh
const url = new URL(window.location);
url.searchParams.set('machine_id', machine.id);
window.history.pushState({}, '', url);
try {
const res = await fetch(`/admin/remote?machine_id=${machine.id}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
this.commands = data.commands || [];
if (data.machine) {
this.selectedMachine = data.machine;
}
} catch (e) {
console.error('Fetch error:', e);
} finally {
this.loading = false;
this.$nextTick(() => this.updateSlotSelect());
}
},
backToList() {
this.viewMode = 'list';
this.selectedMachine = null;
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
backToHistory() {
this.viewMode = 'history';
this.selectedMachine = null;
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
sendCommand(type, params = {}) {
this.note = ''; // Reset note for new command
this.confirmModal.type = type;
this.confirmModal.params = params;
this.confirmModal.show = true;
},
async executeCommand() {
const type = this.confirmModal.type;
const params = this.confirmModal.params;
this.confirmModal.show = false;
this.submitting = true;
try {
const formData = new FormData();
formData.append('machine_id', this.selectedMachine.id);
formData.append('command_type', type);
formData.append('note', this.note);
if (params.amount) formData.append('amount', params.amount);
if (params.slot_no) formData.append('slot_no', params.slot_no);
const res = await fetch(this.appConfig.storeUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': this.appConfig.csrfToken,
'Accept': 'application/json'
},
body: formData
});
if (res.ok) {
window.location.href = this.appConfig.indexUrl;
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: this.translations['Command error:'] + ' ' + e.message,
type: 'error'
}
}));
console.error('Command error:', e);
} finally {
this.submitting = false;
}
},
formatTime(dateStr) {
if (!dateStr) return '--';
const date = new Date(dateStr);
const now = new Date();
const diff = Math.floor((now - date) / 1000); // seconds
if (diff < 60) return this.translations['Just now'];
if (diff < 3600) return Math.floor(diff / 60) + ' ' + this.translations['mins ago'];
if (diff < 7200) return '1 ' + this.translations['hours ago'];
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
},
getCommandBadgeClass(status) {
switch(status) {
case 'pending': return 'bg-amber-100 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400 border-amber-200 dark:border-amber-500/20';
case 'sent': return 'bg-cyan-100 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/20';
case 'success': return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
case 'failed': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
case 'superseded': return 'bg-slate-100 text-slate-500 dark:bg-slate-500/10 dark:text-slate-400 border-slate-200 dark:border-slate-500/20 opacity-80';
default: return 'bg-slate-100 text-slate-600 border-slate-200';
}
},
getCommandName(type) {
const names = {
'reboot': this.translations['Machine Reboot'],
'reboot_card': this.translations['Card Reader Reboot'],
'checkout': this.translations['Remote Reboot'],
'lock': this.translations['Lock Page Lock'],
'unlock': this.translations['Lock Page Unlock'],
'change': this.translations['Remote Change'],
'dispense': this.translations['Remote Dispense'],
'reload_stock': this.translations['Adjust Stock & Expiry']
};
return names[type] || type;
},
getCommandStatus(status) {
const statuses = {
'pending': this.translations['Pending'],
'sent': this.translations['Sent'],
'success': this.translations['Success'],
'failed': this.translations['Failed'],
'superseded': this.translations['Superseded']
};
return statuses[status] || status;
},
getOperatorName(user) {
return user ? user.name : this.translations['System'];
},
translateNote(note) {
if (!note) return '';
const translations = {
'Superseded by new adjustment': this.translations['Superseded by new adjustment']
};
return translations[note] || note;
},
getPayloadDetails(item) {
if (item.command_type === 'reload_stock' && item.payload) {
const p = item.payload;
let details = `${this.translations['Slot']} ${p.slot_no}: `;
if (p.old.stock !== p.new.stock) {
details += `${this.translations['Stock']} ${p.old.stock} → ${p.new.stock}`;
}
if (p.old.expiry_date !== p.new.expiry_date) {
if (p.old.stock !== p.new.stock) details += ', ';
details += `${this.translations['Expiry']} ${p.old.expiry_date || 'N/A'} → ${p.new.expiry_date || 'N/A'}`;
}
if (p.old.batch_no !== p.new.batch_no) {
if (p.old.stock !== p.new.stock || p.old.expiry_date !== p.new.expiry_date) details += ', ';
details += `${this.translations['Batch']} ${p.old.batch_no || 'N/A'} → ${p.new.batch_no || 'N/A'}`;
}
return details;
}
if (item.command_type === 'change' && item.payload) {
return `${this.translations['Amount']}: ${item.payload.amount}`;
}
if (item.command_type === 'dispense' && item.payload) {
return `${this.translations['Slot']}: ${item.payload.slot_no}`;
}
return '';
}
};
};
</script>
<div class="space-y-2 pb-20"
x-data="remoteControlApp({{ Js::from($selectedMachine ? $selectedMachine->id : '') }})">
<div class="flex items-center gap-4">
<!-- Back Button for Detail/Control Mode -->
<template x-if="viewMode === 'control'">
<button @click="backToList()"
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
</template>
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">
{{ __($title ?? 'Remote Command Center') }}
</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
{{ __($subtitle ?? 'Execute maintenance and operational commands remotely') }}
</p>
</div>
</div>
<!-- Tab Navigation (Only visible when not in specific machine control) -->
<template x-if="viewMode !== 'control'">
<div class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50">
<button @click="viewMode = 'history'"
:class="viewMode === 'history' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Operation Records') }}
</button>
<button @click="viewMode = 'list'"
:class="viewMode === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('New Command') }}
</button>
</div>
</template>
<div class="mt-6">
<!-- History View: Operation Records -->
<template x-if="viewMode === 'history'">
<div class="space-y-6 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0 text-sm">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center whitespace-nowrap">{{ __('Creation Time') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center whitespace-nowrap">{{ __('Picked up Time') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Command Type') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Operator') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<template x-for="item in history" :key="item.id">
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(item.machine)">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm overflow-hidden">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
</svg>
</div>
<div>
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="item.machine.name"></div>
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="item.machine.serial_no"></div>
</div>
</div>
</td>
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
<div class="flex flex-col">
<span x-text="new Date(item.created_at).toLocaleDateString()"></span>
<span class="text-[10px] opacity-70" x-text="new Date(item.created_at).toLocaleTimeString()"></span>
</div>
</td>
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
<template x-if="item.executed_at">
<div class="flex flex-col text-cyan-600/80 dark:text-cyan-400/60">
<span x-text="new Date(item.executed_at).toLocaleDateString()"></span>
<span class="text-[10px] opacity-70" x-text="new Date(item.executed_at).toLocaleTimeString()"></span>
</div>
</template>
<template x-if="!item.executed_at">
<span class="text-slate-300 dark:text-slate-700">-</span>
</template>
</td>
<td class="px-6 py-6">
<div class="flex flex-col min-w-[200px]">
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(item.command_type)"></span>
<div class="flex flex-col gap-0.5 mt-1">
<template x-if="getPayloadDetails(item)">
<span class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(item)"></span>
</template>
<template x-if="item.note">
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(item.note)"></span>
</template>
</div>
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-cyan-500/10 flex items-center justify-center text-[10px] font-black text-cyan-600 dark:text-cyan-400 border border-cyan-500/20"
x-text="getOperatorName(item.user).substring(0,1)"></div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300" x-text="getOperatorName(item.user)"></span>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center gap-1.5">
<div class="inline-flex items-center px-4 py-1.5 rounded-full border text-[10px] font-black uppercase tracking-widest shadow-sm"
:class="getCommandBadgeClass(item.status)">
<div class="w-1.5 h-1.5 rounded-full mr-2"
:class="{
'bg-amber-500 animate-pulse': item.status === 'pending',
'bg-cyan-500': item.status === 'sent',
'bg-emerald-500': item.status === 'success',
'bg-rose-500': item.status === 'failed',
'bg-slate-400': item.status === 'superseded'
}"></div>
<span x-text="getCommandStatus(item.status)"></span>
</div>
</div>
</td>
</tr>
</template>
<template x-if="history.length === 0">
<tr>
<td colspan="6" class="px-6 py-20 text-center">
<div class="flex flex-col items-center gap-3">
<div class="w-16 h-16 rounded-full bg-slate-50 dark:bg-slate-900/50 flex items-center justify-center text-slate-200 dark:text-slate-800">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<p class="text-slate-400 font-bold tracking-widest uppercase text-xs">{{ __('No records found') }}</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<!-- Master View: Machine List -->
<template x-if="viewMode === 'list'">
<div class="space-y-6 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
<!-- Filters Area -->
<div class="flex items-center justify-between mb-8">
<div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" x-model="searchQuery"
placeholder="{{ __('Search...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-72">
</div>
</div>
<div class="overflow-x-auto pb-4">
<table class="w-full text-left border-separate border-spacing-y-0 text-sm whitespace-nowrap">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Last Communication') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<template x-for="machine in machines.filter(m =>
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
m.serial_no.toLowerCase().includes(searchQuery.toLowerCase())
)" :key="machine.id">
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(machine)">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
<template x-if="machine.image_urls && machine.image_urls[0]">
<img :src="machine.image_urls[0]" class="w-full h-full object-cover">
</template>
<template x-if="!machine.image_urls || !machine.image_urls[0]">
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
</svg>
</template>
</div>
<div>
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="machine.name"></div>
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.serial_no"></div>
</div>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex items-center justify-center">
<template x-if="machine.status === 'online' || !machine.status">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<div class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</div>
<span class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-[0.1em] uppercase">{{ __('Online') }}</span>
</div>
</template>
<template x-if="machine.status === 'offline'">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 tracking-[0.1em] uppercase">{{ __('Offline') }}</span>
</div>
</template>
<template x-if="machine.status && machine.status !== 'online' && machine.status !== 'offline'">
<div class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
<div class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</div>
<span class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-[0.1em] uppercase">{{ __('Abnormal') }}</span>
</div>
</template>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center">
<span class="text-sm font-black text-slate-700 dark:text-slate-200" x-text="formatTime(machine.last_heartbeat_at)"></span>
<span class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.last_heartbeat_at ? machine.last_heartbeat_at.split('T')[0] : '--'"></span>
</div>
</td>
<td class="px-6 py-6 text-right">
<button @click="selectMachine(machine)"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('Manage') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<!-- Detail View: Remote Control Dashboard -->
<template x-if="viewMode === 'control'">
<div class="space-y-6 animate-luxury-in">
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Left: Control Actions (Spans 2 columns) -->
<div class="lg:col-span-2 space-y-8">
<!-- Machine Status Card -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 border border-cyan-500/20">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h2 class="text-2xl font-black text-slate-800 dark:text-white leading-tight" x-text="selectedMachine.name"></h2>
<p class="text-xs font-mono font-bold text-slate-400 mt-1 uppercase tracking-widest" x-text="selectedMachine.serial_no"></p>
</div>
</div>
<div class="flex gap-3">
<span class="px-4 py-2 rounded-full bg-emerald-500/10 text-emerald-600 text-xs font-black uppercase tracking-widest border border-emerald-500/20 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
{{ __('Connected') }}
</span>
</div>
</div>
<!-- Row 2: System Control -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider mb-8 flex items-center gap-3">
<span class="w-2 h-6 bg-cyan-500 rounded-full"></span>
{{ __('Machine Information') }}
</h3>
<div class="space-y-8">
<!-- Maintenance Operations -->
<div class="space-y-4">
<div class="flex items-center gap-3 ml-1">
<div class="w-1 h-3 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
<span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Maintenance Operations') }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Reboot System -->
<button @click="sendCommand('reboot')" class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 dark:text-cyan-400 group-hover:scale-110 transition-transform duration-500 border border-cyan-500/20 dark:border-cyan-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ __('Machine Reboot') }}</div>
</div>
</button>
<!-- Card Reader Reboot -->
<button @click="sendCommand('reboot_card')" class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div class="w-12 h-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-500 dark:text-cyan-400 group-hover:scale-110 transition-transform duration-500 border border-cyan-500/20 dark:border-cyan-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3-3v8a3 3 0 003 3z" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ __('Card Reader Reboot') }}</div>
</div>
</button>
<!-- Remote Settlement -->
<button @click="sendCommand('checkout')" class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-emerald-500/50 dark:hover:border-emerald-400/60 hover:bg-emerald-500/5 dark:hover:bg-emerald-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div class="w-12 h-12 rounded-2xl bg-emerald-500/10 flex items-center justify-center text-emerald-500 dark:text-emerald-400 group-hover:scale-110 transition-transform duration-500 border border-emerald-500/20 dark:border-emerald-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">{{ __('Remote Reboot') }}</div>
</div>
</button>
</div>
</div>
<!-- Security Controls -->
<div class="space-y-4">
<div class="flex items-center gap-3 ml-1">
<div class="w-1 h-3 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
<span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('Security Controls') }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Unlock -->
<button @click="sendCommand('unlock')" class="p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 flex items-center justify-between hover:border-emerald-500/50 dark:hover:border-emerald-400/60 hover:bg-emerald-500/5 dark:hover:bg-emerald-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div class="flex items-center gap-5">
<div class="w-12 h-12 rounded-2xl bg-emerald-500/10 flex items-center justify-center text-emerald-500 dark:text-emerald-400 group-hover:scale-110 transition-transform duration-500 border border-emerald-500/20 dark:border-emerald-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">{{ __('Lock Page Unlock') }}</div>
</div>
</div>
<div class="px-4 py-2 rounded-xl bg-emerald-500 text-white text-[10px] font-black uppercase tracking-widest shadow-lg shadow-emerald-500/20 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 font-sans">{{ __('Unlock Now') }}</div>
</button>
<!-- Lock -->
<button @click="sendCommand('lock')" class="p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 flex items-center justify-between hover:border-rose-500/50 dark:hover:border-rose-400/60 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div class="flex items-center gap-5">
<div class="w-12 h-12 rounded-2xl bg-rose-500/10 flex items-center justify-center text-rose-500 dark:text-rose-400 group-hover:scale-110 transition-transform duration-500 border border-rose-500/20 dark:border-rose-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-rose-600 dark:group-hover:text-rose-400 transition-colors">{{ __('Lock Page Lock') }}</div>
</div>
</div>
<div class="px-4 py-2 rounded-xl bg-rose-500 text-white text-[10px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/20 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 font-sans">{{ __('Lock Now') }}</div>
</button>
</div>
</div>
</div>
</div>
<!-- Parametric Actions -->
<!-- Row 3: Parametric Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Remote Change -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
<div class="flex items-center justify-between mb-8">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider flex items-center gap-3">
<span class="w-2 h-6 bg-amber-500 rounded-full"></span>
{{ __('Remote Change') }}
</h3>
<div class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">CASH</div>
</div>
<div class="space-y-6">
<div class="relative group">
<input type="number" x-model="changeAmount" class="luxury-input w-full bg-slate-50/50 dark:bg-slate-950/40 border-slate-200 dark:border-slate-800/80 hover:border-amber-500/30 dark:hover:border-amber-400/40 pl-20 text-4xl font-display font-black text-slate-800 dark:text-white focus:ring-0 py-6 placeholder:text-slate-400 transition-all">
<div class="absolute inset-y-0 left-6 flex items-center pointer-events-none z-10 transition-transform group-focus-within:translate-x-1">
<div class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center text-amber-500 dark:text-amber-400 border border-amber-500/20 dark:border-amber-400/20 group-focus-within:scale-110 group-hover:scale-105 transition-transform duration-300">
<span class="text-xl font-black">$</span>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-3">
<template x-for="amt in [10, 50, 100, 500]">
<button @click="changeAmount = amt"
class="py-3 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 text-[11px] font-black uppercase tracking-widest transition-all bg-white/50 dark:bg-slate-900/50 text-slate-600 dark:text-slate-400 shadow-sm hover:border-amber-500/50 dark:hover:border-amber-400/60 hover:text-amber-500 dark:hover:text-amber-400 hover:bg-amber-500/10 dark:hover:bg-amber-400/5 active:scale-95"
x-text="amt"></button>
</template>
</div>
<button @click="sendCommand('change', { amount: changeAmount })" :disabled="submitting" class="btn-luxury-primary w-full py-5 rounded-[1.5rem] text-sm shadow-lg shadow-cyan-500/20 group">
<span class="relative z-10 flex items-center justify-center gap-2">
{{ __('Execute Remote Change') }}
<svg class="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" /></svg>
</span>
</button>
</div>
</div>
<!-- Remote Dispense -->
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
<div class="flex items-center justify-between mb-8">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider flex items-center gap-3">
<span class="w-2 h-6 bg-violet-500 rounded-full"></span>
{{ __('Remote Dispense') }}
</h3>
<div class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">ITEM</div>
</div>
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">{{ __('Select Target Slot') }}</label>
<div id="slot-select-wrapper" class="relative min-h-[60px]">
<!-- Content Injected Dynamically by Alpine.js -->
</div>
</div>
<!-- Dispense Button (Card Style - Mirroring Row 2) -->
<button @click="sendCommand('dispense', { slot_no: selectedSlot })"
:disabled="submitting || !selectedSlot"
class="w-full p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 flex items-center justify-between hover:border-violet-500/50 dark:hover:border-violet-400/60 hover:bg-violet-500/5 dark:hover:bg-violet-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
<div class="flex items-center gap-5">
<div class="w-12 h-12 rounded-2xl bg-violet-500/10 flex items-center justify-center text-violet-500 dark:text-violet-400 group-hover:scale-110 transition-transform duration-500 border border-violet-500/20 dark:border-violet-400/20 group-disabled:opacity-60">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<div class="text-left">
<div class="text-sm font-black text-slate-800 dark:text-white group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors group-disabled:text-slate-500">{{ __('Remote Dispense') }}</div>
</div>
</div>
<div class="px-4 py-2 rounded-xl bg-violet-500 text-white text-[10px] font-black uppercase tracking-widest shadow-lg shadow-violet-500/20 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 font-sans group-disabled:hidden">
{{ __('Trigger') }}
</div>
</button>
</div>
</div>
</div>
</div>
<!-- Right: Command History (Sidebar) -->
<div class="space-y-4 mt-2">
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider flex items-center gap-3">
<span class="w-1.5 h-6 bg-slate-300 dark:bg-slate-700 rounded-full"></span>
{{ __('Recent Commands') }}
</h3>
<div class="space-y-3.5 max-h-[1000px] overflow-y-auto pr-2 custom-scrollbar">
<template x-for="cmd in commands" :key="cmd.id">
<div class="luxury-card p-4 px-5 rounded-2xl border border-slate-100 dark:border-slate-800 transition-all hover:bg-slate-50 dark:hover:bg-slate-900/40 relative group">
<div class="flex items-start justify-between mb-3">
<div class="flex flex-col">
<span class="text-sm font-black text-slate-800 dark:text-white" x-text="getCommandName(cmd.command_type)"></span>
<div class="flex items-center gap-2 mt-1.5">
<div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-[10px] font-black text-slate-500"
x-text="getOperatorName(cmd.user).substring(0,1)"></div>
<span class="text-xs font-bold text-slate-400" x-text="getOperatorName(cmd.user)"></span>
</div>
</div>
<span :class="getCommandBadgeClass(cmd.status)" class="px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border" x-text="getCommandStatus(cmd.status)"></span>
</div>
<div class="text-[10px] font-bold text-slate-400 flex items-center gap-2 mb-3">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span x-text="new Date(cmd.created_at).toLocaleString()"></span>
</div>
<template x-if="getPayloadDetails(cmd)">
<div class="px-3 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-sm font-bold text-cyan-600 dark:text-cyan-400 break-words leading-relaxed" x-text="getPayloadDetails(cmd)">
</div>
</template>
<template x-if="cmd.note">
<div class="mt-2 text-xs font-bold text-slate-400 italic" x-text="'\"' + cmd.note + '\"'"></div>
</template>
</div>
</template>
<template x-if="commands.length === 0">
<div class="py-20 text-center flex flex-col items-center opacity-30">
<svg class="w-12 h-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
<div class="text-[10px] font-black uppercase tracking-widest">{{ __('No command history') }}</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Custom Confirmation Modal -->
<template x-teleport="body">
<div x-show="confirmModal.show"
class="fixed inset-0 z-[100] overflow-y-auto"
x-cloak>
<div class="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
<!-- Background Backdrop -->
<div x-show="confirmModal.show"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
@click="confirmModal.show = false"></div>
<!-- Modal Content -->
<div x-show="confirmModal.show"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative transform overflow-hidden rounded-[2.5rem] bg-white dark:bg-slate-900 p-8 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-slate-200 dark:border-slate-800">
<div class="flex items-center gap-4 mb-6">
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 border border-amber-500/20">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{ __('Command Confirmation') }}</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ __('Please confirm the details below') }}</p>
</div>
</div>
<div class="space-y-4 bg-slate-50 dark:bg-slate-950/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800/50 mb-8">
<div class="flex justify-between items-center px-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Command Type') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-slate-200" x-text="getCommandName(confirmModal.type)"></span>
</div>
<div class="space-y-2 px-1">
<label class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.2em] ml-1">{{ __('Operation Note') }}</label>
<textarea x-model="note"
class="luxury-input w-full min-h-[100px] text-sm py-3 px-4 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus:border-cyan-500/50"
placeholder="{{ __('Reason for this command...') }}"></textarea>
</div>
<template x-if="confirmModal.params.amount">
<div class="flex justify-between items-center pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<span class="text-[10px] font-black text-amber-500 uppercase tracking-widest">{{ __('Amount') }}</span>
<span class="text-lg font-black text-slate-800 dark:text-slate-200" x-text="'$' + confirmModal.params.amount"></span>
</div>
</template>
<template x-if="confirmModal.params.slot_no">
<div class="flex justify-between items-center pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
<span class="text-[10px] font-black text-violet-500 uppercase tracking-widest">{{ __('Slot No') }}</span>
<span class="text-sm font-black text-slate-800 dark:text-slate-200" x-text="confirmModal.params.slot_no"></span>
</div>
</template>
</div>
<div class="flex gap-4">
<button @click="confirmModal.show = false"
class="flex-1 px-6 py-4 rounded-2xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest hover:bg-slate-200 dark:hover:bg-slate-700 transition-all">
{{ __('Cancel') }}
</button>
<button @click="executeCommand()"
class="flex-1 px-6 py-4 rounded-2xl bg-cyan-600 text-white text-xs font-black uppercase tracking-widest hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 active:scale-[0.98] transition-all">
{{ __('Execute') }}
</button>
</div>
</div>
</div>
</div>
</template>
</div>
<style>
/* Custom Scrollbar for Luxury UI */
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e133; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #cbd5e166; }
/* Hide default number spinners */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
appearance: none;
}
</style>
@endsection

View File

@@ -0,0 +1,763 @@
@extends('layouts.admin')
@section('content')
<script>
window.stockApp = function(initialMachineId) {
return {
machines: @json($machines),
searchQuery: '',
selectedMachine: null,
slots: [],
viewMode: initialMachineId ? 'detail' : 'history',
history: @js($history),
loading: false,
updating: false,
// Modal State
showEditModal: false,
formData: {
stock: 0,
expiry_date: '',
batch_no: ''
},
async init() {
if (initialMachineId) {
const machine = this.machines.find(m => m.id == initialMachineId);
if (machine) {
await this.selectMachine(machine);
}
}
},
async selectMachine(machine) {
this.selectedMachine = machine;
this.viewMode = 'detail';
this.loading = true;
this.slots = [];
// Update URL without refresh
const url = new URL(window.location);
url.searchParams.set('machine_id', machine.id);
window.history.pushState({}, '', url);
try {
const res = await fetch(`/admin/machines/${machine.id}/slots-ajax`);
const data = await res.json();
if (data.success) {
this.slots = data.slots;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch (e) {
console.error('Fetch slots error:', e);
} finally {
this.loading = false;
}
},
backToList() {
this.viewMode = 'list';
this.selectedMachine = null;
this.selectedSlot = null;
this.slots = [];
// Clear machine_id from URL
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
backToHistory() {
this.viewMode = 'history';
this.selectedMachine = null;
this.selectedSlot = null;
this.slots = [];
const url = new URL(window.location);
url.searchParams.delete('machine_id');
window.history.pushState({}, '', url);
},
openEdit(slot) {
this.selectedSlot = slot;
this.formData = {
stock: slot.stock || 0,
expiry_date: slot.expiry_date ? slot.expiry_date.split('T')[0] : '',
batch_no: slot.batch_no || ''
};
this.showEditModal = true;
},
async saveChanges() {
this.updating = true;
try {
const csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const res = await fetch(`/admin/machines/${this.selectedMachine.id}/slots/expiry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
body: JSON.stringify({
slot_no: this.selectedSlot.slot_no,
...this.formData
})
});
const data = await res.json();
if (data.success) {
this.showEditModal = false;
// Redirect instantly to history tab.
// The success toast will be handled by the session() flash in the controller.
window.location.href = "{{ route('admin.remote.stock') }}";
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: '{{ __("Save error:") }} ' + e.message,
type: 'error'
}
}));
console.error('Save error:', e);
} finally {
this.updating = false;
}
},
getSlotColorClass(slot) {
if (!slot.expiry_date) return 'bg-slate-50/50 dark:bg-slate-800/50 text-slate-400 border-slate-200/60 dark:border-slate-700/50';
const todayStr = new Date().toISOString().split('T')[0];
const expiryStr = slot.expiry_date;
if (expiryStr < todayStr) {
return 'bg-rose-50/60 dark:bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-500/30 shadow-sm shadow-rose-500/5';
}
const diffDays = Math.round((new Date(expiryStr) - new Date(todayStr)) / 86400000);
if (diffDays <= 7) {
return 'bg-amber-50/60 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/30 shadow-sm shadow-amber-500/5';
}
return 'bg-emerald-50/60 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/30 shadow-sm shadow-emerald-500/5';
},
getCommandBadgeClass(status) {
switch(status) {
case 'pending': return 'bg-amber-100 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400 border-amber-200 dark:border-amber-500/20';
case 'sent': return 'bg-cyan-100 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/20';
case 'success': return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
case 'failed': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
case 'superseded': return 'bg-slate-100 text-slate-500 dark:bg-slate-500/10 dark:text-slate-400 border-slate-200 dark:border-slate-500/20 opacity-80';
default: return 'bg-slate-100 text-slate-600 border-slate-200';
}
},
getCommandName(type) {
const names = {
'reboot': {{ Js::from(__('Machine Reboot')) }},
'reboot_card': {{ Js::from(__('Card Reader Reboot')) }},
'checkout': {{ Js::from(__('Remote Reboot')) }},
'lock': {{ Js::from(__('Lock Page Lock')) }},
'unlock': {{ Js::from(__('Lock Page Unlock')) }},
'change': {{ Js::from(__('Remote Change')) }},
'dispense': {{ Js::from(__('Remote Dispense')) }},
'reload_stock': {{ Js::from(__('Adjust Stock & Expiry')) }}
};
return names[type] || type;
},
getCommandStatus(status) {
const statuses = {
'pending': {{ Js::from(__('Pending')) }},
'sent': {{ Js::from(__('Sent')) }},
'success': {{ Js::from(__('Success')) }},
'failed': {{ Js::from(__('Failed')) }},
'superseded': {{ Js::from(__('Superseded')) }}
};
return statuses[status] || status;
},
getOperatorName(user) {
return user ? user.name : {{ Js::from(__('System')) }};
},
getPayloadDetails(item) {
if (item.command_type === 'reload_stock' && item.payload) {
const p = item.payload;
let details = `{{ __('Slot') }} ${p.slot_no}: `;
if (p.old.stock !== p.new.stock) {
details += `{{ __('Stock') }} ${p.old.stock} → ${p.new.stock}`;
}
if (p.old.expiry_date !== p.new.expiry_date) {
if (p.old.stock !== p.new.stock) details += ', ';
details += `{{ __('Expiry') }} ${p.old.expiry_date || 'N/A'} → ${p.new.expiry_date || 'N/A'}`;
}
if (p.old.batch_no !== p.new.batch_no) {
if (p.old.stock !== p.new.stock || p.old.expiry_date !== p.new.expiry_date) details += ', ';
details += `{{ __('Batch') }} ${p.old.batch_no || 'N/A'} → ${p.new.batch_no || 'N/A'}`;
}
return details;
}
return '';
},
formatTime(dateStr) {
if (!dateStr) return '--';
const date = new Date(dateStr);
const now = new Date();
const diffSeconds = Math.floor((now - date) / 1000);
if (diffSeconds < 0) return date.toISOString().split('T')[0];
if (diffSeconds < 60) return "{{ __('Just now') }}";
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) return diffMinutes + " {{ __('mins ago') }}";
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return diffHours + " {{ __('hours ago') }}";
return date.toISOString().split('T')[0] + ' ' + date.toTimeString().split(' ')[0].substring(0, 5);
},
translateNote(note) {
if (!note) return '';
const translations = {
'Superseded by new adjustment': {{ Js::from(__('Superseded by new adjustment')) }}
};
return translations[note] || note;
}
};
};
</script>
<div class="space-y-2 pb-20"
x-data="stockApp('{{ $selectedMachine ? $selectedMachine->id : '' }}')"
@keydown.escape.window="showEditModal = false">
<div class="flex items-center gap-4">
<!-- Back Button for Detail Mode -->
<template x-if="viewMode === 'detail'">
<button @click="backToList()"
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</button>
</template>
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">
{{ __($title ?? 'Stock & Expiry Management') }}
</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
{{ __($subtitle ?? 'Manage inventory and monitor expiry dates across all machines') }}
</p>
</div>
</div>
<!-- Tab Navigation (Only visible when not in specific machine detail) -->
<template x-if="viewMode !== 'detail'">
<div class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50">
<button @click="viewMode = 'history'"
:class="viewMode === 'history' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Operation Records') }}
</button>
<button @click="viewMode = 'list'"
:class="viewMode === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Adjust Stock & Expiry') }}
</button>
</div>
</template>
<div class="mt-6">
<!-- History View: Operation Records -->
<template x-if="viewMode === 'history'">
<div class="space-y-6 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0 text-sm">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center whitespace-nowrap">{{ __('Creation Time') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center whitespace-nowrap">{{ __('Picked up Time') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Command Type') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Operator') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<template x-for="item in history" :key="item.id">
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(item.machine)">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm overflow-hidden">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
</svg>
</div>
<div>
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="item.machine.name"></div>
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="item.machine.serial_no"></div>
</div>
</div>
</td>
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
<div class="flex flex-col">
<span x-text="new Date(item.created_at).toLocaleDateString()"></span>
<span class="text-[10px] opacity-70" x-text="new Date(item.created_at).toLocaleTimeString()"></span>
</div>
</td>
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
<template x-if="item.executed_at">
<div class="flex flex-col text-cyan-600/80 dark:text-cyan-400/60">
<span x-text="new Date(item.executed_at).toLocaleDateString()"></span>
<span class="text-[10px] opacity-70" x-text="new Date(item.executed_at).toLocaleTimeString()"></span>
</div>
</template>
<template x-if="!item.executed_at">
<span class="text-slate-300 dark:text-slate-700">-</span>
</template>
</td>
<td class="px-6 py-6">
<div class="flex flex-col min-w-[200px]">
<span class="text-sm font-black text-slate-700 dark:text-slate-300 tracking-tight" x-text="getCommandName(item.command_type)"></span>
<div class="flex flex-col gap-0.5 mt-1">
<template x-if="getPayloadDetails(item)">
<span class="text-[11px] font-bold text-cyan-600 dark:text-cyan-400/80 bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10 w-fit" x-text="getPayloadDetails(item)"></span>
</template>
<template x-if="item.note">
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(item.note)"></span>
</template>
</div>
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-cyan-500/10 flex items-center justify-center text-[10px] font-black text-cyan-600 dark:text-cyan-400 border border-cyan-500/20"
x-text="getOperatorName(item.user).substring(0,1)"></div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300" x-text="getOperatorName(item.user)"></span>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center gap-1.5">
<div class="inline-flex items-center px-4 py-1.5 rounded-full border text-[10px] font-black uppercase tracking-widest shadow-sm"
:class="getCommandBadgeClass(item.status)">
<div class="w-1.5 h-1.5 rounded-full mr-2"
:class="{
'bg-amber-500 animate-pulse': item.status === 'pending',
'bg-cyan-500': item.status === 'sent',
'bg-emerald-500': item.status === 'success',
'bg-rose-500': item.status === 'failed',
'bg-slate-400': item.status === 'superseded'
}"></div>
<span x-text="getCommandStatus(item.status)"></span>
</div>
</div>
</td>
</tr>
</template>
<template x-if="history.length === 0">
<tr>
<td colspan="6" class="px-6 py-20 text-center">
<div class="flex flex-col items-center gap-3">
<div class="w-16 h-16 rounded-full bg-slate-50 dark:bg-slate-900/50 flex items-center justify-center text-slate-200 dark:text-slate-800">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<p class="text-slate-400 font-bold tracking-widest uppercase text-xs">{{ __('No records found') }}</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<!-- Master View: Machine List -->
<template x-if="viewMode === 'list'">
<div class="space-y-6 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
<!-- Filters Area -->
<div class="flex items-center justify-between mb-8">
<div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" x-model="searchQuery"
placeholder="{{ __('Search...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-72">
</div>
</div>
<div class="overflow-x-auto pb-4">
<table class="w-full text-left border-separate border-spacing-y-0 text-sm whitespace-nowrap">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine Information') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Alerts') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Last Sync') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<template x-for="machine in machines.filter(m =>
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
m.serial_no.toLowerCase().includes(searchQuery.toLowerCase())
)" :key="machine.id">
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(machine)">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm">
<template x-if="machine.image_urls && machine.image_urls[0]">
<img :src="machine.image_urls[0]" class="w-full h-full object-cover">
</template>
<template x-if="!machine.image_urls || !machine.image_urls[0]">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
</svg>
</template>
</div>
<div>
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="machine.name"></div>
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.serial_no"></div>
</div>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex justify-center">
<template x-if="machine.status === 'online' || !machine.status">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<div class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</div>
<span class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-[0.1em] uppercase">{{ __('Online') }}</span>
</div>
</template>
<template x-if="machine.status === 'offline'">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 tracking-[0.1em] uppercase">{{ __('Offline') }}</span>
</div>
</template>
<template x-if="machine.status && machine.status !== 'online' && machine.status !== 'offline'">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
<div class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
</div>
<span class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-[0.1em] uppercase">{{ __('Abnormal') }}</span>
</div>
</template>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center gap-1.5">
<template x-if="machine.low_stock_count > 0">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-rose-500/10 text-rose-500 text-[10px] font-black border border-rose-500/20 uppercase tracking-widest leading-none shadow-sm shadow-rose-500/5">
<span class="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse"></span>
<span x-text="machine.low_stock_count"></span>&nbsp;{{ __('Low') }}
</span>
</template>
<template x-if="machine.expiring_soon_count > 0">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-500 text-[10px] font-black border border-amber-500/20 uppercase tracking-widest leading-none shadow-sm shadow-amber-500/5">
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
<span x-text="machine.expiring_soon_count"></span>&nbsp;{{ __('Expiring') }}
</span>
</template>
<template x-if="!machine.low_stock_count && !machine.expiring_soon_count">
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-600 uppercase tracking-[0.1em]">{{ __('All Stable') }}</span>
</template>
</div>
</td>
<td class="px-6 py-6 text-center">
<div class="flex flex-col items-center">
<span class="text-sm font-black text-slate-700 dark:text-slate-200" x-text="formatTime(machine.last_heartbeat_at)"></span>
<span class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.last_heartbeat_at ? machine.last_heartbeat_at.split('T')[0] : '--'"></span>
</div>
</td>
<td class="px-6 py-6 text-right">
<button @click="selectMachine(machine)"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
title="{{ __('Manage') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<!-- Detail View: Cabinet Management -->
<template x-if="viewMode === 'detail'">
<div class="space-y-8 animate-luxury-in">
<!-- Machine Header Info -->
<div class="luxury-card rounded-[2.5rem] p-8 md:p-10 flex flex-col md:flex-row md:items-center justify-between gap-8 border border-slate-200/60 dark:border-slate-800/60">
<div class="flex items-center gap-8">
<div class="w-24 h-24 rounded-3xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 overflow-hidden shadow-inner">
<template x-if="selectedMachine?.image_urls && selectedMachine?.image_urls[0]">
<img :src="selectedMachine.image_urls[0]" class="w-full h-full object-cover">
</template>
<template x-if="!selectedMachine?.image_urls || !selectedMachine?.image_urls[0]">
<svg class="w-12 h-12 stroke-[1.2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</template>
</div>
<div>
<h1 x-text="selectedMachine?.name" class="text-4xl font-black text-slate-800 dark:text-white tracking-tighter leading-tight"></h1>
<div class="flex items-center gap-4 mt-3">
<span x-text="selectedMachine?.serial_no" class="px-3 py-1 rounded-lg bg-cyan-500/10 text-cyan-500 text-xs font-mono font-bold uppercase tracking-widest border border-cyan-500/20"></span>
<div class="flex items-center gap-2 text-slate-400 uppercase tracking-widest text-[10px] font-black">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span x-text="selectedMachine?.location || '{{ __('No Location') }}'"></span>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-5">
<div class="px-7 py-4 rounded-[1.75rem] bg-slate-50 dark:bg-slate-800/50 flex flex-col items-center min-w-[120px] border border-slate-100 dark:border-slate-800/50">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Total Slots') }}</span>
<span class="text-3xl font-black text-slate-700 dark:text-slate-200" x-text="slots.length"></span>
</div>
<div class="px-7 py-4 rounded-[1.75rem] bg-rose-500/5 border border-rose-500/10 flex flex-col items-center min-w-[120px]">
<span class="text-[10px] font-black text-rose-500 uppercase tracking-widest mb-1">{{ __('Low Stock') }}</span>
<span class="text-3xl font-black text-rose-600" x-text="slots.filter(s => s != null && s.stock <= 5).length"></span>
</div>
</div>
</div>
<!-- Cabinet Visualization Grid -->
<div class="space-y-6">
<!-- Status Legend -->
<div class="flex items-center justify-between px-4">
<div class="flex items-center gap-8">
<div class="flex items-center gap-2.5">
<span class="w-3.5 h-3.5 rounded-full bg-rose-500 shadow-lg shadow-rose-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{ __('Expired') }}</span>
</div>
<div class="flex items-center gap-2.5">
<span class="w-3.5 h-3.5 rounded-full bg-amber-500 shadow-lg shadow-amber-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{ __('Warning') }}</span>
</div>
<div class="flex items-center gap-2.5">
<span class="w-3.5 h-3.5 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/30"></span>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{ __('Normal') }}</span>
</div>
</div>
</div>
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/50 dark:border-slate-800/50 bg-white/30 dark:bg-slate-900/40 backdrop-blur-xl relative overflow-hidden min-h-[500px]">
<!-- Loading Overlay -->
<div x-show="loading" class="absolute inset-0 bg-white/60 dark:bg-slate-900/60 backdrop-blur-md z-20 flex items-center justify-center transition-all duration-500">
<div class="flex flex-col items-center gap-6">
<div class="w-16 h-16 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
<span class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.3em] ml-2 animate-pulse">{{ __('Loading Cabinet...') }}</span>
</div>
</div>
<!-- Background Decorative Grid -->
<div class="absolute inset-0 opacity-[0.05] pointer-events-none"
style="background-image: radial-gradient(#00d2ff 1.2px, transparent 1.2px); background-size: 40px 40px;">
</div>
<!-- Slots Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6 relative z-10" x-show="!loading">
<template x-for="slot in slots" :key="slot.id">
<div @click="openEdit(slot)"
:class="getSlotColorClass(slot)"
class="min-h-[300px] rounded-[2.5rem] p-5 flex flex-col items-center justify-center border-2 transition-all duration-500 cursor-pointer group hover:scale-[1.08] hover:-translate-y-3 hover:shadow-2xl active:scale-[0.98] relative">
<!-- Slot Header (Pinned to top) -->
<div class="absolute top-4 left-5 right-5 flex justify-between items-center z-20">
<div class="px-2.5 py-1 rounded-xl bg-slate-900/10 dark:bg-white/10 backdrop-blur-md border border-slate-900/5 dark:border-white/10 flex-shrink-0">
<span class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white" x-text="slot.slot_no"></span>
</div>
<template x-if="slot.stock <= 2">
<div class="px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
<!-- Product Image -->
<div class="relative w-20 h-20 mb-4 mt-1">
<div class="absolute inset-0 rounded-[2rem] bg-white/20 dark:bg-slate-900/40 backdrop-blur-xl border border-white/30 dark:border-white/5 shadow-inner group-hover:scale-105 transition-transform duration-500 overflow-hidden">
<template x-if="slot.product && slot.product.image_url">
<img :src="slot.product.image_url" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110">
</template>
<template x-if="!slot.product">
<div class="w-full h-full flex items-center justify-center">
<svg class="w-8 h-8 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
</template>
</div>
</div>
<!-- Slot Info -->
<div class="text-center w-full space-y-3">
<template x-if="slot.product">
<div class="text-base font-black truncate w-full opacity-90 tracking-tight" x-text="slot.product.name"></div>
</template>
<div class="space-y-3">
<!-- Stock Level -->
<div class="flex flex-col items-center">
<div class="flex items-baseline gap-1">
<span class="text-2xl font-black tracking-tighter leading-none" x-text="slot.stock"></span>
<span class="text-xs font-black opacity-30">/</span>
<span class="text-sm font-bold opacity-50" x-text="slot.max_stock || 10"></span>
</div>
</div>
<!-- Expiry Date -->
<div class="flex flex-col items-center">
<span class="text-base font-black tracking-tight leading-none opacity-80" x-text="slot.expiry_date ? slot.expiry_date.replace(/-/g, '/') : '----/--/--'"></span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<!-- Integrated Edit Modal -->
<div x-show="showEditModal"
class="fixed inset-0 z-[100] overflow-y-auto"
style="display: none;"
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">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showEditModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-xl sm:w-full overflow-visible animate-luxury-in"
@click.away="showEditModal = false">
<!-- Modal Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h3 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none">
{{ __('Edit Slot') }} <span x-text="selectedSlot?.slot_no || ''" class="text-cyan-500"></span>
</h3>
<template x-if="selectedSlot && selectedSlot.product">
<p x-text="selectedSlot?.product?.name" class="text-base font-black text-slate-400 uppercase tracking-widest mt-3 ml-0.5"></p>
</template>
</div>
<button @click="showEditModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800">
<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 Controls -->
<div class="space-y-6">
<!-- Stock Count Widget -->
<div class="space-y-2">
<label class="text-base font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Stock Quantity') }}</label>
<div class="flex items-center gap-4 bg-slate-50 dark:bg-slate-900/50 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/50">
<div class="flex-1">
<input type="number" x-model="formData.stock" min="0" :max="selectedSlot ? selectedSlot.max_stock : 99"
class="w-full bg-transparent border-none p-0 text-5xl font-black text-slate-800 dark:text-white focus:ring-0 placeholder-slate-200">
<div class="text-sm font-black text-slate-400 mt-2 uppercase tracking-wider pl-0.5">
{{ __('Max Capacity:') }} <span class="text-slate-600 dark:text-slate-300" x-text="selectedSlot?.max_stock || 0"></span>
</div>
</div>
<div class="flex gap-2">
<button @click="formData.stock = 0" class="px-5 py-3 rounded-lg bg-white dark:bg-slate-800 text-slate-400 hover:text-rose-500 border border-slate-200 dark:border-slate-700 transition-all text-sm font-black uppercase tracking-widest active:scale-95 shadow-sm">
{{ __('Clear') }}
</button>
<button @click="formData.stock = selectedSlot?.max_stock || 0" class="px-5 py-3 rounded-lg bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500 hover:text-white border border-cyan-500/20 transition-all text-sm font-black uppercase tracking-widest active:scale-95 shadow-sm">
{{ __('Max') }}
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Expiry Date -->
<div class="space-y-2">
<label class="text-sm font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Expiry Date') }}</label>
<input type="date" x-model="formData.expiry_date"
class="luxury-input w-full py-4 px-5">
</div>
<!-- Batch Number -->
<div class="space-y-2">
<label class="text-sm font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Batch Number') }}</label>
<input type="text" x-model="formData.batch_no" placeholder="B2026-XXXX"
class="luxury-input w-full py-4 px-5">
</div>
</div>
</div>
<!-- Footer Actions -->
<div class="flex justify-end gap-x-4 mt-10 pt-8 border-t border-slate-100 dark:border-slate-800/50">
<button type="button" @click="showEditModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
<button type="button" @click="saveChanges()" :disabled="updating" class="btn-luxury-primary px-12 min-w-[160px]">
<div x-show="updating" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin mr-3"></div>
<span x-text="updating ? '{{ __('Saving...') }}' : '{{ __('Confirm Changes') }}'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
<style>
/* Hide default number spinners */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
appearance: none;
}
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e133; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #cbd5e166; }
</style>
@endsection

View File

@@ -119,9 +119,11 @@
'advertisements' => __('Advertisement Management'), 'advertisements' => __('Advertisement Management'),
default => null, default => null,
}, },
'remote' => __('Command Center'),
'stock' => __('Stock & Expiry'),
default => null, default => null,
}, },
'edit' => str_starts_with($routeName, 'profile') ? null : __('Edit'), 'edit' => str_starts_with($routeName, 'profile') ? __('Profile') : __('Edit'),
'create' => __('Create'), 'create' => __('Create'),
'show' => __('Detail'), 'show' => __('Detail'),
'logs' => __('Machine Logs'), 'logs' => __('Machine Logs'),
@@ -140,7 +142,7 @@
'purchases' => __('Purchases'), 'purchases' => __('Purchases'),
'replenishments' => __('Replenishments'), 'replenishments' => __('Replenishments'),
'replenishment-records' => __('Replenishment Records'), 'replenishment-records' => __('Replenishment Records'),
'machine-stock' => __('Machine Stock'), 'machine-stock' => __('Stock & Expiry'),
'staff-stock' => __('Staff Stock'), 'staff-stock' => __('Staff Stock'),
'returns' => __('Returns'), 'returns' => __('Returns'),
'pickup-codes' => __('Pickup Codes'), 'pickup-codes' => __('Pickup Codes'),
@@ -184,8 +186,9 @@
'warehouses' => __('Warehouse Permissions'), 'warehouses' => __('Warehouse Permissions'),
'analysis' => __('Analysis Permissions'), 'analysis' => __('Analysis Permissions'),
'audit' => __('Audit Permissions'), 'audit' => __('Audit Permissions'),
'remote' => __('Remote Permissions'), 'remote' => __('Command Center'),
'line' => __('Line Permissions'), 'line' => __('Line Permissions'),
'stock' => __('Stock & Expiry'),
default => null, default => null,
}; };

View File

@@ -166,7 +166,12 @@
<a href="#{{ $api['slug'] }}" class="luxury-nav-link" <a href="#{{ $api['slug'] }}" class="luxury-nav-link"
:class="{ 'active': activeSection === '{{ $api['slug'] }}' }" :class="{ 'active': activeSection === '{{ $api['slug'] }}' }"
@click="activeSection = '{{ $api['slug'] }}'"> @click="activeSection = '{{ $api['slug'] }}'">
<span>{{ $api['name'] }}</span> <div class="flex items-center gap-2 overflow-hidden w-full">
<span class="flex-shrink-0 text-[10px] w-10 text-center font-black px-1.5 py-0.5 rounded-md uppercase tracking-tighter {{ $api['method'] === 'GET' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : ($api['method'] === 'POST' ? 'bg-cyan-50 text-cyan-600 border border-cyan-200' : 'bg-amber-50 text-amber-600 border border-amber-200') }}">
{{ $api['method'] }}
</span>
<span class="truncate">{{ $api['name'] }}</span>
</div>
</a> </a>
@endforeach @endforeach
@endforeach @endforeach
@@ -187,7 +192,12 @@
<div id="{{ $api['slug'] }}" class="mb-16 animate-luxury-in" x-intersect="activeSection = '{{ $api['slug'] }}'"> <div id="{{ $api['slug'] }}" class="mb-16 animate-luxury-in" x-intersect="activeSection = '{{ $api['slug'] }}'">
<div class="mb-4"></div> <div class="mb-4"></div>
<h3 class="font-display text-3xl font-black text-slate-900 mb-6 tracking-tight">{{ $api['name'] }}</h3> <div class="flex items-center gap-4 mb-6">
<span class="px-3 py-1 text-sm font-black rounded-lg uppercase tracking-widest {{ $api['method'] === 'GET' ? 'bg-emerald-100 text-emerald-700' : ($api['method'] === 'POST' ? 'bg-cyan-100 text-cyan-700' : 'bg-amber-100 text-amber-700') }}">
{{ $api['method'] }}
</span>
<h3 class="font-display text-3xl font-black text-slate-900 tracking-tight">{{ $api['name'] }}</h3>
</div>
<p class="text-slate-600 mb-8 text-lg font-medium">{{ $api['description'] }}</p> <p class="text-slate-600 mb-8 text-lg font-medium">{{ $api['description'] }}</p>
<!-- Headers & URL --> <!-- Headers & URL -->
@@ -264,7 +274,7 @@
<span <span
class="text-[11px] font-black text-slate-400 uppercase tracking-tight">範例:</span> class="text-[11px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
<code <code
class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example']) : $param['example'] }}</code> class="text-sm text-cyan-600 font-bold">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
</div> </div>
@endif @endif
</td> </td>
@@ -280,7 +290,7 @@
<!-- Request Example --> <!-- Request Example -->
<div> <div>
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">請求範例 (Request Body)</h4> <h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">請求範例 (Request Body)</h4>
<pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre> <pre><code>{{ json_encode($api['request'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
</div> </div>
<!-- Response Examples --> <!-- Response Examples -->
@@ -310,7 +320,7 @@
@if(isset($param['example'])) @if(isset($param['example']))
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-2">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tight">範例:</span> <span class="text-[10px] font-black text-slate-400 uppercase tracking-tight">範例:</span>
<code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ $param['example'] }}</code> <code class="text-xs text-cyan-600 font-bold bg-cyan-50/50 px-2 py-0.5 rounded">{{ is_array($param['example']) ? json_encode($param['example'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $param['example'] }}</code>
</div> </div>
@endif @endif
</td> </td>
@@ -322,7 +332,7 @@
@endif @endif
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">回應範例 (Response Body)</h4> <h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">回應範例 (Response Body)</h4>
<pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</code></pre> <pre><code>{{ json_encode($api['response'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</code></pre>
</div> </div>
@if(isset($api['notes'])) @if(isset($api['notes']))

View File

@@ -236,7 +236,7 @@
<!-- End Sidebar --> <!-- End Sidebar -->
<!-- Content --> <!-- Content -->
<div class="w-full pt-6 lg:pt-10 pb-12 px-4 sm:px-6 md:px-8 lg:pl-72"> <div class="w-full pt-4 lg:pt-5 pb-12 px-4 sm:px-6 md:px-8 lg:pl-72">
<x-breadcrumbs class="mb-4 hidden lg:flex" /> <x-breadcrumbs class="mb-4 hidden lg:flex" />
<main class="animate-fade-up"> <main class="animate-fade-up">
@yield('content') @yield('content')

View File

@@ -127,7 +127,7 @@
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.purchases') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.purchases') }}">{{ __('Purchases') }}</a></li> <li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.purchases') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.purchases') }}">{{ __('Purchases') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.replenishments') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.replenishments') }}">{{ __('Replenishments') }}</a></li> <li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.replenishments') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.replenishments') }}">{{ __('Replenishments') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.replenishment-records') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.replenishment-records') }}">{{ __('Replenishment Records') }}</a></li> <li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.replenishment-records') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.replenishment-records') }}">{{ __('Replenishment Records') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.machine-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.machine-stock') }}">{{ __('Machine Stock') }}</a></li> <li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.machine-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.machine-stock') }}">{{ __('Stock & Expiry') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.staff-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.staff-stock') }}">{{ __('Staff Stock') }}</a></li> <li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.staff-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.staff-stock') }}">{{ __('Staff Stock') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.returns') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.returns') }}">{{ __('Returns') }}</a></li> <li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.warehouses.returns') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.warehouses.returns') }}">{{ __('Returns') }}</a></li>
</ul> </ul>
@@ -259,13 +259,14 @@
</button> </button>
<div x-show="open" x-collapse> <div x-show="open" x-collapse>
<ul class="luxury-submenu" data-sidebar-sub> <ul class="luxury-submenu" data-sidebar-sub>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.stock') }}">{{ __('Machine Stock') }}</a></li> <li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.stock') }}">
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.restart') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.restart') }}">{{ __('Machine Restart') }}</a></li> <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.restart-card-reader') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.restart-card-reader') }}">{{ __('Card Reader Restart') }}</a></li> {{ __('Stock & Expiry') }}
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.checkout') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.checkout') }}">{{ __('Remote Checkout') }}</a></li> </a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.lock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.lock') }}">{{ __('Remote Lock') }}</a></li> <li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.index') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.index') }}">
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.change') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.change') }}">{{ __('Remote Change') }}</a></li> <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.remote.dispense') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.remote.dispense') }}">{{ __('Remote Dispense') }}</a></li> {{ __('Command Center') }}
</a></li>
</ul> </ul>
</div> </div>
</li> </li>

View File

@@ -47,14 +47,33 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| 專門用於機台通訊,頻率較高,建議搭配異步處理。 | 專門用於機台通訊,頻率較高,建議搭配異步處理。
*/ */
// 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth)
Route::prefix('app')->group(function () {
Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1');
// 機台啟動引導與參數下載 (需人員登入 Token)
Route::middleware('auth:sanctum')->get('machine/setting/B014', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSettings']);
});
Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () { Route::prefix('app')->middleware(['iot.auth', 'throttle:100,1'])->group(function () {
// 心跳與狀態 (B010, B017, B710, B220) // 心跳與狀態 (B010, B017, B710, B220)
Route::post('machine/status/B010', [App\Http\Controllers\Api\V1\App\MachineController::class, 'heartbeat']); Route::post('machine/status/B010', [App\Http\Controllers\Api\V1\App\MachineController::class, 'heartbeat']);
Route::post('machine/reload_msg/B017', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSlots']); Route::get('machine/reload_msg/B017', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getSlots']);
Route::post('machine/timer/B710', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncTimer']); Route::post('machine/timer/B710', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncTimer']);
Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']); Route::post('machine/coins/B220', [App\Http\Controllers\Api\V1\App\MachineController::class, 'syncCoinInventory']);
Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']); Route::post('machine/member/verify/B650', [App\Http\Controllers\Api\V1\App\MachineController::class, 'verifyMember']);
// 廣告與貨道清單 (B005, B009, B012)
Route::get('machine/ad/B005', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getAdvertisements']);
Route::put('products/supplementary/B009', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportSlotList']);
// 統一商品主檔 API (B012 整合版)
Route::match(['get', 'patch'], 'machine/products/B012', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getProducts']);
// 機台故障與異常上報 (B013)
Route::post('machine/error/B013', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportError']);
// 交易、發票與出貨 (B600, B601, B602) // 交易、發票與出貨 (B600, B601, B602)
Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']); Route::post('machine/restock/B018', [App\Http\Controllers\Api\V1\App\MachineController::class, 'recordRestock']);
Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']); Route::post('B600', [App\Http\Controllers\Api\V1\App\TransactionController::class, 'store']);

View File

@@ -144,13 +144,9 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
// 10. 遠端管理 // 10. 遠端管理
Route::prefix('remote')->name('remote.')->group(function () { Route::prefix('remote')->name('remote.')->group(function () {
Route::get('/', [App\Http\Controllers\Admin\RemoteController::class, 'index'])->name('index');
Route::post('/command', [App\Http\Controllers\Admin\RemoteController::class, 'storeCommand'])->name('store-command');
Route::get('/stock', [App\Http\Controllers\Admin\RemoteController::class, 'stock'])->name('stock'); Route::get('/stock', [App\Http\Controllers\Admin\RemoteController::class, 'stock'])->name('stock');
Route::get('/restart', [App\Http\Controllers\Admin\RemoteController::class, 'restart'])->name('restart');
Route::get('/restart-card-reader', [App\Http\Controllers\Admin\RemoteController::class, 'restartCardReader'])->name('restart-card-reader');
Route::get('/checkout', [App\Http\Controllers\Admin\RemoteController::class, 'checkout'])->name('checkout');
Route::get('/lock', [App\Http\Controllers\Admin\RemoteController::class, 'lock'])->name('lock');
Route::get('/change', [App\Http\Controllers\Admin\RemoteController::class, 'change'])->name('change');
Route::get('/dispense', [App\Http\Controllers\Admin\RemoteController::class, 'dispense'])->name('dispense');
}); });
// 11. Line管理 // 11. Line管理

View File

@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MachineStatusTest extends TestCase
{
use RefreshDatabase;
/**
* Test machine is online with recent heartbeat and no errors.
*/
public function test_machine_is_online_with_recent_heartbeat(): void
{
$machine = Machine::factory()->create([
'last_heartbeat_at' => now()->subSeconds(10),
]);
$this->assertEquals('online', $machine->calculated_status);
}
/**
* Test machine is error with recent heartbeat but recent errors.
*/
public function test_machine_is_error_with_recent_logs(): void
{
$machine = Machine::factory()->create([
'last_heartbeat_at' => now()->subSeconds(10),
]);
// Add an error log 10 mins ago
MachineLog::create([
'machine_id' => $machine->id,
'level' => 'error',
'created_at' => now()->subMinutes(10),
'message' => 'Test error',
]);
$this->assertEquals('error', $machine->calculated_status);
}
/**
* Test machine is offline with old heartbeat (30s+).
*/
public function test_machine_is_offline_with_old_heartbeat(): void
{
$machine = Machine::factory()->create([
'last_heartbeat_at' => now()->subSeconds(35),
]);
$this->assertEquals('offline', $machine->calculated_status);
}
/**
* Test machine scopes (online, offline, hasError).
*/
public function test_machine_scopes(): void
{
// 1. Online machine
$onlineMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(10)]);
// 2. Offline machine
$offlineMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(40)]);
// 3. Online machine with error
$errorMachine = Machine::factory()->create(['last_heartbeat_at' => now()->subSeconds(5)]);
MachineLog::create([
'machine_id' => $errorMachine->id,
'level' => 'error',
'created_at' => now()->subMinutes(5),
'message' => 'Error log',
]);
$this->assertEquals(2, Machine::online()->count());
$this->assertEquals(1, Machine::offline()->count());
$this->assertEquals(1, Machine::online()->hasError()->count());
$this->assertTrue(Machine::online()->get()->contains($onlineMachine));
$this->assertTrue(Machine::offline()->get()->contains($offlineMachine));
$this->assertTrue(Machine::online()->hasError()->get()->contains($errorMachine));
}
}