Compare commits
11 Commits
729890d7c7
...
demo
| Author | SHA1 | Date | |
|---|---|---|---|
| 1301bf1cb8 | |||
| 24553d9b73 | |||
| ee985abb2e | |||
| 376f43fa3a | |||
| f49938d1a7 | |||
| 2702e5a655 | |||
| 6382709b90 | |||
| 66f7c1ffb8 | |||
| 32fa28dc0f | |||
| daf8b1ebcc | |||
| 8f008ffb61 |
@@ -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 在背景完成數據持久化。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -12,8 +12,18 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
|||||||
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
|
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
|
||||||
|
|
||||||
## 2. 身份認證 (Authentication)
|
## 2. 身份認證 (Authentication)
|
||||||
- **Bearer Token**:所有 API 必須在 Header 帶入 Authorization: Bearer <api_token>。
|
本系統採用兩階段認證模式:
|
||||||
- **身分綁定**:後端透過 Token 自動識別 machine_id,禁止在 Body 帶入 machine 或 key 欄位。
|
|
||||||
|
### 2.1 維運人員認證 (User Authentication)
|
||||||
|
- **核發端點**:B000 (登入)。
|
||||||
|
- **使用端點**:B014 (參數下載)。
|
||||||
|
- **方式**:使用 Laravel Sanctum 核發之 **User Token**。
|
||||||
|
- **Header**:`Authorization: Bearer <user_token>`。
|
||||||
|
|
||||||
|
### 2.2 機台通訊認證 (Machine Authentication)
|
||||||
|
- **適用 API**:B010, B012, B013, B600 等後續通訊。
|
||||||
|
- **方式**:使用機台專屬之 **api_token**。
|
||||||
|
- **Header**:`Authorization: Bearer <api_token>`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,6 +50,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
|||||||
| 參數 | 類型 | 說明 | 範例 |
|
| 參數 | 類型 | 說明 | 範例 |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| message | String | 驗證結果 (Success 或 Failed) | Success |
|
| message | String | 驗證結果 (Success 或 Failed) | Success |
|
||||||
|
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,3 +227,136 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
|||||||
| **0415** | Pickup door error | error | 取貨門異常 |
|
| **0415** | Pickup door error | error | 取貨門異常 |
|
||||||
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
|
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
|
||||||
| **5403** | Elevator failure | error | 昇降系統故障 |
|
| **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**: GET|PUT /api/v1/app/machine/dispense/B055
|
||||||
|
- **Authentication**: Bearer Token (Header)
|
||||||
|
- **運作模式**:
|
||||||
|
- **GET (查詢)**:當 B010 收到 `status: 85` 時呼叫。雲端會回傳待執行的貨道編號與指令 ID。
|
||||||
|
- **PUT (回報)**:實體出貨完成後回饋結果,以便雲端將該指令標記為「已執行」。
|
||||||
|
|
||||||
|
- **Response Body (GET - 查詢階段):**
|
||||||
|
|
||||||
|
| 參數 | 類型 | 說明 | 範例 |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| data | Array | 指令物件陣列 | [{"res1": "ID", "res2": "SlotNo"}] |
|
||||||
|
|
||||||
|
- **Request Body (PUT - 回報階段):**
|
||||||
|
|
||||||
|
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| id | String | 是 | 雲端下發的指令 ID | "99" |
|
||||||
|
| type | String | 是 | 出貨類型代碼 (0: 成功, 其他: 失敗) | "0" |
|
||||||
|
| stock | String | 是 | 出貨後的貨道剩餘數量 | "9" |
|
||||||
|
|||||||
@@ -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`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
100
.agents/skills/mqtt-communication-specs/SKILL.md
Normal file
100
.agents/skills/mqtt-communication-specs/SKILL.md
Normal 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 輪詢模式以維持基礎通訊。
|
||||||
@@ -22,51 +22,83 @@ class MachineSettingController extends AdminController
|
|||||||
/**
|
/**
|
||||||
* 顯示機台與型號設定列表 (採用標籤頁整合)
|
* 顯示機台與型號設定列表 (採用標籤頁整合)
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): View
|
public function index(Request $request): View|\Illuminate\Http\Response
|
||||||
{
|
{
|
||||||
$tab = $request->input('tab', 'machines');
|
$tab = $request->input('tab', 'machines');
|
||||||
$per_page = $request->input('per_page', 10);
|
$per_page = $request->input('per_page', 10);
|
||||||
$search = $request->input('search');
|
$search = $request->input('search');
|
||||||
|
$isAjax = $request->boolean('_ajax');
|
||||||
|
|
||||||
// 1. 處理機台清單 (Machines Tab)
|
// AJAX 模式:只查當前 Tab 的搜尋/分頁結果
|
||||||
|
if ($isAjax) {
|
||||||
|
$machines = null;
|
||||||
|
$models_list = null;
|
||||||
|
$users_list = null;
|
||||||
|
|
||||||
|
switch ($tab) {
|
||||||
|
case 'machines':
|
||||||
$machineQuery = Machine::query()->with(['machineModel', 'paymentConfig', 'company']);
|
$machineQuery = Machine::query()->with(['machineModel', 'paymentConfig', 'company']);
|
||||||
if ($tab === 'machines' && $search) {
|
if ($search) {
|
||||||
$machineQuery->where(function ($q) use ($search) {
|
$machineQuery->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}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
|
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
|
||||||
|
break;
|
||||||
|
|
||||||
// 2. 處理型號清單 (Models Tab)
|
case 'models':
|
||||||
$modelQuery = MachineModel::query()->withCount('machines');
|
$modelQuery = MachineModel::query()->withCount('machines');
|
||||||
if ($tab === 'models' && $search) {
|
if ($search) {
|
||||||
$modelQuery->where('name', 'like', "%{$search}%");
|
$modelQuery->where('name', 'like', "%{$search}%");
|
||||||
}
|
}
|
||||||
$models_list = $modelQuery->latest()->paginate($per_page)->withQueryString();
|
$models_list = $modelQuery->latest()->paginate($per_page)->withQueryString();
|
||||||
|
break;
|
||||||
|
|
||||||
// 3. 處理機台權限 (Permissions Tab) - 僅顯示 is_admin 帳號
|
case 'permissions':
|
||||||
$users_list = null;
|
|
||||||
if ($tab === 'permissions') {
|
|
||||||
$userQuery = \App\Models\System\User::query()
|
$userQuery = \App\Models\System\User::query()
|
||||||
->where('is_admin', true)
|
->where('is_admin', true)
|
||||||
->with(['company', 'machines']);
|
->with(['company', 'machines']);
|
||||||
|
|
||||||
if ($search) {
|
if ($search) {
|
||||||
$userQuery->where(function($q) use ($search) {
|
$userQuery->where(function($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'like', "%{$search}%")
|
||||||
->orWhere('username', 'like', "%{$search}%");
|
->orWhere('username', 'like', "%{$search}%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('company_id')) {
|
if ($request->filled('company_id')) {
|
||||||
$userQuery->where('company_id', $request->company_id);
|
$userQuery->where('company_id', $request->company_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 基礎下拉資料 (用於新增/編輯機台的彈窗)
|
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||||
|
|
||||||
|
$tabView = match($tab) {
|
||||||
|
'models' => 'admin.basic-settings.machines.partials.tab-models',
|
||||||
|
'permissions' => 'admin.basic-settings.machines.partials.tab-permissions',
|
||||||
|
default => 'admin.basic-settings.machines.partials.tab-machines',
|
||||||
|
};
|
||||||
|
return response()->view($tabView, compact(
|
||||||
|
'machines', 'models_list', 'users_list', 'companies', 'tab'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR 模式:一次查好全部三個 Tab 的首頁資料(供 x-show 即時切換)
|
||||||
|
$machines = Machine::query()
|
||||||
|
->with(['machineModel', 'paymentConfig', 'company'])
|
||||||
|
->latest()->paginate($per_page)->withQueryString();
|
||||||
|
|
||||||
|
$models_list = MachineModel::query()
|
||||||
|
->withCount('machines')
|
||||||
|
->latest()->paginate($per_page)->withQueryString();
|
||||||
|
|
||||||
|
$userQuery = \App\Models\System\User::query()
|
||||||
|
->where('is_admin', true)
|
||||||
|
->with(['company', 'machines']);
|
||||||
|
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
|
||||||
|
|
||||||
|
// 基礎下拉資料 (用於新增/編輯機台的彈窗)
|
||||||
$models = MachineModel::select('id', 'name')->get();
|
$models = MachineModel::select('id', 'name')->get();
|
||||||
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
|
||||||
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
|
||||||
|
|||||||
@@ -150,7 +150,11 @@ class ProductCategoryController extends Controller
|
|||||||
|
|
||||||
// 檢查是否已有商品使用此分類
|
// 檢查是否已有商品使用此分類
|
||||||
if ($category->products()->count() > 0) {
|
if ($category->products()->count() > 0) {
|
||||||
return redirect()->back()->with('error', __('Cannot delete category that has products. Please move products first.'));
|
$errorMsg = __('Cannot delete category that has products. Please move products first.');
|
||||||
|
if (request()->ajax()) {
|
||||||
|
return response()->json(['success' => false, 'message' => $errorMsg], 422);
|
||||||
|
}
|
||||||
|
return redirect()->back()->with('error', $errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($category->name_dictionary_key) {
|
if ($category->name_dictionary_key) {
|
||||||
@@ -159,8 +163,18 @@ class ProductCategoryController extends Controller
|
|||||||
|
|
||||||
$category->delete();
|
$category->delete();
|
||||||
|
|
||||||
|
if (request()->ajax()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('Category deleted successfully')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->back()->with('success', __('Category deleted successfully'));
|
return redirect()->back()->with('success', __('Category deleted successfully'));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
if (request()->ajax()) {
|
||||||
|
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
return redirect()->back()->with('error', $e->getMessage());
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ class ProductController extends Controller
|
|||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$query = Product::with(['category.translations', 'translations', 'company']);
|
$tab = $request->input('tab', 'products');
|
||||||
|
$per_page = $request->input('per_page', 10);
|
||||||
|
|
||||||
|
// Products Query
|
||||||
|
$productQuery = Product::with(['category.translations', 'translations', 'company']);
|
||||||
|
|
||||||
// 搜尋
|
// 搜尋
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('product_search')) {
|
||||||
$search = $request->search;
|
$search = $request->product_search;
|
||||||
$query->where(function($q) use ($search) {
|
$productQuery->where(function($q) use ($search) {
|
||||||
$q->where('name', 'like', "%{$search}%")
|
$q->where('name', 'like', "%{$search}%")
|
||||||
->orWhere('barcode', 'like', "%{$search}%")
|
->orWhere('barcode', 'like', "%{$search}%")
|
||||||
->orWhere('spec', 'like', "%{$search}%");
|
->orWhere('spec', 'like', "%{$search}%");
|
||||||
@@ -33,29 +37,66 @@ class ProductController extends Controller
|
|||||||
|
|
||||||
// 分類篩選
|
// 分類篩選
|
||||||
if ($request->filled('category_id')) {
|
if ($request->filled('category_id')) {
|
||||||
$query->where('category_id', $request->category_id);
|
$productQuery->where('category_id', $request->category_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$per_page = $request->input('per_page', 10);
|
|
||||||
|
|
||||||
$companyId = $user->company_id;
|
|
||||||
if ($user->isSystemAdmin()) {
|
if ($user->isSystemAdmin()) {
|
||||||
if ($request->filled('company_id')) {
|
if ($request->filled('product_company_id')) {
|
||||||
$companyId = $request->company_id;
|
$productQuery->where('company_id', $request->product_company_id);
|
||||||
$query->where('company_id', $companyId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$products = $query->latest()->paginate($per_page)->withQueryString();
|
$products = $productQuery->latest()->paginate($per_page, ['*'], 'product_page')->withQueryString();
|
||||||
$categories = ProductCategory::with('translations')->get();
|
|
||||||
|
// Categories Query
|
||||||
|
$categoryQuery = ProductCategory::with(['translations', 'company']);
|
||||||
|
|
||||||
|
if ($user->isSystemAdmin() && $request->filled('category_company_id')) {
|
||||||
|
$categoryQuery->where('company_id', $request->category_company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('category_search')) {
|
||||||
|
$search = $request->category_search;
|
||||||
|
$categoryQuery->where(function($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = $categoryQuery->latest()->paginate($per_page, ['*'], 'category_page')->withQueryString();
|
||||||
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
$companies = $user->isSystemAdmin() ? Company::all() : collect();
|
||||||
|
|
||||||
// 系統管理員在過濾特定公司時,應顯示該公司的功能開關 (如物料代碼、點數規則)
|
// Settings for Modal (use current user company or fallback)
|
||||||
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
|
$selectedCompanyId = $user->isSystemAdmin()
|
||||||
|
? ($request->input('product_company_id') ?: $request->input('category_company_id'))
|
||||||
|
: $user->company_id;
|
||||||
|
$selectedCompany = $selectedCompanyId ? Company::find($selectedCompanyId) : $user->company;
|
||||||
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
|
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
|
||||||
|
|
||||||
$routeName = 'admin.data-config.products.index';
|
$routeName = 'admin.data-config.products.index';
|
||||||
|
|
||||||
|
if ($request->ajax() || $request->wantsJson()) {
|
||||||
|
if ($tab === 'products') {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'html' => view('admin.products.partials.tab-products', [
|
||||||
|
'products' => $products,
|
||||||
|
'companySettings' => $companySettings,
|
||||||
|
'companies' => $companies,
|
||||||
|
'routeName' => $routeName
|
||||||
|
])->render()
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'html' => view('admin.products.partials.tab-categories', [
|
||||||
|
'categories' => $categories,
|
||||||
|
'companies' => $companies,
|
||||||
|
'routeName' => $routeName
|
||||||
|
])->render()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin.products.index', [
|
return view('admin.products.index', [
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
'categories' => $categories,
|
'categories' => $categories,
|
||||||
@@ -313,8 +354,23 @@ class ProductController extends Controller
|
|||||||
$product->save();
|
$product->save();
|
||||||
|
|
||||||
$status = $product->is_active ? __('Enabled') : __('Disabled');
|
$status = $product->is_active ? __('Enabled') : __('Disabled');
|
||||||
|
|
||||||
|
if (request()->ajax()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('Product status updated to :status', ['status' => $status]),
|
||||||
|
'is_active' => $product->is_active
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->back()->with('success', __('Product status updated to :status', ['status' => $status]));
|
return redirect()->back()->with('success', __('Product status updated to :status', ['status' => $status]));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
if (request()->ajax()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
return redirect()->back()->with('error', $e->getMessage());
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,8 +393,21 @@ class ProductController extends Controller
|
|||||||
|
|
||||||
$product->delete();
|
$product->delete();
|
||||||
|
|
||||||
|
if (request()->ajax()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('Product deleted successfully')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->back()->with('success', __('Product deleted successfully'));
|
return redirect()->back()->with('success', __('Product deleted successfully'));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
if (request()->ajax()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
return redirect()->back()->with('error', $e->getMessage());
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,19 +15,92 @@ class RemoteController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$machines = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
|
|
||||||
$selectedMachine = null;
|
$selectedMachine = null;
|
||||||
$history = RemoteCommand::where('command_type', '!=', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get();
|
|
||||||
|
|
||||||
|
// --- 1. 機台列表處理 (New Command Tab) ---
|
||||||
|
$machineQuery = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc');
|
||||||
|
|
||||||
|
if ($request->filled('search') && $request->input('tab') === 'list') {
|
||||||
|
$search = $request->input('search');
|
||||||
|
$machineQuery->where(function ($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('serial_no', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$machines = $machineQuery->paginate($request->input('per_page', 10), ['*'], 'machine_page');
|
||||||
|
|
||||||
|
// --- 2. 歷史紀錄處理 (Operation Records Tab) ---
|
||||||
|
$historyQuery = RemoteCommand::where('command_type', '!=', 'reload_stock')
|
||||||
|
->with(['machine', 'user']);
|
||||||
|
|
||||||
|
if ($request->filled('search') && ($request->input('tab') === 'history' || !$request->has('tab'))) {
|
||||||
|
$search = $request->input('search');
|
||||||
|
$historyQuery->where(function ($q) use ($search) {
|
||||||
|
$q->whereHas('machine', function ($mq) use ($search) {
|
||||||
|
$mq->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('serial_no', 'like', "%{$search}%");
|
||||||
|
})->orWhereHas('user', function ($uq) use ($search) {
|
||||||
|
$uq->where('name', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 時間區間過濾 (created_at)
|
||||||
|
if ($request->filled('start_date') || $request->filled('end_date')) {
|
||||||
|
try {
|
||||||
|
if ($request->filled('start_date')) {
|
||||||
|
$start = \Illuminate\Support\Carbon::parse($request->input('start_date'));
|
||||||
|
$historyQuery->where('created_at', '>=', $start);
|
||||||
|
}
|
||||||
|
if ($request->filled('end_date')) {
|
||||||
|
$end = \Illuminate\Support\Carbon::parse($request->input('end_date'));
|
||||||
|
$historyQuery->where('created_at', '<=', $end);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 忽略解析錯誤
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指令類型過濾
|
||||||
|
if ($request->filled('command_type')) {
|
||||||
|
$historyQuery->where('command_type', $request->input('command_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 狀態過濾
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$historyQuery->where('status', $request->input('status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$history = $historyQuery->latest()->paginate($request->input('per_page', 10), ['*'], 'history_page');
|
||||||
|
|
||||||
|
// --- 3. 特定機台詳情處理 ---
|
||||||
if ($request->has('machine_id')) {
|
if ($request->has('machine_id')) {
|
||||||
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
|
$selectedMachine = Machine::with([
|
||||||
|
'slots.product',
|
||||||
|
'commands' => function ($query) {
|
||||||
$query->where('command_type', '!=', 'reload_stock')
|
$query->where('command_type', '!=', 'reload_stock')
|
||||||
->latest()
|
->latest()
|
||||||
->limit(5);
|
->limit(5);
|
||||||
}])->find($request->machine_id);
|
}
|
||||||
|
])->find($request->machine_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 4. AJAX 回應處理 ---
|
||||||
if ($request->ajax()) {
|
if ($request->ajax()) {
|
||||||
|
if ($request->has('tab')) {
|
||||||
|
$tab = $request->input('tab');
|
||||||
|
$viewPath = $tab === 'list' ? 'admin.remote.partials.tab-machines-index' : 'admin.remote.partials.tab-history-index';
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'html' => view($viewPath, [
|
||||||
|
'machines' => $machines,
|
||||||
|
'history' => $history,
|
||||||
|
])->render()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'machine' => $selectedMachine,
|
'machine' => $selectedMachine,
|
||||||
@@ -100,7 +173,8 @@ class RemoteController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function stock(Request $request)
|
public function stock(Request $request)
|
||||||
{
|
{
|
||||||
$machines = Machine::withCount([
|
// 1. 機台查詢與分頁
|
||||||
|
$machineQuery = Machine::withCount([
|
||||||
'slots as slots_count',
|
'slots as slots_count',
|
||||||
'slots as low_stock_count' => function ($query) {
|
'slots as low_stock_count' => function ($query) {
|
||||||
$query->where('stock', '<=', 5);
|
$query->where('stock', '<=', 5);
|
||||||
@@ -110,17 +184,82 @@ class RemoteController extends Controller
|
|||||||
->where('expiry_date', '<=', now()->addDays(7))
|
->where('expiry_date', '<=', now()->addDays(7))
|
||||||
->where('expiry_date', '>=', now()->startOfDay());
|
->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();
|
if ($request->filled('machine_search')) {
|
||||||
|
$ms = $request->input('machine_search');
|
||||||
|
$machineQuery->where(function ($q) use ($ms) {
|
||||||
|
$q->where('name', 'like', "%{$ms}%")
|
||||||
|
->orWhere('serial_no', 'like', "%{$ms}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$machines = $machineQuery->orderBy('last_heartbeat_at', 'desc')
|
||||||
|
->orderBy('id', 'desc')
|
||||||
|
->paginate($request->input('per_page', 10), ['*'], 'machine_page');
|
||||||
|
|
||||||
|
// 2. 歷史紀錄查詢與分頁
|
||||||
|
$historyQuery = RemoteCommand::with(['machine', 'user']);
|
||||||
|
$historyQuery->where('command_type', 'reload_stock');
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->input('search');
|
||||||
|
$historyQuery->where(function ($q) use ($search) {
|
||||||
|
$q->whereHas('machine', function ($mq) use ($search) {
|
||||||
|
$mq->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('serial_no', 'like', "%{$search}%");
|
||||||
|
})->orWhereHas('user', function ($uq) use ($search) {
|
||||||
|
$uq->where('name', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 時間區間過濾 (created_at)
|
||||||
|
if ($request->filled('start_date') || $request->filled('end_date')) {
|
||||||
|
try {
|
||||||
|
if ($request->filled('start_date')) {
|
||||||
|
$start = \Illuminate\Support\Carbon::parse($request->input('start_date'));
|
||||||
|
$historyQuery->where('created_at', '>=', $start);
|
||||||
|
}
|
||||||
|
if ($request->filled('end_date')) {
|
||||||
|
$end = \Illuminate\Support\Carbon::parse($request->input('end_date'));
|
||||||
|
$historyQuery->where('created_at', '<=', $end);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 忽略解析錯誤
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 狀態過濾
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$historyQuery->where('status', $request->input('status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$history = $historyQuery->latest()->paginate($request->input('per_page', 10), ['*'], 'history_page');
|
||||||
|
|
||||||
|
// 3. AJAX 回傳處理
|
||||||
|
if ($request->boolean('_ajax')) {
|
||||||
|
$tab = $request->input('tab', 'history');
|
||||||
|
if ($tab === 'machines') {
|
||||||
|
return response()->view('admin.remote.partials.tab-machines', [
|
||||||
|
'machines' => $machines,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return response()->view('admin.remote.partials.tab-history', [
|
||||||
|
'history' => $history,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$selectedMachine = null;
|
$selectedMachine = null;
|
||||||
if ($request->has('machine_id')) {
|
if ($request->has('machine_id')) {
|
||||||
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
|
$selectedMachine = Machine::with([
|
||||||
|
'slots.product',
|
||||||
|
'commands' => function ($query) {
|
||||||
$query->where('command_type', 'reload_stock')
|
$query->where('command_type', 'reload_stock')
|
||||||
->latest()
|
->latest()
|
||||||
->limit(50);
|
->limit(50);
|
||||||
}])->find($request->machine_id);
|
}
|
||||||
|
])->find($request->machine_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin.remote.stock', [
|
return view('admin.remote.stock', [
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ class MachineAuthController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Success'
|
'message' => 'Success',
|
||||||
|
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,114 @@ class MachineController extends Controller
|
|||||||
], 202); // 202 Accepted
|
], 202); // 202 Accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B055: Get Remote Dispense Queue (POST/GET)
|
||||||
|
* 用於獲取雲端下發的遠端出貨指令詳情。
|
||||||
|
*/
|
||||||
|
public function getDispenseQueue(Request $request)
|
||||||
|
{
|
||||||
|
$machine = $request->get('machine');
|
||||||
|
|
||||||
|
// 查找該機台狀態為「已發送 (sent)」且類型為「出貨 (dispense)」的最新指令
|
||||||
|
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||||
|
->where('command_type', 'dispense')
|
||||||
|
->where('status', 'sent')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$command) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'code' => 200,
|
||||||
|
'data' => []
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 映射 Java APP 預期格式: res1 = ID, res2 = SlotNo
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'code' => 200,
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'res1' => (string) $command->id,
|
||||||
|
'res2' => (string) ($command->payload['slot_no'] ?? ''),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B055: Report Remote Dispense Result (PUT)
|
||||||
|
* 用於機台回報遠端出貨執行的最終結果。
|
||||||
|
*/
|
||||||
|
public function reportDispenseResult(Request $request, \App\Services\Machine\MachineService $machineService)
|
||||||
|
{
|
||||||
|
$commandId = $request->input('id');
|
||||||
|
$type = $request->input('type'); // 通常為 0
|
||||||
|
$stockReported = $request->input('stock'); // 機台回報的剩餘庫存
|
||||||
|
|
||||||
|
$command = \App\Models\Machine\RemoteCommand::find($commandId);
|
||||||
|
|
||||||
|
if (!$command) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'code' => 404,
|
||||||
|
'message' => 'Command not found'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新指令狀態 (0 代表成功)
|
||||||
|
$status = ($type == '0') ? 'success' : 'failed';
|
||||||
|
|
||||||
|
$payloadUpdates = [
|
||||||
|
'reported_stock' => $stockReported,
|
||||||
|
'report_type' => $type
|
||||||
|
];
|
||||||
|
|
||||||
|
// 若成功,連動更新貨道庫存
|
||||||
|
if ($status === 'success' && isset($command->payload['slot_no'])) {
|
||||||
|
$machine = $command->machine;
|
||||||
|
$slotNo = $command->payload['slot_no'];
|
||||||
|
|
||||||
|
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
|
||||||
|
if ($slot) {
|
||||||
|
$oldStock = $slot->stock;
|
||||||
|
// 若 APP 回傳的庫存大於 0 則使用回傳值,否則執行扣減
|
||||||
|
$newStock = (int)($stockReported ?? 0);
|
||||||
|
if ($newStock <= 0 && $oldStock > 0) {
|
||||||
|
$newStock = $oldStock - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalStock = max(0, $newStock);
|
||||||
|
$slot->update(['stock' => $finalStock]);
|
||||||
|
|
||||||
|
// 紀錄庫存變化供前端顯示
|
||||||
|
$payloadUpdates['old_stock'] = $oldStock;
|
||||||
|
$payloadUpdates['new_stock'] = $finalStock;
|
||||||
|
|
||||||
|
// 記錄狀態變化
|
||||||
|
ProcessStateLog::dispatch(
|
||||||
|
$machine->id,
|
||||||
|
$machine->company_id,
|
||||||
|
"Remote dispense successful for slot {$slotNo}",
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$command->update([
|
||||||
|
'status' => $status,
|
||||||
|
'executed_at' => now(),
|
||||||
|
'payload' => array_merge($command->payload, $payloadUpdates)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'code' => 200,
|
||||||
|
'message' => 'Result reported'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* B018: Record Machine Restock/Setup Report (Asynchronous)
|
* B018: Record Machine Restock/Setup Report (Asynchronous)
|
||||||
*/
|
*/
|
||||||
@@ -168,35 +276,46 @@ 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 指令已成功被機台響應
|
// 自動轉 Success: 若機台來撈 B017,代表之前的 reload_stock 指令已成功被機台響應
|
||||||
|
// 同時處理 sent 與 pending 狀態,確保狀態機正確關閉
|
||||||
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||||
->where('command_type', 'reload_stock')
|
->where('command_type', 'reload_stock')
|
||||||
->where('status', 'sent')
|
->whereIn('status', ['pending', 'sent'])
|
||||||
->update(['status' => 'success', 'executed_at' => now()]);
|
->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,
|
||||||
'product_id' => $slot->product_id,
|
'num' => (int)$slot->stock,
|
||||||
'stock' => $slot->stock,
|
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
|
||||||
'capacity' => $slot->capacity,
|
|
||||||
'price' => $slot->price,
|
|
||||||
'status' => $slot->status,
|
|
||||||
'expiry_date' => $slot->expiry_date,
|
|
||||||
'batch_no' => $slot->batch_no,
|
'batch_no' => $slot->batch_no,
|
||||||
|
// 保留原始欄位以供除錯或未來擴充
|
||||||
|
'product_id' => $slot->product_id,
|
||||||
|
'capacity' => $slot->max_stock,
|
||||||
|
'status' => $slot->is_active ? '1' : '0',
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* B710: Sync Timer status (Asynchronous)
|
* B710: Sync Timer status (Asynchronous)
|
||||||
*/
|
*/
|
||||||
@@ -456,4 +575,86 @@ class MachineController extends Controller
|
|||||||
'message' => 'Error report accepted',
|
'message' => 'Error report accepted',
|
||||||
], 202); // 202 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 預期的是包含單一物件的陣列
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,113 @@ 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)',
|
'name' => 'B005: 廣告清單同步 (Ad Sync)',
|
||||||
'slug' => 'b005-ad-sync',
|
'slug' => 'b005-ad-sync',
|
||||||
@@ -350,6 +457,141 @@ return [
|
|||||||
],
|
],
|
||||||
'notes' => '硬體代碼對照表見後端 MachineService::ERROR_CODE_MAP 定義。
|
'notes' => '硬體代碼對照表見後端 MachineService::ERROR_CODE_MAP 定義。
|
||||||
0402: 出貨成功, 0403: 貨道卡貨, 0202: 貨道缺貨, 0415: 取貨門異常...等。'
|
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']
|
||||||
|
]
|
||||||
|
],
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 秒)上送,用於確認連線狀態、溫度及門禁狀態。
|
||||||
|
|
||||||
|
|||||||
@@ -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) ✅ 已完成
|
||||||
> 為何最後:儀表板匯總機台、銷售、遠端指令、倉庫等全部數據,必須等上游模組完成才有意義。
|
> 為何最後:儀表板匯總機台、銷售、遠端指令、倉庫等全部數據,必須等上游模組完成才有意義。
|
||||||
|
|
||||||
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
|
| # | 模組名稱 | 子菜單項目 | 日期 | 功能重點 |
|
||||||
|
|||||||
191
docs/mqtt-implementation-plan.md
Normal file
191
docs/mqtt-implementation-plan.md
Normal 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 的處理速率
|
||||||
11
lang/en.json
11
lang/en.json
@@ -85,6 +85,7 @@
|
|||||||
"Apply changes to all identical products in this machine": "Apply changes to all identical products in this machine",
|
"Apply changes to all identical products in this machine": "Apply changes to all identical products in this machine",
|
||||||
"Apply to all identical products in this machine": "Apply to all identical products in this machine",
|
"Apply to all identical products in this machine": "Apply to all identical products in this machine",
|
||||||
"Are you sure to delete this customer?": "Are you sure to delete this customer?",
|
"Are you sure to delete this customer?": "Are you sure to delete this customer?",
|
||||||
|
"Are you sure you want to change the status of this item? This will affect its visibility on vending machines.": "Are you sure you want to change the status of this item? This will affect its visibility on vending machines.",
|
||||||
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.",
|
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.",
|
||||||
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.",
|
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.",
|
||||||
"Are you sure you want to change the status? This may affect associated accounts.": "Are you sure you want to change the status? This may affect associated accounts.",
|
"Are you sure you want to change the status? This may affect associated accounts.": "Are you sure you want to change the status? This may affect associated accounts.",
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
"Are you sure you want to delete this configuration?": "您確定要刪除此金流配置嗎?",
|
"Are you sure you want to delete this configuration?": "您確定要刪除此金流配置嗎?",
|
||||||
"Are you sure you want to delete this configuration? This action cannot be undone.": "Are you sure you want to delete this configuration? This action cannot be undone.",
|
"Are you sure you want to delete this configuration? This action cannot be undone.": "Are you sure you want to delete this configuration? This action cannot be undone.",
|
||||||
"Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
|
"Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
|
||||||
|
"Are you sure you want to delete this product or category? This action cannot be undone.": "Are you sure you want to delete this product or category? This action cannot be undone.",
|
||||||
"Are you sure you want to delete this product?": "Are you sure you want to delete this product?",
|
"Are you sure you want to delete this product?": "Are you sure you want to delete this product?",
|
||||||
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "Are you sure you want to delete this product? All related historical translation data will also be removed.",
|
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "Are you sure you want to delete this product? All related historical translation data will also be removed.",
|
||||||
"Are you sure you want to delete this role? This action cannot be undone.": "Are you sure you want to delete this role? This action cannot be undone.",
|
"Are you sure you want to delete this role? This action cannot be undone.": "Are you sure you want to delete this role? This action cannot be undone.",
|
||||||
@@ -828,6 +830,7 @@
|
|||||||
"Search by name or S\/N...": "Search by name or S\/N...",
|
"Search by name or S\/N...": "Search by name or S\/N...",
|
||||||
"Search cargo lane": "Search cargo lane",
|
"Search cargo lane": "Search cargo lane",
|
||||||
"Search Company Title...": "Search Company Title...",
|
"Search Company Title...": "Search Company Title...",
|
||||||
|
"Search categories...": "Search categories...",
|
||||||
"Search company...": "Search company...",
|
"Search company...": "Search company...",
|
||||||
"Search configurations...": "Search configurations...",
|
"Search configurations...": "Search configurations...",
|
||||||
"Search customers...": "Search customers...",
|
"Search customers...": "Search customers...",
|
||||||
@@ -1152,5 +1155,11 @@
|
|||||||
"Ongoing": "Ongoing",
|
"Ongoing": "Ongoing",
|
||||||
"Waiting": "Waiting",
|
"Waiting": "Waiting",
|
||||||
"Publish Time": "Publish Time",
|
"Publish Time": "Publish Time",
|
||||||
"Expired Time": "Expired Time"
|
"Expired Time": "Expired Time",
|
||||||
|
"Inventory synced with machine": "Inventory synced with machine",
|
||||||
|
"Failed to load tab content": "Failed to load tab content",
|
||||||
|
"No machines found": "No machines found",
|
||||||
|
"No products found matching your criteria.": "No products found matching your criteria.",
|
||||||
|
"No categories found.": "No categories found.",
|
||||||
|
"Track Limit (Track/Spring)": "Track Limit (Track/Spring)"
|
||||||
}
|
}
|
||||||
11
lang/ja.json
11
lang/ja.json
@@ -85,6 +85,7 @@
|
|||||||
"Apply changes to all identical products in this machine": "この機台の同一商品すべてに変更を適用",
|
"Apply changes to all identical products in this machine": "この機台の同一商品すべてに変更を適用",
|
||||||
"Apply to all identical products in this machine": "この機体内のすべての同一商品に適用する",
|
"Apply to all identical products in this machine": "この機体内のすべての同一商品に適用する",
|
||||||
"Are you sure to delete this customer?": "この顧客を削除してもよろしいですか?",
|
"Are you sure to delete this customer?": "この顧客を削除してもよろしいですか?",
|
||||||
|
"Are you sure you want to change the status of this item? This will affect its visibility on vending machines.": "この項目のステータスを変更してもよろしいですか?自動販売機での表示に影響します。",
|
||||||
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "この商品のステータスを変更してもよろしいですか?無効にすると機体に表示されなくなります。",
|
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "この商品のステータスを変更してもよろしいですか?無効にすると機体に表示されなくなります。",
|
||||||
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "ステータスを変更してもよろしいですか?無効にすると、このアカウントはシステムにログインできなくなります。",
|
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "ステータスを変更してもよろしいですか?無効にすると、このアカウントはシステムにログインできなくなります。",
|
||||||
"Are you sure you want to change the status? This may affect associated accounts.": "ステータスを変更してもよろしいですか?関連するアカウントに影響を与える可能性があります。",
|
"Are you sure you want to change the status? This may affect associated accounts.": "ステータスを変更してもよろしいですか?関連するアカウントに影響を与える可能性があります。",
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
"Are you sure you want to delete this configuration?": "この設定を削除してもよろしいですか?",
|
"Are you sure you want to delete this configuration?": "この設定を削除してもよろしいですか?",
|
||||||
"Are you sure you want to delete this configuration? This action cannot be undone.": "この設定を削除してもよろしいですか?この操作は取り消せません。",
|
"Are you sure you want to delete this configuration? This action cannot be undone.": "この設定を削除してもよろしいですか?この操作は取り消せません。",
|
||||||
"Are you sure you want to delete this item? This action cannot be undone.": "この項目を削除してもよろしいですか?この操作は取り消せません。",
|
"Are you sure you want to delete this item? This action cannot be undone.": "この項目を削除してもよろしいですか?この操作は取り消せません。",
|
||||||
|
"Are you sure you want to delete this product or category? This action cannot be undone.": "この商品またはカテゴリーを削除してもよろしいですか?この操作は取り消せません。",
|
||||||
"Are you sure you want to delete this product?": "この商品を削除してもよろしいですか?",
|
"Are you sure you want to delete this product?": "この商品を削除してもよろしいですか?",
|
||||||
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "この商品を削除してもよろしいですか?関連するすべての翻訳履歴データも削除されます。",
|
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "この商品を削除してもよろしいですか?関連するすべての翻訳履歴データも削除されます。",
|
||||||
"Are you sure you want to delete this role? This action cannot be undone.": "この権限を削除してもよろしいですか?この操作は取り消せません。",
|
"Are you sure you want to delete this role? This action cannot be undone.": "この権限を削除してもよろしいですか?この操作は取り消せません。",
|
||||||
@@ -827,6 +829,7 @@
|
|||||||
"Search by name or S\/N...": "名称または製造番号で検索...",
|
"Search by name or S\/N...": "名称または製造番号で検索...",
|
||||||
"Search cargo lane": "貨道を検索",
|
"Search cargo lane": "貨道を検索",
|
||||||
"Search Company Title...": "会社名を検索...",
|
"Search Company Title...": "会社名を検索...",
|
||||||
|
"Search categories...": "カテゴリーを検索...",
|
||||||
"Search company...": "会社を検索...",
|
"Search company...": "会社を検索...",
|
||||||
"Search configurations...": "設定を検索...",
|
"Search configurations...": "設定を検索...",
|
||||||
"Search customers...": "顧客を検索...",
|
"Search customers...": "顧客を検索...",
|
||||||
@@ -1151,5 +1154,11 @@
|
|||||||
"Ongoing": "進行中",
|
"Ongoing": "進行中",
|
||||||
"Waiting": "待機中",
|
"Waiting": "待機中",
|
||||||
"Publish Time": "公開時間",
|
"Publish Time": "公開時間",
|
||||||
"Expired Time": "終了時間"
|
"Expired Time": "終了時間",
|
||||||
|
"Inventory synced with machine": "在庫が機体と同期されました",
|
||||||
|
"Failed to load tab content": "タブコンテンツの読み込みに失敗しました",
|
||||||
|
"No machines found": "マシンが見つかりません",
|
||||||
|
"No products found matching your criteria.": "条件に一致する商品が見つかりませんでした。",
|
||||||
|
"No categories found.": "カテゴリーが見つかりませんでした。",
|
||||||
|
"Track Limit (Track/Spring)": "在庫上限 (ベルト/スプリング)"
|
||||||
}
|
}
|
||||||
@@ -62,9 +62,11 @@
|
|||||||
"All": "全部",
|
"All": "全部",
|
||||||
"All Affiliations": "所有公司",
|
"All Affiliations": "所有公司",
|
||||||
"All Categories": "所有分類",
|
"All Categories": "所有分類",
|
||||||
|
"All Command Types": "所有指令類型",
|
||||||
"All Companies": "所有公司",
|
"All Companies": "所有公司",
|
||||||
"All Levels": "所有層級",
|
"All Levels": "所有層級",
|
||||||
"All Machines": "所有機台",
|
"All Machines": "所有機台",
|
||||||
|
"All Status": "所有狀態",
|
||||||
"All Stable": "狀態穩定",
|
"All Stable": "狀態穩定",
|
||||||
"All Times System Timezone": "所有時間為系統時區",
|
"All Times System Timezone": "所有時間為系統時區",
|
||||||
"Amount": "金額",
|
"Amount": "金額",
|
||||||
@@ -85,6 +87,7 @@
|
|||||||
"Apply changes to all identical products in this machine": "同步套用至此機台內的所有相同商品",
|
"Apply changes to all identical products in this machine": "同步套用至此機台內的所有相同商品",
|
||||||
"Apply to all identical products in this machine": "同步套用至此機台內的所有相同商品",
|
"Apply to all identical products in this machine": "同步套用至此機台內的所有相同商品",
|
||||||
"Are you sure to delete this customer?": "您確定要刪除此客戶嗎?",
|
"Are you sure to delete this customer?": "您確定要刪除此客戶嗎?",
|
||||||
|
"Are you sure you want to change the status of this item? This will affect its visibility on vending machines.": "您確定要變更此項目的狀態嗎?這將會影響其在販賣機上的顯示內容。",
|
||||||
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "確定要變更此商品的狀態嗎?停用的商品將不會在機台上顯示。",
|
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "確定要變更此商品的狀態嗎?停用的商品將不會在機台上顯示。",
|
||||||
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "您確定要變更狀態嗎?停用之後,該帳號將會立即被登出且無法再登入系統。",
|
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "您確定要變更狀態嗎?停用之後,該帳號將會立即被登出且無法再登入系統。",
|
||||||
"Are you sure you want to change the status? This may affect associated accounts.": "您確定要變更狀態嗎?這可能會影響相關帳號的權限效力。",
|
"Are you sure you want to change the status? This may affect associated accounts.": "您確定要變更狀態嗎?這可能會影響相關帳號的權限效力。",
|
||||||
@@ -95,6 +98,7 @@
|
|||||||
"Are you sure you want to delete this configuration?": "您確定要刪除此金流配置嗎?",
|
"Are you sure you want to delete this configuration?": "您確定要刪除此金流配置嗎?",
|
||||||
"Are you sure you want to delete this configuration? This action cannot be undone.": "您確定要刪除此金流配置嗎?此操作將無法復原。",
|
"Are you sure you want to delete this configuration? This action cannot be undone.": "您確定要刪除此金流配置嗎?此操作將無法復原。",
|
||||||
"Are you sure you want to delete this item? This action cannot be undone.": "確定要刪除此項目嗎?此操作無法復原。",
|
"Are you sure you want to delete this item? This action cannot be undone.": "確定要刪除此項目嗎?此操作無法復原。",
|
||||||
|
"Are you sure you want to delete this product or category? This action cannot be undone.": "您確定要刪除此商品或分類嗎?此操作將無法復原。",
|
||||||
"Are you sure you want to delete this product?": "您確定要刪除此商品嗎?",
|
"Are you sure you want to delete this product?": "您確定要刪除此商品嗎?",
|
||||||
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "確定要刪除此商品嗎?所有相關的歷史翻譯數據也將被移除。",
|
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "確定要刪除此商品嗎?所有相關的歷史翻譯數據也將被移除。",
|
||||||
"Are you sure you want to delete this role? This action cannot be undone.": "您確定要刪除此角色嗎?此操作將無法復原。",
|
"Are you sure you want to delete this role? This action cannot be undone.": "您確定要刪除此角色嗎?此操作將無法復原。",
|
||||||
@@ -274,6 +278,7 @@
|
|||||||
"Discord Notifications": "Discord通知",
|
"Discord Notifications": "Discord通知",
|
||||||
"Dispense Failed": "出貨失敗",
|
"Dispense Failed": "出貨失敗",
|
||||||
"Dispense Success": "出貨成功",
|
"Dispense Success": "出貨成功",
|
||||||
|
"Displaying": "目前顯示",
|
||||||
"Dispensing": "出貨",
|
"Dispensing": "出貨",
|
||||||
"Duration": "時長",
|
"Duration": "時長",
|
||||||
"Duration (Seconds)": "播放秒數",
|
"Duration (Seconds)": "播放秒數",
|
||||||
@@ -393,7 +398,7 @@
|
|||||||
"Initial Role": "初始角色",
|
"Initial Role": "初始角色",
|
||||||
"Installation": "裝機",
|
"Installation": "裝機",
|
||||||
"Invoice Status": "發票開立狀態",
|
"Invoice Status": "發票開立狀態",
|
||||||
"Items": "個項目",
|
"Items": "筆",
|
||||||
"items": "筆項目",
|
"items": "筆項目",
|
||||||
"Japanese": "日文",
|
"Japanese": "日文",
|
||||||
"JKO_MERCHANT_ID": "街口支付 商店代號",
|
"JKO_MERCHANT_ID": "街口支付 商店代號",
|
||||||
@@ -631,8 +636,9 @@
|
|||||||
"OEE.Hours": "小時",
|
"OEE.Hours": "小時",
|
||||||
"OEE.Orders": "訂單",
|
"OEE.Orders": "訂單",
|
||||||
"OEE.Sales": "銷售",
|
"OEE.Sales": "銷售",
|
||||||
"of": "總計",
|
"of": "/共",
|
||||||
"Offline": "離線",
|
"Offline": "離線",
|
||||||
|
"of items": "筆項目",
|
||||||
"Offline Machines": "離線機台",
|
"Offline Machines": "離線機台",
|
||||||
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "一旦您的帳號被刪除,其所有資源和數據將被永久刪除。在刪除帳號之前,請下載您希望保留的任何數據或資訊。",
|
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "一旦您的帳號被刪除,其所有資源和數據將被永久刪除。在刪除帳號之前,請下載您希望保留的任何數據或資訊。",
|
||||||
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "帳號一旦刪除,所有關連數據將被永久移除。請輸入您的密碼以確認您希望永久刪除此帳號。",
|
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "帳號一旦刪除,所有關連數據將被永久移除。請輸入您的密碼以確認您希望永久刪除此帳號。",
|
||||||
@@ -828,6 +834,7 @@
|
|||||||
"Search by name or S\/N...": "搜尋名稱或序號...",
|
"Search by name or S\/N...": "搜尋名稱或序號...",
|
||||||
"Search cargo lane": "搜尋貨道編號或商品名稱",
|
"Search cargo lane": "搜尋貨道編號或商品名稱",
|
||||||
"Search Company Title...": "搜尋公司名稱...",
|
"Search Company Title...": "搜尋公司名稱...",
|
||||||
|
"Search categories...": "搜尋分類...",
|
||||||
"Search company...": "搜尋公司...",
|
"Search company...": "搜尋公司...",
|
||||||
"Search configurations...": "搜尋設定...",
|
"Search configurations...": "搜尋設定...",
|
||||||
"Search customers...": "搜尋客戶...",
|
"Search customers...": "搜尋客戶...",
|
||||||
@@ -853,6 +860,7 @@
|
|||||||
"Select Category": "選擇類別",
|
"Select Category": "選擇類別",
|
||||||
"Select Company": "選擇公司名稱",
|
"Select Company": "選擇公司名稱",
|
||||||
"Select Company (Default: System)": "選擇公司 (預設:系統)",
|
"Select Company (Default: System)": "選擇公司 (預設:系統)",
|
||||||
|
"Select Date Range": "選擇建立日期區間",
|
||||||
"Select date to sync data": "選擇日期以同步數據",
|
"Select date to sync data": "選擇日期以同步數據",
|
||||||
"Select Machine": "選擇機台",
|
"Select Machine": "選擇機台",
|
||||||
"Select Machine to view metrics": "請選擇機台以查看指標",
|
"Select Machine to view metrics": "請選擇機台以查看指標",
|
||||||
@@ -876,7 +884,7 @@
|
|||||||
"Show": "顯示",
|
"Show": "顯示",
|
||||||
"Show material code field in products": "在商品資料中顯示物料編號欄位",
|
"Show material code field in products": "在商品資料中顯示物料編號欄位",
|
||||||
"Show points rules in products": "在商品資料中顯示點數規則相關欄位",
|
"Show points rules in products": "在商品資料中顯示點數規則相關欄位",
|
||||||
"Showing": "顯示第",
|
"Showing": "目前顯示",
|
||||||
"Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項",
|
"Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項",
|
||||||
"Sign in to your account": "隨時隨地掌控您的業務。",
|
"Sign in to your account": "隨時隨地掌控您的業務。",
|
||||||
"Signed in as": "登入身份",
|
"Signed in as": "登入身份",
|
||||||
@@ -908,6 +916,7 @@
|
|||||||
"Stock & Expiry Overview": "庫存與效期一覽",
|
"Stock & Expiry Overview": "庫存與效期一覽",
|
||||||
"Stock Management": "庫存管理單",
|
"Stock Management": "庫存管理單",
|
||||||
"Stock Quantity": "庫存數量",
|
"Stock Quantity": "庫存數量",
|
||||||
|
"Stock Update": "同步庫存",
|
||||||
"Stock:": "庫存:",
|
"Stock:": "庫存:",
|
||||||
"Store Gifts": "來店禮",
|
"Store Gifts": "來店禮",
|
||||||
"Store ID": "商店代號",
|
"Store ID": "商店代號",
|
||||||
@@ -980,7 +989,10 @@
|
|||||||
"Track Channel Limit": "履帶貨道上限",
|
"Track Channel Limit": "履帶貨道上限",
|
||||||
"Track device health and maintenance history": "追蹤設備健康與維修歷史",
|
"Track device health and maintenance history": "追蹤設備健康與維修歷史",
|
||||||
"Track Limit": "履帶貨道上限",
|
"Track Limit": "履帶貨道上限",
|
||||||
|
"Track Limit (Track/Spring)": "貨道上限(履帶/彈簧)",
|
||||||
"Traditional Chinese": "繁體中文",
|
"Traditional Chinese": "繁體中文",
|
||||||
|
"Track": "履帶",
|
||||||
|
"Spring": "彈簧",
|
||||||
"Transfer Audit": "調撥單",
|
"Transfer Audit": "調撥單",
|
||||||
"Transfers": "調撥單",
|
"Transfers": "調撥單",
|
||||||
"Trigger": "觸發",
|
"Trigger": "觸發",
|
||||||
@@ -1152,5 +1164,10 @@
|
|||||||
"Ongoing": "進行中",
|
"Ongoing": "進行中",
|
||||||
"Waiting": "等待中",
|
"Waiting": "等待中",
|
||||||
"Publish Time": "發布時間",
|
"Publish Time": "發布時間",
|
||||||
"Expired Time": "下架時間"
|
"Expired Time": "下架時間",
|
||||||
|
"Inventory synced with machine": "庫存已與機台同步",
|
||||||
|
"Failed to load tab content": "載入分頁內容失敗",
|
||||||
|
"No machines found": "未找到機台",
|
||||||
|
"No products found matching your criteria.": "找不到符合條件的商品。",
|
||||||
|
"No categories found.": "找不到分類。"
|
||||||
}
|
}
|
||||||
@@ -373,11 +373,27 @@
|
|||||||
color: #06b6d4 !important;
|
color: #06b6d4 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flatpickr-day.selected {
|
.flatpickr-day.selected,
|
||||||
|
.flatpickr-day.startRange,
|
||||||
|
.flatpickr-day.endRange {
|
||||||
background: linear-gradient(135deg, #06b6d4, #3b82f6) !important;
|
background: linear-gradient(135deg, #06b6d4, #3b82f6) !important;
|
||||||
border-color: transparent !important;
|
border-color: transparent !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
box-shadow: 0 8px 15px -3px rgba(6, 182, 212, 0.4) !important;
|
box-shadow: 0 8px 15px -3px rgba(6, 182, 212, 0.4) !important;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-day.inRange {
|
||||||
|
background: #ecfeff !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: -5px 0 0 #ecfeff, 5px 0 0 #ecfeff !important;
|
||||||
|
color: #0891b2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .flatpickr-day.inRange {
|
||||||
|
background: rgba(6, 182, 212, 0.15) !important;
|
||||||
|
box-shadow: -5px 0 0 rgba(6, 182, 212, 0.15), 5px 0 0 rgba(6, 182, 212, 0.15) !important;
|
||||||
|
color: #22d3ee !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flatpickr-day:not(.selected):hover {
|
.flatpickr-day:not(.selected):hover {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
@section('content')
|
@section('content')
|
||||||
<div class="space-y-2 pb-20" x-data="{
|
<div class="space-y-2 pb-20" x-data="{
|
||||||
tab: '{{ $tab }}',
|
tab: '{{ $tab }}',
|
||||||
|
tabLoading: false,
|
||||||
|
machineSearch: '',
|
||||||
|
modelSearch: '',
|
||||||
|
permissionSearch: '',
|
||||||
|
permissionCompanyId: '{{ request('company_id') }}',
|
||||||
showCreateMachineModal: false,
|
showCreateMachineModal: false,
|
||||||
showPhotoModal: false,
|
showPhotoModal: false,
|
||||||
showDetailDrawer: false,
|
showDetailDrawer: false,
|
||||||
@@ -180,7 +185,9 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message, type: 'success' } }));
|
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message, type: 'success' } }));
|
||||||
setTimeout(() => window.location.reload(), 500);
|
this.showPermissionModal = false;
|
||||||
|
// SPA 模式:重新載入 permissions Tab 內容
|
||||||
|
setTimeout(() => this.searchInTab('permissions'), 300);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || 'Update failed');
|
throw new Error(data.error || 'Update failed');
|
||||||
}
|
}
|
||||||
@@ -188,6 +195,113 @@
|
|||||||
.catch(e => {
|
.catch(e => {
|
||||||
window.dispatchEvent(new CustomEvent('toast', { detail: { message: e.message, type: 'error' } }));
|
window.dispatchEvent(new CustomEvent('toast', { detail: { message: e.message, type: 'error' } }));
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
// === 搜尋/分頁 AJAX(僅在搜尋或換頁時觸發,Tab 切換不走此路) ===
|
||||||
|
async searchInTab(tabName, extraQuery = '') {
|
||||||
|
this.tabLoading = true;
|
||||||
|
const searchMap = { machines: this.machineSearch, models: this.modelSearch, permissions: this.permissionSearch };
|
||||||
|
const search = searchMap[tabName] || '';
|
||||||
|
let qs = `tab=${tabName}&_ajax=1`;
|
||||||
|
if (search) qs += `&search=${encodeURIComponent(search)}`;
|
||||||
|
if (tabName === 'permissions' && this.permissionCompanyId) qs += `&company_id=${this.permissionCompanyId}`;
|
||||||
|
if (extraQuery) qs += extraQuery;
|
||||||
|
|
||||||
|
// 同步 URL(不含 _ajax)
|
||||||
|
const visibleQs = qs.replace(/&?_ajax=1/, '');
|
||||||
|
history.pushState({}, '', `{{ route('admin.basic-settings.machines.index') }}?${visibleQs}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`{{ route('admin.basic-settings.machines.index') }}?${qs}`,
|
||||||
|
{ headers: { 'X-Requested-With': 'XMLHttpRequest' } }
|
||||||
|
);
|
||||||
|
const html = await res.text();
|
||||||
|
const ref = this.$refs[tabName + 'Content'];
|
||||||
|
if (ref) {
|
||||||
|
ref.innerHTML = html;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Alpine.initTree(ref);
|
||||||
|
this.bindPaginationLinks(ref, tabName);
|
||||||
|
if (window.HSStaticMethods) {
|
||||||
|
setTimeout(() => window.HSStaticMethods.autoInit(), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Search failed:', e);
|
||||||
|
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Failed to load tab content') }}', type: 'error' } }));
|
||||||
|
} finally {
|
||||||
|
this.tabLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 攔截分頁連結,改為 AJAX 請求
|
||||||
|
bindPaginationLinks(container, tabName) {
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll('a[href]').forEach(a => {
|
||||||
|
const href = a.getAttribute('href');
|
||||||
|
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.origin);
|
||||||
|
if (!url.searchParams.has('page') || a.closest('td.px-6')) return;
|
||||||
|
a.addEventListener('click', (e) => {
|
||||||
|
if (a.title) return; // 排除 action 按鈕
|
||||||
|
e.preventDefault();
|
||||||
|
const page = url.searchParams.get('page') || 1;
|
||||||
|
const perPage = url.searchParams.get('per_page') || '';
|
||||||
|
let extra = `&page=${page}`;
|
||||||
|
if (perPage) extra += `&per_page=${perPage}`;
|
||||||
|
this.searchInTab(tabName, extra);
|
||||||
|
});
|
||||||
|
} catch(err) {}
|
||||||
|
});
|
||||||
|
// 攔截分頁 <select> (快速跳頁 & 每頁筆數)
|
||||||
|
container.querySelectorAll('select[onchange]').forEach(sel => {
|
||||||
|
const origOnchange = sel.getAttribute('onchange');
|
||||||
|
sel.removeAttribute('onchange');
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const val = sel.value;
|
||||||
|
try {
|
||||||
|
if (val.startsWith('http') || val.startsWith('/')) {
|
||||||
|
const url = new URL(val, window.location.origin);
|
||||||
|
const page = url.searchParams.get('page') || 1;
|
||||||
|
const perPage = url.searchParams.get('per_page') || '';
|
||||||
|
let extra = `&page=${page}`;
|
||||||
|
if (perPage) extra += `&per_page=${perPage}`;
|
||||||
|
this.searchInTab(tabName, extra);
|
||||||
|
} else if (origOnchange && origOnchange.includes('per_page')) {
|
||||||
|
this.searchInTab(tabName, `&per_page=${val}`);
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
if (origOnchange) new Function(origOnchange).call(sel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
// 觸發頂部進度條
|
||||||
|
this.$watch('tabLoading', (val) => {
|
||||||
|
const bar = document.getElementById('top-loading-bar');
|
||||||
|
if (bar) {
|
||||||
|
if (val) bar.classList.add('loading');
|
||||||
|
else bar.classList.remove('loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 首次載入時綁定每個 Tab 的分頁連結
|
||||||
|
this.$nextTick(() => {
|
||||||
|
['machines', 'models', 'permissions'].forEach(t => {
|
||||||
|
const ref = this.$refs[t + 'Content'];
|
||||||
|
if (ref) this.bindPaginationLinks(ref, t);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Tab 切換時同步 URL
|
||||||
|
this.$watch('tab', (newTab) => {
|
||||||
|
history.pushState({}, '', `{{ route('admin.basic-settings.machines.index') }}?tab=${newTab}`);
|
||||||
|
});
|
||||||
|
// 瀏覽器上一頁/下一頁
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
this.tab = url.searchParams.get('tab') || 'machines';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}" @execute-regenerate.window="executeRegeneration($event.detail)">
|
}" @execute-regenerate.window="executeRegeneration($event.detail)">
|
||||||
<!-- 1. Header Area -->
|
<!-- 1. Header Area -->
|
||||||
@@ -197,417 +311,86 @@
|
|||||||
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Management of operational parameters and models') }}</p>
|
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Management of operational parameters and models') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@if($tab === 'machines')
|
<button x-show="tab === 'machines'" x-cloak @click="showCreateMachineModal = true" class="btn-luxury-primary flex items-center gap-2">
|
||||||
<button @click="showCreateMachineModal = true" class="btn-luxury-primary flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ __('Add Machine') }}</span>
|
<span>{{ __('Add Machine') }}</span>
|
||||||
</button>
|
</button>
|
||||||
@elseif($tab === 'models')
|
<button x-show="tab === 'models'" x-cloak @click="showCreateModelModal = true" class="btn-luxury-primary flex items-center gap-2">
|
||||||
<button @click="showCreateModelModal = true" class="btn-luxury-primary flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ __('Add Machine Model') }}</span>
|
<span>{{ __('Add Machine Model') }}</span>
|
||||||
</button>
|
</button>
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<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">
|
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">
|
||||||
<a href="{{ route('admin.basic-settings.machines.index', ['tab' => 'machines']) }}"
|
<button @click="tab = 'machines'"
|
||||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'machines' ? '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="tab === 'machines' ? '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">
|
||||||
{{ __('Machines') }}
|
{{ __('Machines') }}
|
||||||
</a>
|
</button>
|
||||||
<a href="{{ route('admin.basic-settings.machines.index', ['tab' => 'models']) }}"
|
<button @click="tab = 'models'"
|
||||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'models' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
|
:class="tab === 'models' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
|
||||||
|
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all">
|
||||||
{{ __('Models') }}
|
{{ __('Models') }}
|
||||||
</a>
|
</button>
|
||||||
<a href="{{ route('admin.basic-settings.machines.index', ['tab' => 'permissions']) }}"
|
<button @click="tab = 'permissions'"
|
||||||
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'permissions' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
|
:class="tab === 'permissions' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
|
||||||
|
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all">
|
||||||
{{ __('Machine Permissions') }}
|
{{ __('Machine Permissions') }}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. Main Content Card -->
|
<!-- 2. Main Content Card -->
|
||||||
<div class="luxury-card rounded-3xl p-8 animate-luxury-in mt-6">
|
<div class="luxury-card rounded-3xl p-8 animate-luxury-in mt-6 relative overflow-hidden">
|
||||||
<!-- Toolbar & Filters -->
|
<!-- Spinner Overlay -->
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div x-show="tabLoading"
|
||||||
<div class="flex items-center gap-4">
|
x-transition:enter="transition ease-out duration-300"
|
||||||
<form method="GET" action="{{ route('admin.basic-settings.machines.index') }}" class="relative group">
|
x-transition:enter-start="opacity-0"
|
||||||
<input type="hidden" name="tab" value="{{ $tab }}">
|
x-transition:enter-end="opacity-100"
|
||||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
|
x-transition:leave="transition ease-in duration-200"
|
||||||
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
|
x-transition:leave-start="opacity-100"
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
x-transition:leave-end="opacity-0"
|
||||||
stroke-linejoin="round">
|
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center" x-cloak>
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
|
||||||
|
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
|
||||||
|
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
|
||||||
<input type="text" name="search" value="{{ request('search') }}"
|
|
||||||
placeholder="{{ $tab === 'machines' ? __('Search machines...') : ($tab === 'models' ? __('Search models...') : __('Search accounts...')) }}"
|
|
||||||
class="luxury-input py-2.5 pl-12 pr-6 block w-64">
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@if($tab === 'permissions' && auth()->user()->isSystemAdmin())
|
|
||||||
<div class="w-72">
|
|
||||||
<form method="GET" action="{{ route('admin.basic-settings.machines.index') }}">
|
|
||||||
<input type="hidden" name="tab" value="permissions">
|
|
||||||
<input type="hidden" name="search" value="{{ request('search') }}">
|
|
||||||
<x-searchable-select name="company_id" :options="$companies" :selected="request('company_id')"
|
|
||||||
:placeholder="__('All Companies')" onchange="this.form.submit()" />
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
</div>
|
||||||
|
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">{{ __('Loading Data') }}...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="tabLoading ? 'opacity-30 pointer-events-none transition-opacity duration-300' : 'transition-opacity duration-300'">
|
||||||
|
<!-- Machines Tab -->
|
||||||
|
<div x-show="tab === 'machines'" x-cloak>
|
||||||
|
<div x-ref="machinesContent">
|
||||||
|
@include('admin.basic-settings.machines.partials.tab-machines')
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($tab === 'machines')
|
<!-- Models Tab -->
|
||||||
<!-- Machine Table -->
|
<div x-show="tab === 'models'" x-cloak>
|
||||||
<div class="overflow-x-auto">
|
<div x-ref="modelsContent">
|
||||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
@include('admin.basic-settings.machines.partials.tab-models')
|
||||||
<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 Info') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
|
||||||
{{ __('Machine Model') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
|
||||||
{{ __('Status') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
|
||||||
{{ __('Card Reader') }}</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">
|
|
||||||
{{ __('Owner') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">
|
|
||||||
{{ __('Action') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
|
||||||
@forelse($machines as $machine)
|
|
||||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
|
||||||
<td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300 overflow-hidden">
|
|
||||||
@if(isset($machine->image_urls[0]))
|
|
||||||
<img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover">
|
|
||||||
@else
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
stroke-width="2.5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="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>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
|
|
||||||
{{ $machine->name }}</div>
|
|
||||||
<div class="flex items-center gap-2 mt-0.5">
|
|
||||||
<span
|
|
||||||
class="text-xs font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{
|
|
||||||
$machine->serial_no }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
|
|
||||||
<span
|
|
||||||
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
|
|
||||||
{{ $machine->machineModel->name ?? '--' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6">
|
|
||||||
@php
|
|
||||||
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInSeconds() < 30;
|
|
||||||
@endphp <div class="flex items-center gap-2.5">
|
|
||||||
<div class="relative flex h-2.5 w-2.5">
|
|
||||||
@if($isOnline)
|
|
||||||
<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.5 w-2.5 bg-emerald-500"></span>
|
|
||||||
@else
|
|
||||||
<span
|
|
||||||
class="relative inline-flex rounded-full h-2.5 w-2.5 bg-slate-300 dark:bg-slate-600"></span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-xs font-bold uppercase tracking-wider {{ $isOnline ? 'text-emerald-500' : 'text-slate-500 dark:text-slate-400' }}">
|
|
||||||
{{ $isOnline ? __('Online') : __('Offline') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6">
|
|
||||||
<div class="text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
||||||
{{ $machine->card_reader_seconds ?? 0 }}s <span
|
|
||||||
class="text-slate-300 dark:text-slate-700 mx-1.5">/</span> <span
|
|
||||||
class="text-xs text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest">No.{{
|
|
||||||
$machine->card_reader_no ?? '--' }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6">
|
|
||||||
<span
|
|
||||||
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
|
|
||||||
{{ $machine->company->name ?? __('System') }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-right flex items-center justify-end gap-2">
|
|
||||||
<button @click="openMaintenanceQr(@js($machine->only(['name', 'serial_no'])))"
|
|
||||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-transparent hover:border-emerald-500/20 transition-all inline-flex group/btn"
|
|
||||||
title="{{ __('Maintenance QR Code') }}">
|
|
||||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button @click="openPhotoModal(@js($machine->only(['id', 'name', 'image_urls'])))"
|
|
||||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn"
|
|
||||||
title="{{ __('Machine Images') }}">
|
|
||||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="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>
|
|
||||||
</button>
|
|
||||||
<a href="{{ route('admin.basic-settings.machines.edit', $machine) }}"
|
|
||||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn"
|
|
||||||
title="{{ __('Edit Settings') }}">
|
|
||||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
@click="openDetail(@js($machine->only(['name', 'serial_no', 'status', 'location', 'last_heartbeat_at', 'card_reader_no', 'card_reader_seconds', 'firmware_version', 'api_token', 'heating_start_time', 'heating_end_time', 'image_urls'])))"
|
|
||||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn"
|
|
||||||
title="{{ __('View Details') }}">
|
|
||||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr>
|
|
||||||
<td colspan="5"
|
|
||||||
class="px-6 py-20 text-center text-slate-500 dark:text-slate-400 font-bold tracking-widest uppercase">
|
|
||||||
{{ __('No data available') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
|
||||||
{{ $machines->appends(['tab' => 'machines'])->links('vendor.pagination.luxury') }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@elseif($tab === 'permissions')
|
<!-- Permissions Tab -->
|
||||||
<!-- Permissions Table -->
|
<div x-show="tab === 'permissions'" x-cloak>
|
||||||
<div class="overflow-x-auto">
|
<div x-ref="permissionsContent">
|
||||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
@include('admin.basic-settings.machines.partials.tab-permissions')
|
||||||
<thead>
|
|
||||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
|
||||||
{{ __('Account Info') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-left">
|
|
||||||
{{ __('Company Name') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
|
|
||||||
{{ __('Authorized Machines') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
|
|
||||||
{{ __('Action') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
|
||||||
@forelse($users_list as $user)
|
|
||||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
|
||||||
<td class="px-6 py-6 font-display text-left">
|
|
||||||
<div class="flex items-center gap-4 text-left">
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
|
|
||||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
|
||||||
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col text-left">
|
|
||||||
<span
|
|
||||||
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{
|
|
||||||
$user->name }}</span>
|
|
||||||
<span
|
|
||||||
class="text-xs font-mono font-bold text-slate-500 tracking-widest uppercase">{{
|
|
||||||
$user->username }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-left">
|
|
||||||
<span
|
|
||||||
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest uppercase">
|
|
||||||
{{ $user->company->name ?? __('System') }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-center">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap gap-2 justify-center lg:justify-start max-w-[420px] mx-auto lg:mx-0 max-h-[140px] overflow-y-auto pr-2 custom-scrollbar py-1 text-left">
|
|
||||||
@forelse($user->machines as $m)
|
|
||||||
<div
|
|
||||||
class="flex flex-col px-3 py-1.5 rounded-xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-white/5 hover:border-cyan-500/30 transition-all duration-300 text-left">
|
|
||||||
<span class="text-[11px] font-black text-slate-700 dark:text-slate-200 leading-tight">{{
|
|
||||||
$m->name }}</span>
|
|
||||||
<span class="text-[9px] font-mono font-bold text-cyan-500 tracking-tighter mt-0.5 opacity-80">{{
|
|
||||||
$m->serial_no }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
@empty
|
|
||||||
<div class="w-full text-center lg:text-left">
|
|
||||||
<span
|
|
||||||
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest opacity-40 italic">--
|
|
||||||
{{ __('None') }} --</span>
|
|
||||||
</div>
|
</div>
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-right">
|
|
||||||
<button
|
|
||||||
@click='openPermissionModal({{ json_encode(["id" => $user->id, "name" => $user->name]) }})'
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500 hover:text-white transition-all duration-300 text-xs font-black uppercase tracking-widest shadow-sm shadow-cyan-500/5 group/auth">
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
|
||||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 00-2 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
<span>{{ __('Authorize') }}</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="px-6 py-24 text-center">
|
|
||||||
<div class="flex flex-col items-center gap-3 opacity-20">
|
|
||||||
<svg class="size-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
||||||
d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2m16-10a4 4 0 11-8 0 4 4 0 018 0zM23 21v-2a4 4 0 00-3-3.87m-4-12a4 4 0 010 7.75" />
|
|
||||||
</svg>
|
|
||||||
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No accounts found') }}</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6 text-left mb-6">
|
|
||||||
@if($users_list)
|
|
||||||
{{ $users_list->appends(['tab' => 'permissions'])->links('vendor.pagination.luxury') }}
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@else
|
|
||||||
<!-- Model Table -->
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
|
|
||||||
{{ __('Model Name') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
|
|
||||||
{{ __('Machine Count') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
|
|
||||||
{{ __('Last Updated') }}</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">
|
|
||||||
{{ __('Action') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
|
||||||
@forelse($models_list as $model)
|
|
||||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
|
||||||
<td class="px-6 py-6">
|
|
||||||
<div class="flex items-center gap-x-3">
|
|
||||||
<div
|
|
||||||
class="flex-shrink-0 w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
|
|
||||||
{{ $model->name }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6">
|
|
||||||
<span
|
|
||||||
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
|
|
||||||
{{ $model->machines_count ?? 0 }} {{ __('Items') }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6">
|
|
||||||
<div
|
|
||||||
class="text-xs font-black text-slate-400 dark:text-slate-400/80 uppercase tracking-widest leading-none">
|
|
||||||
{{ $model->updated_at->format('Y/m/d H:i') }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-right space-x-2">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<button @click="currentModel = @js($model->only(['name'])); modelActionUrl = '{{ route('admin.basic-settings.machine-models.update', $model) }}'; showEditModelModal = true"
|
|
||||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 dark:hover:text-cyan-400 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all group/btn"
|
|
||||||
title="{{ __('Edit') }}">
|
|
||||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<form :id="'delete-model-form-' + {{ $model->id }}"
|
|
||||||
action="{{ route('admin.basic-settings.machine-models.destroy', $model) }}"
|
|
||||||
method="POST" class="inline">
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button type="button"
|
|
||||||
@click="confirmDelete('{{ route('admin.basic-settings.machine-models.destroy', $model) }}')"
|
|
||||||
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 dark:hover:text-rose-400 hover:bg-rose-500/5 dark:hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition-all"
|
|
||||||
title="{{ __('Delete') }}">
|
|
||||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr>
|
|
||||||
<td colspan="4"
|
|
||||||
class="px-6 py-20 text-center text-slate-500 dark:text-slate-400 font-bold tracking-widest uppercase">
|
|
||||||
{{ __('No data available') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
|
||||||
{{ $models_list->appends(['tab' => 'models'])->links('vendor.pagination.luxury') }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modals & Drawers -->
|
<!-- Modals & Drawers -->
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
{{-- Machines Tab Content (Partial) --}}
|
||||||
|
{{-- 此檔案被 index.blade.php 的 @include 和 AJAX 模式共用 --}}
|
||||||
|
|
||||||
|
{{-- Toolbar 區:搜尋框 --}}
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<form @submit.prevent="searchInTab('machines')" 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="machineSearch"
|
||||||
|
placeholder="{{ __('Search machines...') }}"
|
||||||
|
class="luxury-input py-2.5 pl-12 pr-6 block w-64">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Machine Table --}}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
||||||
|
{{ __('Machine Info') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
||||||
|
{{ __('Machine Model') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
||||||
|
{{ __('Status') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
||||||
|
{{ __('Card Reader') }}</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">
|
||||||
|
{{ __('Owner') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">
|
||||||
|
{{ __('Action') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||||
|
@forelse($machines as $machine)
|
||||||
|
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||||
|
<td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300 overflow-hidden">
|
||||||
|
@if(isset($machine->image_urls[0]))
|
||||||
|
<img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover">
|
||||||
|
@else
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="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>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
|
||||||
|
{{ $machine->name }}</div>
|
||||||
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
|
<span
|
||||||
|
class="text-xs font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{
|
||||||
|
$machine->serial_no }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
|
||||||
|
<span
|
||||||
|
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
|
||||||
|
{{ $machine->machineModel->name ?? '--' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6">
|
||||||
|
@php
|
||||||
|
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInSeconds() < 30;
|
||||||
|
@endphp <div class="flex items-center gap-2.5">
|
||||||
|
<div class="relative flex h-2.5 w-2.5">
|
||||||
|
@if($isOnline)
|
||||||
|
<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.5 w-2.5 bg-emerald-500"></span>
|
||||||
|
@else
|
||||||
|
<span
|
||||||
|
class="relative inline-flex rounded-full h-2.5 w-2.5 bg-slate-300 dark:bg-slate-600"></span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-xs font-bold uppercase tracking-wider {{ $isOnline ? 'text-emerald-500' : 'text-slate-500 dark:text-slate-400' }}">
|
||||||
|
{{ $isOnline ? __('Online') : __('Offline') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6">
|
||||||
|
<div class="text-sm font-bold text-slate-700 dark:text-slate-200">
|
||||||
|
{{ $machine->card_reader_seconds ?? 0 }}s <span
|
||||||
|
class="text-slate-300 dark:text-slate-700 mx-1.5">/</span> <span
|
||||||
|
class="text-xs text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest">No.{{
|
||||||
|
$machine->card_reader_no ?? '--' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6">
|
||||||
|
<span
|
||||||
|
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
|
||||||
|
{{ $machine->company->name ?? __('System') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-right flex items-center justify-end gap-2">
|
||||||
|
<button @click="openMaintenanceQr(@js($machine->only(['name', 'serial_no'])))"
|
||||||
|
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 dark:hover:bg-emerald-500/10 border border-transparent hover:border-emerald-500/20 transition-all inline-flex group/btn"
|
||||||
|
title="{{ __('Maintenance QR Code') }}">
|
||||||
|
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h.75v.75h-.75v-.75z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="openPhotoModal(@js($machine->only(['id', 'name', 'image_urls'])))"
|
||||||
|
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn"
|
||||||
|
title="{{ __('Machine Images') }}">
|
||||||
|
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="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>
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.basic-settings.machines.edit', $machine) }}"
|
||||||
|
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn"
|
||||||
|
title="{{ __('Edit Settings') }}">
|
||||||
|
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
@click="openDetail(@js($machine->only(['name', 'serial_no', 'status', 'location', 'last_heartbeat_at', 'card_reader_no', 'card_reader_seconds', 'firmware_version', 'api_token', 'heating_start_time', 'heating_end_time', 'image_urls'])))"
|
||||||
|
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn"
|
||||||
|
title="{{ __('View Details') }}">
|
||||||
|
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="5"
|
||||||
|
class="px-6 py-20 text-center text-slate-500 dark:text-slate-400 font-bold tracking-widest uppercase">
|
||||||
|
{{ __('No data available') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
||||||
|
{{ $machines->appends(['tab' => 'machines'])->links('vendor.pagination.luxury') }}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{{-- Models Tab Content (Partial) --}}
|
||||||
|
{{-- 此檔案被 index.blade.php 的 @include 和 AJAX 模式共用 --}}
|
||||||
|
|
||||||
|
{{-- Toolbar 區:搜尋框 --}}
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<form @submit.prevent="searchInTab('models')" 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="modelSearch"
|
||||||
|
placeholder="{{ __('Search models...') }}"
|
||||||
|
class="luxury-input py-2.5 pl-12 pr-6 block w-64">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Model Table --}}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
|
||||||
|
{{ __('Model Name') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
|
||||||
|
{{ __('Machine Count') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">
|
||||||
|
{{ __('Last Updated') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">
|
||||||
|
{{ __('Action') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||||
|
@forelse($models_list as $model)
|
||||||
|
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||||
|
<td class="px-6 py-6">
|
||||||
|
<div class="flex items-center gap-x-3">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
|
||||||
|
{{ $model->name }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6">
|
||||||
|
<span
|
||||||
|
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
|
||||||
|
{{ $model->machines_count ?? 0 }} {{ __('Items') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6">
|
||||||
|
<div
|
||||||
|
class="text-xs font-black text-slate-400 dark:text-slate-400/80 uppercase tracking-widest leading-none">
|
||||||
|
{{ $model->updated_at->format('Y/m/d H:i') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-right space-x-2">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button @click="currentModel = @js($model->only(['name'])); modelActionUrl = '{{ route('admin.basic-settings.machine-models.update', $model) }}'; showEditModelModal = true"
|
||||||
|
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 dark:hover:text-cyan-400 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all group/btn"
|
||||||
|
title="{{ __('Edit') }}">
|
||||||
|
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form :id="'delete-model-form-' + {{ $model->id }}"
|
||||||
|
action="{{ route('admin.basic-settings.machine-models.destroy', $model) }}"
|
||||||
|
method="POST" class="inline">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="button"
|
||||||
|
@click="confirmDelete('{{ route('admin.basic-settings.machine-models.destroy', $model) }}')"
|
||||||
|
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 dark:hover:text-rose-400 hover:bg-rose-500/5 dark:hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition-all"
|
||||||
|
title="{{ __('Delete') }}">
|
||||||
|
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4"
|
||||||
|
class="px-6 py-20 text-center text-slate-500 dark:text-slate-400 font-bold tracking-widest uppercase">
|
||||||
|
{{ __('No data available') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
|
||||||
|
{{ $models_list->appends(['tab' => 'models'])->links('vendor.pagination.luxury') }}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
{{-- Permissions Tab Content (Partial) --}}
|
||||||
|
{{-- 此檔案被 index.blade.php 的 @include 和 AJAX 模式共用 --}}
|
||||||
|
|
||||||
|
{{-- Toolbar 區:搜尋框 + 公司篩選 --}}
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<form @submit.prevent="searchInTab('permissions')" 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="permissionSearch"
|
||||||
|
placeholder="{{ __('Search accounts...') }}"
|
||||||
|
class="luxury-input py-2.5 pl-12 pr-6 block w-64">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if(auth()->user()->isSystemAdmin())
|
||||||
|
<div class="w-72">
|
||||||
|
<x-searchable-select name="company_filter" :options="$companies" :selected="request('company_id')"
|
||||||
|
:placeholder="__('All Companies')"
|
||||||
|
x-on:change="permissionCompanyId = $event.target.value; searchInTab('permissions')" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Permissions Table --}}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
|
||||||
|
{{ __('Account Info') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-left">
|
||||||
|
{{ __('Company Name') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
|
||||||
|
{{ __('Authorized Machines') }}</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
|
||||||
|
{{ __('Action') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||||
|
@forelse($users_list ?? [] as $user)
|
||||||
|
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||||
|
<td class="px-6 py-6 font-display text-left">
|
||||||
|
<div class="flex items-center gap-4 text-left">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
|
||||||
|
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
||||||
|
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col text-left">
|
||||||
|
<span
|
||||||
|
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{
|
||||||
|
$user->name }}</span>
|
||||||
|
<span
|
||||||
|
class="text-xs font-mono font-bold text-slate-500 tracking-widest uppercase">{{
|
||||||
|
$user->username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-left">
|
||||||
|
<span
|
||||||
|
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest uppercase">
|
||||||
|
{{ $user->company->name ?? __('System') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-2 justify-center lg:justify-start max-w-[420px] mx-auto lg:mx-0 max-h-[140px] overflow-y-auto pr-2 custom-scrollbar py-1 text-left">
|
||||||
|
@forelse($user->machines as $m)
|
||||||
|
<div
|
||||||
|
class="flex flex-col px-3 py-1.5 rounded-xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-white/5 hover:border-cyan-500/30 transition-all duration-300 text-left">
|
||||||
|
<span class="text-[11px] font-black text-slate-700 dark:text-slate-200 leading-tight">{{
|
||||||
|
$m->name }}</span>
|
||||||
|
<span class="text-[9px] font-mono font-bold text-cyan-500 tracking-tighter mt-0.5 opacity-80">{{
|
||||||
|
$m->serial_no }}</span>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="w-full text-center lg:text-left">
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest opacity-40 italic">--
|
||||||
|
{{ __('None') }} --</span>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-right">
|
||||||
|
<button
|
||||||
|
@click='openPermissionModal({{ json_encode(["id" => $user->id, "name" => $user->name]) }})'
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500 hover:text-white transition-all duration-300 text-xs font-black uppercase tracking-widest shadow-sm shadow-cyan-500/5 group/auth">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 00-2 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ __('Authorize') }}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-24 text-center">
|
||||||
|
<div class="flex flex-col items-center gap-3 opacity-20">
|
||||||
|
<svg class="size-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2m16-10a4 4 0 11-8 0 4 4 0 018 0zM23 21v-2a4 4 0 00-3-3.87m-4-12a4 4 0 010 7.75" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No accounts found') }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6 text-left mb-6">
|
||||||
|
@if($users_list)
|
||||||
|
{{ $users_list->appends(['tab' => 'permissions'])->links('vendor.pagination.luxury') }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -17,7 +17,7 @@ $roleSelectConfig = [
|
|||||||
@section('content')
|
@section('content')
|
||||||
<div class="space-y-2 pb-20"
|
<div class="space-y-2 pb-20"
|
||||||
x-data="productManager"
|
x-data="productManager"
|
||||||
data-categories="{{ json_encode($categories) }}"
|
data-categories="{{ json_encode($categories->items()) }}"
|
||||||
data-settings="{{ json_encode($companySettings) }}"
|
data-settings="{{ json_encode($companySettings) }}"
|
||||||
data-errors="{{ json_encode($errors->any()) }}"
|
data-errors="{{ json_encode($errors->any()) }}"
|
||||||
data-store-url="{{ route($baseRoute . '.store') }}"
|
data-store-url="{{ route($baseRoute . '.store') }}"
|
||||||
@@ -69,222 +69,72 @@ $roleSelectConfig = [
|
|||||||
|
|
||||||
<!-- Tab Contents -->
|
<!-- Tab Contents -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|
||||||
|
|
||||||
<!-- Products Tab -->
|
<!-- Products Tab -->
|
||||||
<div x-show="activeTab === 'products'" class="luxury-card rounded-3xl p-8 animate-luxury-in" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4" x-transition:enter-end="opacity-100 translate-y-0" x-cloak>
|
<div x-show="activeTab === 'products'"
|
||||||
<!-- Filters & Search -->
|
class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]"
|
||||||
<form action="{{ route($routeName) }}" method="GET" class="flex flex-col md:flex-row md:items-center gap-4 mb-10">
|
x-transition:enter="transition ease-out duration-300"
|
||||||
<div class="relative group">
|
x-transition:enter-start="opacity-0 translate-y-4"
|
||||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
x-cloak>
|
||||||
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
||||||
|
<!-- Loading Overlay (Products) -->
|
||||||
|
<div x-show="tabLoading === 'products'" x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center rounded-3xl"
|
||||||
|
x-cloak>
|
||||||
|
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
|
||||||
|
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
|
||||||
|
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-14v10l-8-4m16 4l-8 4m0-10l-8 4" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</div>
|
||||||
<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 products...') }}">
|
</div>
|
||||||
|
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
|
||||||
|
{{ __('Loading Data') }}...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(auth()->user()->isSystemAdmin())
|
<div id="tab-products-container" class="relative">
|
||||||
<div class="relative min-w-[200px]">
|
@include('admin.products.partials.tab-products')
|
||||||
<x-searchable-select name="company_id" :options="$companies" :selected="request('company_id')" :placeholder="__('All Companies')" onchange="this.form.submit()" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Product Info') }}</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Barcode') }}</th>
|
|
||||||
@if(auth()->user()->isSystemAdmin())
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Company') }}</th>
|
|
||||||
@endif
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Sale Price') }}</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">{{ __('Channel Limits (Track/Spring)') }}</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
|
||||||
@forelse($products as $product)
|
|
||||||
<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 group/info" @click="viewProductDetail(@js($product))" title="{{ __('View Details') }}">
|
|
||||||
<div class="flex items-center gap-x-4">
|
|
||||||
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover/info:bg-cyan-500 group-hover/info:text-white group-hover/info:border-cyan-500 shadow-sm group-hover/info:shadow-cyan-500/50 transition-all duration-300 overflow-hidden">
|
|
||||||
@if($product->image_url)
|
|
||||||
<img src="{{ $product->image_url }}" class="w-full h-full object-cover">
|
|
||||||
@else
|
|
||||||
<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="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover/info:text-cyan-600 dark:group-hover/info:text-cyan-400 transition-colors">{{ $product->localized_name }}</span>
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5 mt-1">
|
|
||||||
@php
|
|
||||||
$catName = $product->category->localized_name ?? __('Uncategorized');
|
|
||||||
@endphp
|
|
||||||
<span class="text-[10px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded transition-colors group-hover/info:text-slate-600 dark:group-hover/info:text-slate-300">{{ $catName }}</span>
|
|
||||||
@if(($companySettings['enable_material_code'] ?? false) && isset($product->metadata['material_code']))
|
|
||||||
<span class="text-[10px] font-bold text-emerald-500/80 uppercase tracking-widest bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">#{{ $product->metadata['material_code'] }}</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 whitespace-nowrap">
|
|
||||||
<span class="text-sm font-mono font-black text-slate-700 dark:text-slate-200 tracking-tight">{{ $product->barcode ?: '-' }}</span>
|
|
||||||
</td>
|
|
||||||
@if(auth()->user()->isSystemAdmin())
|
|
||||||
<td class="px-6 py-6 text-center">
|
|
||||||
<span class="text-xs font-bold text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-200 transition-colors">{{ $product->company->name ?? '-' }}</span>
|
|
||||||
</td>
|
|
||||||
@endif
|
|
||||||
<td class="px-6 py-6 text-center whitespace-nowrap">
|
|
||||||
<span class="text-sm font-black text-slate-800 dark:text-white">${{ number_format($product->price, 0) }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-center whitespace-nowrap">
|
|
||||||
<span class="text-sm font-black text-indigo-500 dark:text-indigo-400">{{ $product->track_limit }}</span>
|
|
||||||
<span class="text-xs font-bold text-slate-400 mx-1">/</span>
|
|
||||||
<span class="text-sm font-black text-amber-500 dark:text-amber-400">{{ $product->spring_limit }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-center">
|
|
||||||
@if($product->is_active)
|
|
||||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Active') }}</span>
|
|
||||||
@else
|
|
||||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
|
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-right">
|
|
||||||
<div class="flex justify-end items-center gap-2">
|
|
||||||
@if($product->is_active)
|
|
||||||
<button type="button"
|
|
||||||
@click="toggleFormAction = '{{ route($baseRoute . '.status.toggle', $product->id) }}'; isStatusConfirmOpen = true"
|
|
||||||
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 transition-all border border-transparent hover:border-amber-500/20"
|
|
||||||
title="{{ __('Disable') }}">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" /></svg>
|
|
||||||
</button>
|
|
||||||
@else
|
|
||||||
<button type="button"
|
|
||||||
@click="toggleFormAction = '{{ route($baseRoute . '.status.toggle', $product->id) }}'; $nextTick(() => $refs.statusToggleForm.submit())"
|
|
||||||
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20"
|
|
||||||
title="{{ __('Enable') }}">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347c-.75.412-1.667-.13-1.667-.986V5.653z" /></svg>
|
|
||||||
</button>
|
|
||||||
@endif
|
|
||||||
<a href="{{ route($baseRoute . '.edit', $product->id) }}" 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="{{ __('Edit') }}">
|
|
||||||
<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>
|
|
||||||
</a>
|
|
||||||
<button type="button" @click="confirmDelete('{{ route($baseRoute . '.destroy', $product->id) }}')" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Delete') }}">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" @click="viewProductDetail(@js($product))" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-indigo-500 hover:bg-indigo-500/5 transition-all border border-transparent hover:border-indigo-500/20" title="{{ __('View Details') }}">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.644C3.67 8.5 7.652 5 12 5c4.418 0 8.401 3.5 10.014 6.722a1.012 1.012 0 010 .644C20.33 15.5 16.348 19 12 19c-4.412 0-8.401-3.5-10.014-6.722z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="px-6 py-32 text-center text-slate-400 italic">{{ __('No products found matching your criteria.') }}</td>
|
|
||||||
</tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="mt-8">
|
|
||||||
{{ $products->links() }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Tab -->
|
<!-- Category Tab -->
|
||||||
<div x-show="activeTab === 'categories'" class="luxury-card rounded-3xl p-8 animate-luxury-in" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4" x-transition:enter-end="opacity-100 translate-y-0" x-cloak>
|
<div x-show="activeTab === 'categories'"
|
||||||
<div class="overflow-x-auto">
|
class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]"
|
||||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
x-transition:enter="transition ease-out duration-300"
|
||||||
<thead>
|
x-transition:enter-start="opacity-0 translate-y-4"
|
||||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
<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">{{ __('Category Name') }}</th>
|
x-cloak>
|
||||||
@if(auth()->user()->isSystemAdmin())
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Company') }}</th>
|
<!-- Loading Overlay (Categories) -->
|
||||||
@endif
|
<div x-show="tabLoading === 'categories'" x-transition:enter="transition ease-out duration-300"
|
||||||
<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>
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
</tr>
|
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
|
||||||
</thead>
|
x-transition:leave-end="opacity-0"
|
||||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center rounded-3xl"
|
||||||
@forelse($categories as $category)
|
x-cloak>
|
||||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
|
||||||
<td class="px-6 py-6">
|
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
|
||||||
<div class="flex items-center gap-x-4">
|
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
|
||||||
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
|
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v13.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V9.432a2.25 2.25 0 00-.659-1.591l-4.182-4.182A2.25 2.25 0 0014.568 3h-5z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h-6m6 4h-6m6-8h-6" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 transition-colors group-hover:text-cyan-600 dark:group-hover:text-cyan-400">
|
|
||||||
{{ $category->localized_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
@if(auth()->user()->isSystemAdmin())
|
|
||||||
<td class="px-6 py-6 text-center">
|
|
||||||
<span class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ $category->company->name ?? __('System Default') }}</span>
|
|
||||||
</td>
|
|
||||||
@endif
|
|
||||||
<td class="px-6 py-6 text-right">
|
|
||||||
<div class="flex justify-end items-center gap-2">
|
|
||||||
<button type="button" @click="openCategoryModal(@js($category))" 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="{{ __('Edit') }}">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" @click="confirmDelete('{{ route('admin.data-config.product-categories.destroy', $category->id) }}')" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Delete') }}">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr class="animate-luxury-in">
|
|
||||||
<td colspan="{{ auth()->user()->isSystemAdmin() ? 3 : 2 }}" class="px-6 py-20 text-center">
|
|
||||||
<div class="flex flex-col items-center justify-center space-y-4">
|
|
||||||
<div class="w-16 h-16 rounded-3xl bg-slate-50 dark:bg-slate-800/50 flex items-center justify-center text-slate-300 dark:text-slate-700 shadow-inner">
|
|
||||||
<svg class="size-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" /></svg>
|
|
||||||
</div>
|
|
||||||
<p class="text-slate-400 font-bold tracking-widest text-sm uppercase">{{ __('No categories found.') }}</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
|
||||||
|
{{ __('Loading Data') }}...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-categories-container" class="relative">
|
||||||
|
@include('admin.products.partials.tab-categories')
|
||||||
<!-- Delete Confirm Modal -->
|
</div>
|
||||||
<x-delete-confirm-modal
|
</div>
|
||||||
:title="__('Delete Product Confirmation')"
|
</div>
|
||||||
:message="__('Are you sure you want to delete this product? All related historical translation data will also be removed.')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Status Toggle Modal -->
|
|
||||||
<x-status-confirm-modal
|
|
||||||
:title="__('Disable Product Confirmation')"
|
|
||||||
:message="__('Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<form x-ref="statusToggleForm" :action="toggleFormAction" method="POST" class="hidden">
|
|
||||||
@csrf
|
|
||||||
@method('PATCH')
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form x-ref="deleteForm" :action="deleteFormAction" method="POST" class="hidden">
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Category Modal -->
|
<!-- Category Modal -->
|
||||||
<div x-show="isCategoryModalOpen" class="fixed inset-0 z-[110] overflow-y-auto" x-cloak>
|
<div x-show="isCategoryModalOpen" class="fixed inset-0 z-[110] overflow-y-auto" x-cloak>
|
||||||
@@ -534,6 +384,21 @@ $roleSelectConfig = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modals -->
|
||||||
|
<x-delete-confirm-modal
|
||||||
|
:title="__('Confirm Deletion')"
|
||||||
|
:message="__('Are you sure you want to delete this product or category? This action cannot be undone.')"
|
||||||
|
/>
|
||||||
|
<x-status-confirm-modal
|
||||||
|
:title="__('Confirm Status Change')"
|
||||||
|
:message="__('Are you sure you want to change the status of this item? This will affect its visibility on vending machines.')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form x-ref="statusToggleForm" :action="toggleFormAction" method="POST" class="hidden">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Image Zoom Modal -->
|
<!-- Image Zoom Modal -->
|
||||||
<div x-show="isImageZoomed"
|
<div x-show="isImageZoomed"
|
||||||
x-transition:enter="ease-out duration-300"
|
x-transition:enter="ease-out duration-300"
|
||||||
@@ -570,28 +435,32 @@ $roleSelectConfig = [
|
|||||||
isDetailOpen: false,
|
isDetailOpen: false,
|
||||||
isImageZoomed: false,
|
isImageZoomed: false,
|
||||||
isStatusConfirmOpen: false,
|
isStatusConfirmOpen: false,
|
||||||
|
isDeleteConfirmOpen: false,
|
||||||
isCategoryModalOpen: false,
|
isCategoryModalOpen: false,
|
||||||
activeTab: '{{ request("tab", "products") }}',
|
activeTab: '{{ request("tab", "products") }}',
|
||||||
|
tabLoading: null,
|
||||||
|
loading: false,
|
||||||
categoryModalMode: 'create',
|
categoryModalMode: 'create',
|
||||||
categoryFormAction: '',
|
categoryFormAction: '',
|
||||||
deleteFormAction: '',
|
deleteFormAction: '',
|
||||||
toggleFormAction: '',
|
toggleFormAction: '',
|
||||||
selectedProduct: null,
|
selectedProduct: null,
|
||||||
categories: [],
|
categories: [],
|
||||||
|
companies: [],
|
||||||
categoryFormFields: {
|
categoryFormFields: {
|
||||||
names: { zh_TW: '', en: '', ja: '' },
|
names: { zh_TW: '', en: '', ja: '' },
|
||||||
company_id: ''
|
company_id: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
submitConfirmedForm() {
|
|
||||||
this.$refs.statusToggleForm.submit();
|
|
||||||
},
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
|
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
|
||||||
this.companies = @js($companies);
|
this.companies = @js($companies);
|
||||||
|
|
||||||
// Watch for category modal opening to sync searchable select
|
// Initial binding
|
||||||
|
this.bindPaginationLinks('#tab-products-container', 'products');
|
||||||
|
this.bindPaginationLinks('#tab-categories-container', 'categories');
|
||||||
|
|
||||||
|
// Watch for category modal updates
|
||||||
this.$watch('isCategoryModalOpen', (value) => {
|
this.$watch('isCategoryModalOpen', (value) => {
|
||||||
if (value && document.getElementById('category_company_select_wrapper')) {
|
if (value && document.getElementById('category_company_select_wrapper')) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -599,6 +468,154 @@ $roleSelectConfig = [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync top loading bar
|
||||||
|
this.$watch('tabLoading', (val) => {
|
||||||
|
const bar = document.getElementById('top-loading-bar');
|
||||||
|
if (bar) {
|
||||||
|
if (val) bar.classList.add('loading');
|
||||||
|
else bar.classList.remove('loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync global loading to top bar as well
|
||||||
|
this.$watch('loading', (val) => {
|
||||||
|
const bar = document.getElementById('top-loading-bar');
|
||||||
|
if (bar) {
|
||||||
|
if (val) bar.classList.add('loading');
|
||||||
|
else if (!this.tabLoading) bar.classList.remove('loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync tab to URL (Clean up parameters from other tabs when switching)
|
||||||
|
this.$watch('activeTab', (val) => {
|
||||||
|
const url = new URL(window.location.origin + window.location.pathname);
|
||||||
|
url.searchParams.set('tab', val);
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchTabData(tab, url = null) {
|
||||||
|
this.tabLoading = tab;
|
||||||
|
|
||||||
|
const container = document.getElementById('tab-' + tab + '-container');
|
||||||
|
|
||||||
|
// If no URL is provided, build one from the current tab's form
|
||||||
|
if (!url) {
|
||||||
|
const form = container?.querySelector('form');
|
||||||
|
// Start with a clean set of parameters, only keeping the current tab
|
||||||
|
let params = new URLSearchParams();
|
||||||
|
params.set('tab', tab);
|
||||||
|
params.set('_ajax', '1');
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (value.trim() !== '') {
|
||||||
|
params.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
url = `${window.location.pathname}?${params.toString()}`;
|
||||||
|
} else {
|
||||||
|
// Ensure URL has tab and _ajax params
|
||||||
|
const urlObj = new URL(url, window.location.origin);
|
||||||
|
urlObj.searchParams.set('tab', tab);
|
||||||
|
urlObj.searchParams.set('_ajax', '1');
|
||||||
|
url = urlObj.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = data.html;
|
||||||
|
|
||||||
|
// Re-init Alpine components in the dynamic content
|
||||||
|
if (window.Alpine) {
|
||||||
|
window.Alpine.initTree(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-bind pagination events
|
||||||
|
this.bindPaginationLinks('#tab-' + tab + '-container', tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update browser URL (without _ajax)
|
||||||
|
const historyUrl = new URL(url, window.location.origin);
|
||||||
|
historyUrl.searchParams.delete('_ajax');
|
||||||
|
window.history.pushState({}, '', historyUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
if (window.showToast) window.showToast('{{ __("Failed to load data") }}', 'error');
|
||||||
|
} finally {
|
||||||
|
this.tabLoading = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleFilterSubmit(tab) {
|
||||||
|
// Clear page when searching
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const pageKey = tab === 'products' ? 'product_page' : 'category_page';
|
||||||
|
url.searchParams.delete(pageKey);
|
||||||
|
|
||||||
|
this.fetchTabData(tab);
|
||||||
|
},
|
||||||
|
|
||||||
|
bindPaginationLinks(containerSelector, tab) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Re-init Preline components (Selects etc.)
|
||||||
|
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
|
||||||
|
window.HSStaticMethods.autoInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Intercept Standard Links
|
||||||
|
container.querySelectorAll('nav a, .pagination a').forEach(link => {
|
||||||
|
// Prevent multiple bindings
|
||||||
|
if (link.dataset.ajaxBound) return;
|
||||||
|
link.dataset.ajaxBound = 'true';
|
||||||
|
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.fetchTabData(tab, link.href);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Intercept Dropdown Changes (Per Page / Page Jump)
|
||||||
|
container.querySelectorAll('select[onchange]').forEach(sel => {
|
||||||
|
const originalOnchange = sel.getAttribute('onchange');
|
||||||
|
if (originalOnchange) {
|
||||||
|
sel.removeAttribute('onchange'); // Prevent default behavior
|
||||||
|
|
||||||
|
sel.addEventListener('change', (e) => {
|
||||||
|
let newUrl;
|
||||||
|
// Simple page jump: value is usually the URL
|
||||||
|
if (sel.value.includes('?')) {
|
||||||
|
newUrl = sel.value;
|
||||||
|
} else {
|
||||||
|
// Per page limit change: needs to build URL
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set('per_page', sel.value);
|
||||||
|
const pageKey = tab === 'products' ? 'product_page' : 'category_page';
|
||||||
|
params.delete(pageKey); // Reset to page 1
|
||||||
|
newUrl = window.location.pathname + '?' + params.toString();
|
||||||
|
}
|
||||||
|
this.fetchTabData(tab, newUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateCategoryCompanySelect() {
|
updateCategoryCompanySelect() {
|
||||||
@@ -627,7 +644,6 @@ $roleSelectConfig = [
|
|||||||
|
|
||||||
selectEl.setAttribute('data-hs-select', JSON.stringify(config));
|
selectEl.setAttribute('data-hs-select', JSON.stringify(config));
|
||||||
|
|
||||||
// Default System option
|
|
||||||
const defaultOpt = document.createElement('option');
|
const defaultOpt = document.createElement('option');
|
||||||
defaultOpt.value = "";
|
defaultOpt.value = "";
|
||||||
defaultOpt.textContent = "{{ __('System Default (Common)') }}";
|
defaultOpt.textContent = "{{ __('System Default (Common)') }}";
|
||||||
@@ -635,7 +651,6 @@ $roleSelectConfig = [
|
|||||||
if (!this.categoryFormFields.company_id) defaultOpt.selected = true;
|
if (!this.categoryFormFields.company_id) defaultOpt.selected = true;
|
||||||
selectEl.appendChild(defaultOpt);
|
selectEl.appendChild(defaultOpt);
|
||||||
|
|
||||||
// Company options
|
|
||||||
this.companies.forEach(company => {
|
this.companies.forEach(company => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = company.id;
|
opt.value = company.id;
|
||||||
@@ -658,10 +673,7 @@ $roleSelectConfig = [
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
confirmDelete(action) {
|
|
||||||
this.deleteFormAction = action;
|
|
||||||
this.isDeleteConfirmOpen = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
viewProductDetail(product) {
|
viewProductDetail(product) {
|
||||||
this.selectedProduct = product;
|
this.selectedProduct = product;
|
||||||
@@ -690,6 +702,84 @@ $roleSelectConfig = [
|
|||||||
this.isCategoryModalOpen = true;
|
this.isCategoryModalOpen = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
confirmDelete(action) {
|
||||||
|
this.deleteFormAction = action;
|
||||||
|
this.isDeleteConfirmOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleStatus(actionUrl) {
|
||||||
|
this.toggleFormAction = actionUrl;
|
||||||
|
this.isStatusConfirmOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitConfirmedForm() {
|
||||||
|
if (this.isStatusConfirmOpen) {
|
||||||
|
this.isStatusConfirmOpen = false;
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.toggleFormAction, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
'_method': 'PATCH'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
await this.fetchTabData(this.activeTab);
|
||||||
|
if (window.showToast) window.showToast(data.message, 'success');
|
||||||
|
} else {
|
||||||
|
if (window.showToast) window.showToast(data.message || 'Error', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Status toggle error:', error);
|
||||||
|
if (window.showToast) window.showToast('{{ __("Operation failed") }}', 'error');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmDelete(actionUrl) {
|
||||||
|
this.deleteFormAction = actionUrl;
|
||||||
|
this.isDeleteConfirmOpen = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteItem() {
|
||||||
|
this.isDeleteConfirmOpen = false;
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.deleteFormAction, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
'_method': 'DELETE'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
await this.fetchTabData(this.activeTab);
|
||||||
|
if (window.showToast) window.showToast(data.message, 'success');
|
||||||
|
} else {
|
||||||
|
if (window.showToast) window.showToast(data.message || 'Error', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete error:', error);
|
||||||
|
if (window.showToast) window.showToast('{{ __("Operation failed") }}', 'error');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getCategoryName(id) {
|
getCategoryName(id) {
|
||||||
const category = this.categories.find(c => c.id == id);
|
const category = this.categories.find(c => c.id == id);
|
||||||
return category ? (category.name || "{{ __('Uncategorized') }}") : "{{ __('Uncategorized') }}";
|
return category ? (category.name || "{{ __('Uncategorized') }}") : "{{ __('Uncategorized') }}";
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<div class="relative">
|
||||||
|
<form @submit.prevent="handleFilterSubmit('categories')" id="categories-filter-form" class="mb-8 flex flex-wrap items-center gap-4">
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="relative group">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-cyan-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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="category_search" value="{{ request('category_search', request('tab') === 'categories' ? request('category_search') : '') }}"
|
||||||
|
class="bg-slate-50 dark:bg-slate-900/50 border-none rounded-xl py-3 pl-10 pr-4 text-sm font-bold text-slate-700 dark:text-slate-200 focus:ring-2 focus:ring-cyan-500/20 w-64 transition-all shadow-sm"
|
||||||
|
placeholder="{{ __('Search categories...') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(auth()->user()->isSystemAdmin())
|
||||||
|
<div class="relative min-w-[200px]">
|
||||||
|
<x-searchable-select name="category_company_id"
|
||||||
|
:options="$companies"
|
||||||
|
:selected="request('category_company_id')"
|
||||||
|
:placeholder="__('All Companies')"
|
||||||
|
@change="handleFilterSubmit('categories')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<input type="hidden" name="tab" value="categories">
|
||||||
|
</form>
|
||||||
|
<div class="overflow-x-auto luxury-scrollbar">
|
||||||
|
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Category Name') }}</th>
|
||||||
|
@if(auth()->user()->isSystemAdmin())
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Company') }}</th>
|
||||||
|
@endif
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||||
|
@forelse($categories as $category)
|
||||||
|
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||||
|
<td class="px-6 py-5">
|
||||||
|
<div class="flex items-center gap-x-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
|
||||||
|
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v13.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V9.432a2.25 2.25 0 00-.659-1.591l-4.182-4.182A2.25 2.25 0 0014.568 3h-5z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h-6m6 4h-6m6-8h-6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 transition-colors group-hover:text-cyan-600 dark:group-hover:text-cyan-400">
|
||||||
|
{{ $category->localized_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
@if(auth()->user()->isSystemAdmin())
|
||||||
|
<td class="px-6 py-5 text-center">
|
||||||
|
<span class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ $category->company->name ?? __('System Default') }}</span>
|
||||||
|
</td>
|
||||||
|
@endif
|
||||||
|
<td class="px-6 py-5 text-right">
|
||||||
|
<div class="flex justify-end items-center gap-2">
|
||||||
|
<button type="button" @click="openCategoryModal(@js($category))" 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="{{ __('Edit') }}">
|
||||||
|
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="confirmDelete('{{ route('admin.data-config.product-categories.destroy', $category->id) }}')" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Delete') }}">
|
||||||
|
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ auth()->user()->isSystemAdmin() ? 3 : 2 }}" class="px-6 py-20 text-center text-slate-400 italic font-bold tracking-widest small-caps">
|
||||||
|
{{ __('No categories found.') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">
|
||||||
|
{{ $categories->links('vendor.pagination.luxury') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
144
resources/views/admin/products/partials/tab-products.blade.php
Normal file
144
resources/views/admin/products/partials/tab-products.blade.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
@php
|
||||||
|
$baseRoute = 'admin.data-config.products';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Filters & Search -->
|
||||||
|
<form @submit.prevent="handleFilterSubmit('products')" id="products-filter-form" class="flex flex-col md:flex-row md:items-center gap-4 mb-6">
|
||||||
|
<div class="relative group">
|
||||||
|
<button type="submit" class="absolute inset-y-0 left-0 flex items-center pl-4 z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
|
||||||
|
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input type="text" name="product_search" value="{{ request('product_search') }}"
|
||||||
|
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search products...') }}">
|
||||||
|
<button type="submit" class="hidden"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(auth()->user()->isSystemAdmin())
|
||||||
|
<div class="relative min-w-[200px]">
|
||||||
|
<x-searchable-select name="product_company_id" :options="$companies" :selected="request('product_company_id')" :placeholder="__('All Companies')" @change="handleFilterSubmit('products')" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<input type="hidden" name="tab" value="products">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="overflow-x-auto luxury-scrollbar">
|
||||||
|
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Product Info') }}</th>
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Barcode') }}</th>
|
||||||
|
@if(auth()->user()->isSystemAdmin())
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Company') }}</th>
|
||||||
|
@endif
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Sale Price') }}</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">{{ __('Track Limit (Track/Spring)') }}</th>
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
|
||||||
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||||
|
@forelse($products as $product)
|
||||||
|
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||||
|
<td class="px-6 py-5 cursor-pointer group/info" @click="viewProductDetail(@js($product))" title="{{ __('View Details') }}">
|
||||||
|
<div class="flex items-center gap-x-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover/info:bg-cyan-500 group-hover/info:text-white group-hover/info:border-cyan-500 shadow-sm group-hover/info:shadow-cyan-500/50 transition-all duration-300 overflow-hidden relative">
|
||||||
|
@if($product->image_url)
|
||||||
|
<img src="{{ $product->image_url }}" class="w-full h-full object-cover">
|
||||||
|
@else
|
||||||
|
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover/info:text-cyan-600 dark:group-hover/info:text-cyan-400 transition-colors leading-tight">{{ $product->localized_name }}</span>
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||||
|
@php
|
||||||
|
$catName = $product->category->localized_name ?? __('Uncategorized');
|
||||||
|
@endphp
|
||||||
|
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.14em] bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200/60 dark:border-slate-700/60 px-2.5 py-1 rounded-lg backdrop-blur-sm transition-all duration-300 group-hover/info:bg-cyan-500/10 group-hover/info:border-cyan-500/20 group-hover/info:text-cyan-500 group-hover/info:shadow-sm group-hover/info:shadow-cyan-500/10">{{ $catName }}</span>
|
||||||
|
@if(($companySettings['enable_material_code'] ?? false) && isset($product->metadata['material_code']))
|
||||||
|
<span class="text-[10px] font-black text-emerald-500/90 uppercase tracking-widest bg-emerald-500/10 px-2 py-0.5 rounded-lg border border-emerald-500/20 shadow-sm shadow-emerald-500/5">#{{ $product->metadata['material_code'] }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5 whitespace-nowrap">
|
||||||
|
<span class="text-sm font-mono font-bold text-slate-600 dark:text-slate-300 tracking-tight">{{ $product->barcode ?: '-' }}</span>
|
||||||
|
</td>
|
||||||
|
@if(auth()->user()->isSystemAdmin())
|
||||||
|
<td class="px-6 py-5 text-center">
|
||||||
|
<span class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest group-hover:text-slate-600 dark:group-hover:text-slate-300 transition-colors">{{ $product->company->name ?? '-' }}</span>
|
||||||
|
</td>
|
||||||
|
@endif
|
||||||
|
<td class="px-6 py-5 text-center whitespace-nowrap">
|
||||||
|
<span class="text-sm font-black text-slate-800 dark:text-white leading-none">${{ number_format($product->price, 0) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5">
|
||||||
|
<div class="flex items-center justify-center gap-2 font-black">
|
||||||
|
<span class="text-base text-indigo-500 dark:text-indigo-400">
|
||||||
|
{{ $product->track_limit ?: 0 }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate-300 dark:text-slate-700">/</span>
|
||||||
|
<span class="text-base text-amber-500 dark:text-amber-500">
|
||||||
|
{{ $product->spring_limit ?: 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5 text-center">
|
||||||
|
@if($product->is_active)
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase shadow-sm shadow-emerald-500/5">{{ __('Active') }}</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-slate-400/10 text-slate-400 border border-slate-400/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-5 text-right">
|
||||||
|
<div class="flex justify-end items-center gap-2">
|
||||||
|
@if($product->is_active)
|
||||||
|
<button type="button"
|
||||||
|
@click="toggleStatus('{{ route($baseRoute . '.status.toggle', $product->id) }}')"
|
||||||
|
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 transition-all border border-transparent hover:border-amber-500/20"
|
||||||
|
title="{{ __('Disable') }}">
|
||||||
|
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" /></svg>
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button type="button"
|
||||||
|
@click="toggleStatus('{{ route($baseRoute . '.status.toggle', $product->id) }}')"
|
||||||
|
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20"
|
||||||
|
title="{{ __('Enable') }}">
|
||||||
|
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347c-.75.412-1.667-.13-1.667-.986V5.653z" /></svg>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route($baseRoute . '.edit', $product->id) }}" 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="{{ __('Edit') }}">
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
<button type="button" @click="confirmDelete('{{ route($baseRoute . '.destroy', $product->id) }}')" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Delete') }}">
|
||||||
|
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="viewProductDetail({{ json_encode($product) }})" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-indigo-500 hover:bg-indigo-500/5 transition-all border border-transparent hover:border-indigo-500/20" title="{{ __('View') }}">
|
||||||
|
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.67 8.5 7.652 6 12 6c4.348 0 8.33 2.5 9.964 5.678.134.263.134.561 0 .824C20.33 15.5 16.348 18 12 18c-4.348 0-8.33-2.5-9.964-5.678Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-20 text-center text-slate-400 italic font-bold tracking-widest small-caps">{{ __('No products found matching your criteria.') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">
|
||||||
|
{{ $products->links('vendor.pagination.luxury') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,15 +2,20 @@
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<script>
|
<script>
|
||||||
window.remoteControlApp = function(initialMachineId) {
|
window.remoteControlApp = function (initialMachineId) {
|
||||||
return {
|
return {
|
||||||
machines: @js($machines),
|
machines: @js($machines),
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
selectedMachine: null,
|
selectedMachine: null,
|
||||||
commands: [],
|
commands: [],
|
||||||
viewMode: initialMachineId ? 'control' : 'history',
|
viewMode: initialMachineId ? 'control' : (
|
||||||
|
['search', 'page', 'date_range', 'command_type', 'status'].some(p => new URLSearchParams(window.location.search).has(p))
|
||||||
|
? 'history' : 'history'
|
||||||
|
),
|
||||||
|
// 預設為 history,篩選條件存在時也維持 history
|
||||||
history: @js($history),
|
history: @js($history),
|
||||||
loading: false,
|
loading: false,
|
||||||
|
tabLoading: null,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
|
|
||||||
// App Config & Meta
|
// App Config & Meta
|
||||||
@@ -63,17 +68,153 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
note: '',
|
note: '',
|
||||||
|
|
||||||
async init() {
|
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
|
// Watch for machine data changes to rebuild slot select
|
||||||
this.$watch('selectedMachine.slots', () => {
|
this.$watch('selectedMachine.slots', () => {
|
||||||
this.$nextTick(() => this.updateSlotSelect());
|
this.$nextTick(() => this.updateSlotSelect());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 首次載入時綁定分頁連結
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.bindPaginationLinks(this.$refs.historyContent, 'history');
|
||||||
|
this.bindPaginationLinks(this.$refs.listContent, 'list');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步頂部進度條
|
||||||
|
this.$watch('tabLoading', (val) => {
|
||||||
|
const bar = document.getElementById('top-loading-bar');
|
||||||
|
if (bar) {
|
||||||
|
if (val) bar.classList.add('loading');
|
||||||
|
else bar.classList.remove('loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialMachineId) {
|
||||||
|
const machine = this.machines.data.find(m => m.id == initialMachineId);
|
||||||
|
if (machine) {
|
||||||
|
await this.selectMachine(machine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 攔截分頁連結與下拉切換 (與庫存模組一致的強健版本)
|
||||||
|
bindPaginationLinks(container, tab) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 處理連結
|
||||||
|
container.querySelectorAll('a[href]').forEach(a => {
|
||||||
|
const href = a.getAttribute('href');
|
||||||
|
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.origin);
|
||||||
|
const pageKey = (tab === 'list') ? 'machine_page' : 'history_page';
|
||||||
|
|
||||||
|
// 確保只有分頁連結被攔截
|
||||||
|
if (!url.searchParams.has(pageKey) || a.closest('td.px-6')) return;
|
||||||
|
|
||||||
|
a.addEventListener('click', (e) => {
|
||||||
|
if (a.title) return; // 略過有 title 的連結 (可能是其他功能)
|
||||||
|
e.preventDefault();
|
||||||
|
const page = url.searchParams.get(pageKey) || 1;
|
||||||
|
const perPage = url.searchParams.get('per_page') || '';
|
||||||
|
let extra = `&${pageKey}=${page}`;
|
||||||
|
if (perPage) extra += `&per_page=${perPage}`;
|
||||||
|
|
||||||
|
const newUrl = new URL(this.appConfig.indexUrl);
|
||||||
|
newUrl.searchParams.set('tab', tab);
|
||||||
|
newUrl.searchParams.set('_ajax', '1');
|
||||||
|
extra.split('&').filter(Boolean).forEach(p => {
|
||||||
|
const [k, v] = p.split('=');
|
||||||
|
newUrl.searchParams.set(k, v);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fetchTabData(tab, newUrl.toString());
|
||||||
|
});
|
||||||
|
} catch (err) { }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 處理下拉切換 (每頁筆數或跳頁)
|
||||||
|
container.querySelectorAll('select[onchange]').forEach(sel => {
|
||||||
|
const origOnchange = sel.getAttribute('onchange');
|
||||||
|
sel.removeAttribute('onchange');
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const val = sel.value;
|
||||||
|
const pageKey = (tab === 'list') ? 'machine_page' : 'history_page';
|
||||||
|
try {
|
||||||
|
if (val.startsWith('http') || val.startsWith('/')) {
|
||||||
|
const url = new URL(val, window.location.origin);
|
||||||
|
const page = url.searchParams.get(pageKey) || 1;
|
||||||
|
const perPage = url.searchParams.get('per_page') || '';
|
||||||
|
let extra = `&${pageKey}=${page}`;
|
||||||
|
if (perPage) extra += `&per_page=${perPage}`;
|
||||||
|
|
||||||
|
const newUrl = new URL(this.appConfig.indexUrl);
|
||||||
|
newUrl.searchParams.set('tab', tab);
|
||||||
|
newUrl.searchParams.set('_ajax', '1');
|
||||||
|
extra.split('&').filter(Boolean).forEach(p => {
|
||||||
|
const [k, v] = p.split('=');
|
||||||
|
newUrl.searchParams.set(k, v);
|
||||||
|
});
|
||||||
|
this.fetchTabData(tab, newUrl.toString());
|
||||||
|
} else if (origOnchange && origOnchange.includes('per_page')) {
|
||||||
|
const newUrl = new URL(this.appConfig.indexUrl);
|
||||||
|
newUrl.searchParams.set('tab', tab);
|
||||||
|
newUrl.searchParams.set('_ajax', '1');
|
||||||
|
newUrl.searchParams.set('per_page', val);
|
||||||
|
this.fetchTabData(tab, newUrl.toString());
|
||||||
|
}
|
||||||
|
} catch (err) { }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchInTab(tab, clear = false) {
|
||||||
|
const form = event?.target?.closest('form');
|
||||||
|
const url = new URL(this.appConfig.indexUrl);
|
||||||
|
url.searchParams.set('tab', tab);
|
||||||
|
url.searchParams.set('_ajax', '1');
|
||||||
|
|
||||||
|
if (!clear && form) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
for (let [key, value] of formData.entries()) {
|
||||||
|
if (value) url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fetchTabData(tab, url.toString());
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchTabData(tab, url) {
|
||||||
|
this.tabLoading = tab;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
const ref = (tab === 'history') ? this.$refs.historyContent : this.$refs.listContent;
|
||||||
|
if (ref) {
|
||||||
|
ref.innerHTML = data.html;
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Alpine.initTree(ref);
|
||||||
|
this.bindPaginationLinks(ref, tab);
|
||||||
|
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
|
||||||
|
setTimeout(() => window.HSStaticMethods.autoInit(), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update browser URL
|
||||||
|
const browserUrl = new URL(url);
|
||||||
|
browserUrl.searchParams.delete('_ajax');
|
||||||
|
window.history.pushState({}, '', browserUrl.toString());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fetch error:', e);
|
||||||
|
} finally {
|
||||||
|
this.tabLoading = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSlotSelect() {
|
updateSlotSelect() {
|
||||||
@@ -86,7 +227,7 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
try {
|
try {
|
||||||
const instance = window.HSSelect.getInstance(oldSelect);
|
const instance = window.HSSelect.getInstance(oldSelect);
|
||||||
if (instance) instance.destroy();
|
if (instance) instance.destroy();
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
wrapper.innerHTML = '';
|
wrapper.innerHTML = '';
|
||||||
|
|
||||||
@@ -219,7 +360,7 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('machine_id', this.selectedMachine.id);
|
formData.append('machine_id', this.selectedMachine?.id);
|
||||||
formData.append('command_type', type);
|
formData.append('command_type', type);
|
||||||
formData.append('note', this.note);
|
formData.append('note', this.note);
|
||||||
|
|
||||||
@@ -265,7 +406,7 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getCommandBadgeClass(status) {
|
getCommandBadgeClass(status) {
|
||||||
switch(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 '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 '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 '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';
|
||||||
@@ -339,17 +480,20 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.command_type === 'dispense' && item.payload) {
|
if (item.command_type === 'dispense' && item.payload) {
|
||||||
return `${this.translations['Slot']}: ${item.payload.slot_no}`;
|
let details = `${this.translations['Slot']} ${item.payload.slot_no}`;
|
||||||
|
if (item.status === 'success' && item.payload.old_stock !== undefined) {
|
||||||
|
details += `: ${this.translations['Stock']} ${item.payload.old_stock} → ${item.payload.new_stock}`;
|
||||||
|
}
|
||||||
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-2 pb-20"
|
<div class="space-y-2 pb-20" x-data="remoteControlApp({{ Js::from($selectedMachine ? $selectedMachine->id : '') }})">
|
||||||
x-data="remoteControlApp({{ Js::from($selectedMachine ? $selectedMachine->id : '') }})">
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<!-- Back Button for Detail/Control Mode -->
|
<!-- Back Button for Detail/Control Mode -->
|
||||||
@@ -373,7 +517,8 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
|
|
||||||
<!-- Tab Navigation (Only visible when not in specific machine control) -->
|
<!-- Tab Navigation (Only visible when not in specific machine control) -->
|
||||||
<template x-if="viewMode !== '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">
|
<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'"
|
<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="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">
|
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
|
||||||
@@ -390,222 +535,30 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|
||||||
<!-- History View: Operation Records -->
|
<!-- History View: Operation Records -->
|
||||||
<template x-if="viewMode === 'history'">
|
<div x-show="viewMode === 'history'" x-cloak>
|
||||||
<div class="space-y-6 animate-luxury-in">
|
<div class="space-y-6 animate-luxury-in">
|
||||||
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
|
<div class="luxury-card rounded-3xl p-8 overflow-hidden relative">
|
||||||
<div class="overflow-x-auto">
|
<div x-ref="historyContent">
|
||||||
<table class="w-full text-left border-separate border-spacing-y-0 text-sm">
|
@include('admin.remote.partials.tab-history-index')
|
||||||
<thead>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Master View: Machine List -->
|
<!-- Master View: Machine List -->
|
||||||
<template x-if="viewMode === 'list'">
|
<div x-show="viewMode === 'list'" x-cloak>
|
||||||
<div class="space-y-6 animate-luxury-in">
|
<div class="space-y-6 animate-luxury-in">
|
||||||
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
|
<div class="luxury-card rounded-3xl p-8 overflow-hidden relative">
|
||||||
<!-- Filters Area -->
|
<div x-ref="listContent">
|
||||||
<div class="flex items-center justify-between mb-8">
|
@include('admin.remote.partials.tab-machines-index')
|
||||||
<div class="relative group">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Detail View: Remote Control Dashboard -->
|
<!-- Detail View: Remote Control Dashboard -->
|
||||||
<template x-if="viewMode === 'control'">
|
<div x-show="viewMode === 'control'" x-cloak>
|
||||||
<div class="space-y-6 animate-luxury-in">
|
<div class="space-y-6 animate-luxury-in">
|
||||||
|
|
||||||
<!-- Dashboard Grid -->
|
<!-- Dashboard Grid -->
|
||||||
@@ -615,20 +568,26 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
<div class="lg:col-span-2 space-y-8">
|
<div class="lg:col-span-2 space-y-8">
|
||||||
|
|
||||||
<!-- Machine Status Card -->
|
<!-- 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="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="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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-black text-slate-800 dark:text-white leading-tight" x-text="selectedMachine.name"></h2>
|
<h2 class="text-2xl font-black text-slate-800 dark:text-white leading-tight"
|
||||||
<p class="text-xs font-mono font-bold text-slate-400 mt-1 uppercase tracking-widest" x-text="selectedMachine.serial_no"></p>
|
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>
|
</div>
|
||||||
<div class="flex gap-3">
|
<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="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>
|
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
{{ __('Connected') }}
|
{{ __('Connected') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -636,8 +595,10 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: System Control -->
|
<!-- Row 2: System Control -->
|
||||||
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
|
<div
|
||||||
<h3 class="text-lg font-black text-slate-800 dark:text-white uppercase tracking-wider mb-8 flex items-center gap-3">
|
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>
|
<span class="w-2 h-6 bg-cyan-500 rounded-full"></span>
|
||||||
{{ __('Machine Information') }}
|
{{ __('Machine Information') }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -647,36 +608,65 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-3 ml-1">
|
<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>
|
<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>
|
<span
|
||||||
|
class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{{
|
||||||
|
__('Maintenance Operations') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<!-- Reboot System -->
|
<!-- 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">
|
<button @click="sendCommand('reboot')"
|
||||||
<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">
|
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">
|
||||||
<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
|
||||||
|
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>
|
||||||
<div class="text-left">
|
<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
|
||||||
|
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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Card Reader Reboot -->
|
<!-- 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">
|
<button @click="sendCommand('reboot_card')"
|
||||||
<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">
|
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">
|
||||||
<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
|
||||||
|
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>
|
||||||
<div class="text-left">
|
<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
|
||||||
|
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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Remote Settlement -->
|
<!-- 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">
|
<button @click="sendCommand('checkout')"
|
||||||
<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">
|
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">
|
||||||
<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
|
||||||
|
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>
|
||||||
<div class="text-left">
|
<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
|
||||||
|
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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -686,33 +676,57 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-3 ml-1">
|
<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>
|
<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>
|
<span
|
||||||
|
class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{{
|
||||||
|
__('Security Controls') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<!-- Unlock -->
|
<!-- 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">
|
<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="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">
|
<div
|
||||||
<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>
|
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>
|
||||||
<div class="text-left">
|
<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
|
||||||
|
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>
|
</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>
|
<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>
|
</button>
|
||||||
|
|
||||||
<!-- Lock -->
|
<!-- 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">
|
<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="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">
|
<div
|
||||||
<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>
|
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>
|
||||||
<div class="text-left">
|
<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
|
||||||
|
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>
|
</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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -724,19 +738,26 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
|
||||||
<!-- Remote Change -->
|
<!-- Remote Change -->
|
||||||
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
|
<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">
|
<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">
|
<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>
|
<span class="w-2 h-6 bg-amber-500 rounded-full"></span>
|
||||||
{{ __('Remote Change') }}
|
{{ __('Remote Change') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">CASH</div>
|
<div
|
||||||
|
class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">
|
||||||
|
CASH</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="relative group">
|
<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">
|
<input type="number" x-model="changeAmount"
|
||||||
<div class="absolute inset-y-0 left-6 flex items-center pointer-events-none z-10 transition-transform group-focus-within:translate-x-1">
|
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="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">
|
<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>
|
<span class="text-xl font-black">$</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -748,27 +769,39 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
x-text="amt"></button>
|
x-text="amt"></button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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">
|
<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">
|
<span class="relative z-10 flex items-center justify-center gap-2">
|
||||||
{{ __('Execute Remote Change') }}
|
{{ __('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>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remote Dispense -->
|
<!-- Remote Dispense -->
|
||||||
<div class="luxury-card rounded-[2.5rem] p-8 border border-slate-200/60 dark:border-slate-800/60">
|
<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">
|
<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">
|
<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>
|
<span class="w-2 h-6 bg-violet-500 rounded-full"></span>
|
||||||
{{ __('Remote Dispense') }}
|
{{ __('Remote Dispense') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">ITEM</div>
|
<div
|
||||||
|
class="text-[40px] font-black text-slate-200 dark:text-slate-800 leading-none select-none tracking-tighter">
|
||||||
|
ITEM</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="space-y-2">
|
<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>
|
<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]">
|
<div id="slot-select-wrapper" class="relative min-h-[60px]">
|
||||||
<!-- Content Injected Dynamically by Alpine.js -->
|
<!-- Content Injected Dynamically by Alpine.js -->
|
||||||
</div>
|
</div>
|
||||||
@@ -779,14 +812,22 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
:disabled="submitting || !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">
|
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="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">
|
<div
|
||||||
<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>
|
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>
|
||||||
<div class="text-left">
|
<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
|
||||||
|
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>
|
</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">
|
<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') }}
|
{{ __('Trigger') }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -797,43 +838,58 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
|
|
||||||
<!-- Right: Command History (Sidebar) -->
|
<!-- Right: Command History (Sidebar) -->
|
||||||
<div class="space-y-4 mt-2">
|
<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">
|
<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>
|
<span class="w-1.5 h-6 bg-slate-300 dark:bg-slate-700 rounded-full"></span>
|
||||||
{{ __('Recent Commands') }}
|
{{ __('Recent Commands') }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="space-y-3.5 max-h-[1000px] overflow-y-auto pr-2 custom-scrollbar">
|
<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">
|
<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="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 items-start justify-between mb-3">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-sm font-black text-slate-800 dark:text-white" x-text="getCommandName(cmd.command_type)"></span>
|
<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="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"
|
<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>
|
x-text="getOperatorName(cmd.user).substring(0,1)"></div>
|
||||||
<span class="text-xs font-bold text-slate-400" x-text="getOperatorName(cmd.user)"></span>
|
<span class="text-xs font-bold text-slate-400"
|
||||||
|
x-text="getOperatorName(cmd.user)"></span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
||||||
<div class="text-[10px] font-bold text-slate-400 flex items-center gap-2 mb-3">
|
<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>
|
<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>
|
<span x-text="new Date(cmd.created_at).toLocaleString()"></span>
|
||||||
</div>
|
</div>
|
||||||
<template x-if="getPayloadDetails(cmd)">
|
<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 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="cmd.note">
|
<template x-if="cmd.note">
|
||||||
<div class="mt-2 text-xs font-bold text-slate-400 italic" x-text="'\"' + cmd.note + '\"'"></div>
|
<div class="mt-2 text-xs font-bold text-slate-400 italic"
|
||||||
|
x-text="'\"' + cmd.note + ' \"'"></div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if="commands.length === 0">
|
<template x-if="commands.length === 0">
|
||||||
<div class="py-20 text-center flex flex-col items-center opacity-30">
|
<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>
|
<svg class="w-12 h-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div class="text-[10px] font-black uppercase tracking-widest">{{ __('No command history') }}</div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -841,30 +897,24 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Confirmation Modal -->
|
<!-- Custom Confirmation Modal -->
|
||||||
<template x-teleport="body">
|
<template x-teleport="body">
|
||||||
<div x-show="confirmModal.show"
|
<div x-show="confirmModal.show" class="fixed inset-0 z-[100] overflow-y-auto" x-cloak>
|
||||||
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">
|
<div class="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
|
||||||
<!-- Background Backdrop -->
|
<!-- Background Backdrop -->
|
||||||
<div x-show="confirmModal.show"
|
<div x-show="confirmModal.show" x-transition:enter="ease-out duration-300"
|
||||||
x-transition:enter="ease-out duration-300"
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
x-transition:enter-start="opacity-0"
|
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100"
|
||||||
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"
|
x-transition:leave-end="opacity-0"
|
||||||
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
|
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
|
||||||
@click="confirmModal.show = false"></div>
|
@click="confirmModal.show = false"></div>
|
||||||
|
|
||||||
<!-- Modal Content -->
|
<!-- Modal Content -->
|
||||||
<div x-show="confirmModal.show"
|
<div x-show="confirmModal.show" x-transition:enter="ease-out duration-300"
|
||||||
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-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:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||||
x-transition:leave="ease-in duration-200"
|
x-transition:leave="ease-in duration-200"
|
||||||
@@ -873,38 +923,53 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
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">
|
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="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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{ __('Command Confirmation') }}</h3>
|
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{
|
||||||
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ __('Please confirm the details below') }}</p>
|
__('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>
|
</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="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">
|
<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-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Command
|
||||||
<span class="text-sm font-black text-slate-800 dark:text-slate-200" x-text="getCommandName(confirmModal.type)"></span>
|
Type') }}</span>
|
||||||
|
<span class="text-sm font-black text-slate-800 dark:text-slate-200"
|
||||||
|
x-text="getCommandName(confirmModal.type)"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2 px-1">
|
<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>
|
<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"
|
<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"
|
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>
|
placeholder="{{ __('Reason for this command...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<template x-if="confirmModal.params.amount">
|
<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">
|
<div
|
||||||
<span class="text-[10px] font-black text-amber-500 uppercase tracking-widest">{{ __('Amount') }}</span>
|
class="flex justify-between items-center pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
|
||||||
<span class="text-lg font-black text-slate-800 dark:text-slate-200" x-text="'$' + confirmModal.params.amount"></span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="confirmModal.params.slot_no">
|
<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">
|
<div
|
||||||
<span class="text-[10px] font-black text-violet-500 uppercase tracking-widest">{{ __('Slot No') }}</span>
|
class="flex justify-between items-center pt-3 border-t border-slate-200/50 dark:border-slate-800/50">
|
||||||
<span class="text-sm font-black text-slate-800 dark:text-slate-200" x-text="confirmModal.params.slot_no"></span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -927,21 +992,34 @@ window.remoteControlApp = function(initialMachineId) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Custom Scrollbar for Luxury UI */
|
/* Custom Scrollbar for Luxury UI */
|
||||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
width: 4px;
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e133; border-radius: 10px; }
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #cbd5e166; }
|
|
||||||
|
|
||||||
/* Hide default number spinners */
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
input::-webkit-outer-spin-button,
|
background: transparent;
|
||||||
input::-webkit-inner-spin-button {
|
}
|
||||||
|
|
||||||
|
.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;
|
-webkit-appearance: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
input[type=number] {
|
|
||||||
|
input[type=number] {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@endsection
|
@endsection
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div x-show="tabLoading === 'history'" x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center"
|
||||||
|
x-cloak>
|
||||||
|
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
|
||||||
|
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
|
||||||
|
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
|
||||||
|
{{ __('Loading Data') }}...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Area -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<form @submit.prevent="searchInTab('history')" class="flex flex-wrap items-center gap-4">
|
||||||
|
<!-- Search Box -->
|
||||||
|
<div class="relative group flex-[1.5] min-w-[200px]">
|
||||||
|
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
|
||||||
|
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="text" name="search" value="{{ request('search') }}"
|
||||||
|
placeholder="{{ __('Search machines...') }}"
|
||||||
|
class="luxury-input py-2.5 pl-12 pr-6 block w-full">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Range -->
|
||||||
|
<div class="relative group flex-[2] min-w-[340px]" x-data="{
|
||||||
|
fp: null,
|
||||||
|
startDate: '{{ request('start_date') }}',
|
||||||
|
endDate: '{{ request('end_date') }}'
|
||||||
|
}" x-init="fp = flatpickr($refs.dateRange, {
|
||||||
|
mode: 'range',
|
||||||
|
dateFormat: 'Y-m-d H:i', enableTime: true, time_24hr: true,
|
||||||
|
defaultHour: 0,
|
||||||
|
defaultMinute: 0,
|
||||||
|
locale: 'zh_tw',
|
||||||
|
defaultDate: startDate && endDate ? [startDate, endDate] : (startDate ? [startDate] : []),
|
||||||
|
onChange: function(selectedDates, dateStr, instance) {
|
||||||
|
if (selectedDates.length === 2) {
|
||||||
|
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
|
||||||
|
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
|
||||||
|
} else if (selectedDates.length === 0) {
|
||||||
|
$refs.startDate.value = '';
|
||||||
|
$refs.endDate.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})">
|
||||||
|
<span
|
||||||
|
class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="hidden" name="start_date" x-ref="startDate"
|
||||||
|
value="{{ request('start_date') }}">
|
||||||
|
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
|
||||||
|
<input type="text" x-ref="dateRange"
|
||||||
|
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
|
||||||
|
placeholder="{{ __('Select Date Range') }}"
|
||||||
|
class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Command Type -->
|
||||||
|
<div class="flex-[0.8] min-w-[160px]">
|
||||||
|
<x-searchable-select name="command_type" :options="[
|
||||||
|
'reboot' => __('Machine Reboot'),
|
||||||
|
'reboot_card' => __('Card Reader Reboot'),
|
||||||
|
'checkout' => __('Remote Settlement'),
|
||||||
|
'lock' => __('Lock Page Lock'),
|
||||||
|
'unlock' => __('Lock Page Unlock'),
|
||||||
|
'change' => __('Remote Change'),
|
||||||
|
'dispense' => __('Remote Dispense'),
|
||||||
|
]" :selected="request('command_type')" :placeholder="__('All Command Types')"
|
||||||
|
:hasSearch="false"
|
||||||
|
@change="searchInTab('history')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex-[0.8] min-w-[160px]">
|
||||||
|
<x-searchable-select name="status" :options="[
|
||||||
|
'pending' => __('Pending'),
|
||||||
|
'sent' => __('Sent'),
|
||||||
|
'success' => __('Success'),
|
||||||
|
'failed' => __('Failed'),
|
||||||
|
'superseded' => __('Superseded'),
|
||||||
|
]" :selected="request('status')" :placeholder="__('All Status')" :hasSearch="false" @change="searchInTab('history')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="submit" class="p-2.5 rounded-xl bg-cyan-600 text-white hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 transition-all active:scale-95">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="searchInTab('history', true)" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
@foreach ($history as $item)
|
||||||
|
<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({{ Js::from($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 shrink-0">
|
||||||
|
<template x-if="{{ Js::from(!empty($item->machine->image_urls) && isset($item->machine->image_urls[0])) }}">
|
||||||
|
<img src="{{ !empty($item->machine->image_urls) ? $item->machine->image_urls[0] : '' }}"
|
||||||
|
class="w-full h-full object-cover">
|
||||||
|
</template>
|
||||||
|
<template x-if="{{ Js::from(empty($item->machine->image_urls) || !isset($item->machine->image_urls[0])) }}">
|
||||||
|
<svg class="w-6 h-6 shrink-0" 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">
|
||||||
|
{{ $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">
|
||||||
|
{{ $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>{{ $item->created_at->format('Y/m/d') }}</span>
|
||||||
|
<span class="text-[15px] font-bold text-slate-500 dark:text-slate-400">{{ $item->created_at->format('H:i:s')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
|
||||||
|
@if($item->executed_at)
|
||||||
|
<div class="flex flex-col text-cyan-600/80 dark:text-cyan-400/60">
|
||||||
|
<span>{{ $item->executed_at->format('Y/m/d') }}</span>
|
||||||
|
<span class="text-[15px] font-bold">{{ $item->executed_at->format('H:i:s')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-slate-300 dark:text-slate-700">-</span>
|
||||||
|
@endif
|
||||||
|
</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({{ Js::from($item->command_type) }})"></span>
|
||||||
|
<div class="flex flex-col gap-0.5 mt-1">
|
||||||
|
<span x-show="getPayloadDetails({{ Js::from($item) }})"
|
||||||
|
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({{ Js::from($item) }})"></span>
|
||||||
|
@if($item->note)
|
||||||
|
<span class="text-[10px] text-slate-400 italic pl-1"
|
||||||
|
x-text="translateNote({{ Js::from($item->note) }})"></span>
|
||||||
|
@endif
|
||||||
|
</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">
|
||||||
|
{{ mb_substr($item->user ? $item->user->name : __('System'), 0, 1) }}
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{
|
||||||
|
$item->user ? $item->user->name : __('System') }}</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({{ Js::from($item->status) }})">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full mr-2" :class="{
|
||||||
|
'bg-amber-500 animate-pulse': {{ Js::from($item->status) }} === 'pending',
|
||||||
|
'bg-cyan-500': {{ Js::from($item->status) }} === 'sent',
|
||||||
|
'bg-emerald-500': {{ Js::from($item->status) }} === 'success',
|
||||||
|
'bg-rose-500': {{ Js::from($item->status) }} === 'failed',
|
||||||
|
'bg-slate-400': {{ Js::from($item->status) }} === 'superseded'
|
||||||
|
}"></div>
|
||||||
|
<span x-text="getCommandStatus({{ Js::from($item->status) }})"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
@if($history->isEmpty())
|
||||||
|
<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>
|
||||||
|
@endif
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Area -->
|
||||||
|
<div class="mt-8">
|
||||||
|
{{ $history->appends(request()->query())->links('vendor.pagination.luxury') }}
|
||||||
|
</div>
|
||||||
190
resources/views/admin/remote/partials/tab-history.blade.php
Normal file
190
resources/views/admin/remote/partials/tab-history.blade.php
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
{{-- 庫存操作紀錄 Partial (AJAX 可替換) --}}
|
||||||
|
<!-- Filters Area -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<form method="GET" action="{{ route('admin.remote.stock') }}" class="flex flex-wrap items-center gap-4" @submit.prevent="searchInTab()">
|
||||||
|
<!-- Search Box -->
|
||||||
|
<div class="relative group flex-[1.5] min-w-[200px]">
|
||||||
|
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
|
||||||
|
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="text" name="search" value="{{ request('search') }}" x-model="historySearch"
|
||||||
|
placeholder="{{ __('Search machines...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Range -->
|
||||||
|
<div class="relative group flex-[2] min-w-[340px]"
|
||||||
|
x-data="{
|
||||||
|
fp: null,
|
||||||
|
startDate: '{{ request('start_date') }}',
|
||||||
|
endDate: '{{ request('end_date') }}'
|
||||||
|
}"
|
||||||
|
x-init="fp = flatpickr($refs.dateRange, {
|
||||||
|
mode: 'range',
|
||||||
|
dateFormat: 'Y-m-d H:i', enableTime: true, time_24hr: true,
|
||||||
|
defaultHour: 0,
|
||||||
|
defaultMinute: 0,
|
||||||
|
locale: 'zh_tw',
|
||||||
|
defaultDate: startDate && endDate ? [startDate, endDate] : (startDate ? [startDate] : []),
|
||||||
|
onChange: function(selectedDates, dateStr, instance) {
|
||||||
|
if (selectedDates.length === 2) {
|
||||||
|
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
|
||||||
|
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
|
||||||
|
historyStartDate = $refs.startDate.value;
|
||||||
|
historyEndDate = $refs.endDate.value;
|
||||||
|
} else if (selectedDates.length === 0) {
|
||||||
|
$refs.startDate.value = '';
|
||||||
|
$refs.endDate.value = '';
|
||||||
|
historyStartDate = '';
|
||||||
|
historyEndDate = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})">
|
||||||
|
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||||
|
</span>
|
||||||
|
<input type="hidden" name="start_date" x-ref="startDate" value="{{ request('start_date') }}">
|
||||||
|
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
|
||||||
|
<input type="text" x-ref="dateRange"
|
||||||
|
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
|
||||||
|
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full cursor-pointer">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex-1 min-w-[160px]" @change="
|
||||||
|
historyStatus = $event.target.value === ' ' ? '' : $event.target.value;
|
||||||
|
searchInTab();
|
||||||
|
">
|
||||||
|
<x-searchable-select
|
||||||
|
name="status"
|
||||||
|
:options="[
|
||||||
|
'pending' => __('Pending'),
|
||||||
|
'sent' => __('Sent'),
|
||||||
|
'success' => __('Success'),
|
||||||
|
'failed' => __('Failed'),
|
||||||
|
'superseded' => __('Superseded'),
|
||||||
|
]"
|
||||||
|
:selected="request('status')"
|
||||||
|
:placeholder="__('All Status')"
|
||||||
|
:hasSearch="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="submit" class="p-2.5 rounded-xl bg-cyan-600 text-white hover:bg-cyan-500 shadow-lg shadow-cyan-500/20 transition-all active:scale-95">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="historySearch = ''; historyStartDate = ''; historyEndDate = ''; historyStatus = ''; searchInTab()" class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
@foreach ($history as $item)
|
||||||
|
<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(@js($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">{{ $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">{{ $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>{{ $item->created_at->format('Y/m/d') }}</span>
|
||||||
|
<span class="text-[15px] font-bold text-slate-500 dark:text-slate-400">{{ $item->created_at->format('H:i:s') }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 font-mono text-xs font-black text-slate-400 tracking-widest whitespace-nowrap text-center">
|
||||||
|
@if($item->executed_at)
|
||||||
|
<div class="flex flex-col text-cyan-600/80 dark:text-cyan-400/60">
|
||||||
|
<span>{{ $item->executed_at->format('Y/m/d') }}</span>
|
||||||
|
<span class="text-[15px] font-bold">{{ $item->executed_at->format('H:i:s') }}</span>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-slate-300 dark:text-slate-700">-</span>
|
||||||
|
@endif
|
||||||
|
</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(@js($item->command_type))"></span>
|
||||||
|
<div class="flex flex-col gap-0.5 mt-1">
|
||||||
|
<span x-show="getPayloadDetails(@js($item))" 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(@js($item))"></span>
|
||||||
|
@if($item->note)
|
||||||
|
<span class="text-[10px] text-slate-400 italic pl-1" x-text="translateNote(@js($item->note))"></span>
|
||||||
|
@endif
|
||||||
|
</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">
|
||||||
|
{{ mb_substr($item->user ? $item->user->name : __('System'), 0, 1) }}
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ $item->user ? $item->user->name : __('System') }}</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(@js($item->status))">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full mr-2"
|
||||||
|
:class="{
|
||||||
|
'bg-amber-500 animate-pulse': @js($item->status) === 'pending',
|
||||||
|
'bg-cyan-500': @js($item->status) === 'sent',
|
||||||
|
'bg-emerald-500': @js($item->status) === 'success',
|
||||||
|
'bg-rose-500': @js($item->status) === 'failed',
|
||||||
|
'bg-slate-400': @js($item->status) === 'superseded'
|
||||||
|
}"></div>
|
||||||
|
<span x-text="getCommandStatus(@js($item->status))"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
@if($history->isEmpty())
|
||||||
|
<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>
|
||||||
|
@endif
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Area -->
|
||||||
|
<div class="mt-8">
|
||||||
|
{{ $history->appends(request()->query())->links('vendor.pagination.luxury') }}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div x-show="tabLoading === 'list'" x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center"
|
||||||
|
x-cloak>
|
||||||
|
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
|
||||||
|
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
|
||||||
|
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
|
||||||
|
{{ __('Loading Data') }}...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Area -->
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<form @submit.prevent="searchInTab('list')" 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" name="search" value="{{ request('search') }}" placeholder="{{ __('Search machines...') }}"
|
||||||
|
class="luxury-input py-2.5 pl-12 pr-6 block w-72">
|
||||||
|
</form>
|
||||||
|
</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">
|
||||||
|
@foreach($machines as $machine)
|
||||||
|
<tr
|
||||||
|
class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||||
|
<td class="px-6 py-6 cursor-pointer" @click="selectMachine({{ Js::from($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">
|
||||||
|
@if(!empty($machine->image_urls) && isset($machine->image_urls[0]))
|
||||||
|
<img src="{{ $machine->image_urls[0] }}"
|
||||||
|
class="w-full h-full object-cover">
|
||||||
|
@else
|
||||||
|
<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>
|
||||||
|
@endif
|
||||||
|
</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">
|
||||||
|
{{ $machine->name }}</div>
|
||||||
|
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">
|
||||||
|
{{ $machine->serial_no }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-center">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
@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>
|
||||||
|
@elseif($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>
|
||||||
|
@else
|
||||||
|
<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>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-center">
|
||||||
|
<template x-data="{ heartbeat: {{ Js::from($machine->last_heartbeat_at) }} }">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="text-sm font-black text-slate-700 dark:text-slate-200"
|
||||||
|
x-text="formatTime(heartbeat)"></span>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5"
|
||||||
|
x-text="heartbeat ? heartbeat.split('T')[0] : '--'"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-right">
|
||||||
|
<button @click="selectMachine({{ Js::from($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>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Area -->
|
||||||
|
<div class="mt-8">
|
||||||
|
{{ $machines->appends(request()->query())->links('vendor.pagination.luxury') }}
|
||||||
|
</div>
|
||||||
124
resources/views/admin/remote/partials/tab-machines.blade.php
Normal file
124
resources/views/admin/remote/partials/tab-machines.blade.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<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">
|
||||||
|
@forelse($machines as $machine)
|
||||||
|
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||||
|
<td class="px-6 py-6 cursor-pointer" @click="selectMachine({{ Js::from($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">
|
||||||
|
@if($machine->image_urls && isset($machine->image_urls[0]))
|
||||||
|
<img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover">
|
||||||
|
@else
|
||||||
|
<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>
|
||||||
|
@endif
|
||||||
|
</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">
|
||||||
|
{{ $machine->name }}</div>
|
||||||
|
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">
|
||||||
|
{{ $machine->serial_no }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-center">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
@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>
|
||||||
|
@elseif($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>
|
||||||
|
@else
|
||||||
|
<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>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-center">
|
||||||
|
<div class="flex flex-col items-center gap-1.5">
|
||||||
|
@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>
|
||||||
|
{{ $machine->low_stock_count }} {{ __('Low') }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@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>
|
||||||
|
{{ $machine->expiring_soon_count }} {{ __('Expiring') }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@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>
|
||||||
|
@endif
|
||||||
|
</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({{ Js::from($machine->last_heartbeat_at) }})"></span>
|
||||||
|
<span class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5">
|
||||||
|
{{ $machine->last_heartbeat_at ? \Illuminate\Support\Carbon::parse($machine->last_heartbeat_at)->format('Y-m-d') : '--' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-6 text-right">
|
||||||
|
<button @click="selectMachine({{ Js::from($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>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" 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" stroke-width="1.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-400 font-bold tracking-widest uppercase text-xs">{{ __('No machines found') }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 標準化分頁底欄 --}}
|
||||||
|
<div class="mt-8">
|
||||||
|
{{ $machines->appends(request()->except('machine_page'))->links('vendor.pagination.luxury') }}
|
||||||
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<script>
|
<script>
|
||||||
window.stockApp = function(initialMachineId) {
|
window.stockApp = function (initialMachineId) {
|
||||||
return {
|
return {
|
||||||
machines: @json($machines),
|
machines: @json($machines),
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
@@ -12,6 +12,7 @@ window.stockApp = function(initialMachineId) {
|
|||||||
history: @js($history),
|
history: @js($history),
|
||||||
loading: false,
|
loading: false,
|
||||||
updating: false,
|
updating: false,
|
||||||
|
tabLoading: false,
|
||||||
|
|
||||||
// Modal State
|
// Modal State
|
||||||
showEditModal: false,
|
showEditModal: false,
|
||||||
@@ -21,13 +22,123 @@ window.stockApp = function(initialMachineId) {
|
|||||||
batch_no: ''
|
batch_no: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 機器搜尋狀態
|
||||||
|
machineSearch: '{{ request('machine_search') }}',
|
||||||
|
historySearch: '{{ request('search') }}',
|
||||||
|
historyStartDate: '{{ request('start_date') }}',
|
||||||
|
historyEndDate: '{{ request('end_date') }}',
|
||||||
|
historyStatus: '{{ request('status') }}',
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (initialMachineId) {
|
if (initialMachineId) {
|
||||||
const machine = this.machines.find(m => m.id == initialMachineId);
|
const machine = this.machines.data.find(m => m.id == initialMachineId);
|
||||||
if (machine) {
|
if (machine) {
|
||||||
await this.selectMachine(machine);
|
await this.selectMachine(machine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 首次載入時綁定分頁連結
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.historyContent) this.bindPaginationLinks(this.$refs.historyContent, 'history');
|
||||||
|
if (this.$refs.machinesContent) this.bindPaginationLinks(this.$refs.machinesContent, 'machines');
|
||||||
|
});
|
||||||
|
// 觸發進度條
|
||||||
|
this.$watch('tabLoading', (val) => {
|
||||||
|
const bar = document.getElementById('top-loading-bar');
|
||||||
|
if (bar) {
|
||||||
|
if (val) bar.classList.add('loading');
|
||||||
|
else bar.classList.remove('loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// === AJAX 搜尋/分頁 ===
|
||||||
|
async searchInTab(tab = 'history', extraQuery = '') {
|
||||||
|
this.tabLoading = true;
|
||||||
|
let qs = `_ajax=1&tab=${tab}`;
|
||||||
|
|
||||||
|
if (tab === 'machines') {
|
||||||
|
if (this.machineSearch) qs += `&machine_search=${encodeURIComponent(this.machineSearch)}`;
|
||||||
|
} else {
|
||||||
|
if (this.historySearch) qs += `&search=${encodeURIComponent(this.historySearch)}`;
|
||||||
|
if (this.historyStartDate) qs += `&start_date=${encodeURIComponent(this.historyStartDate)}`;
|
||||||
|
if (this.historyEndDate) qs += `&end_date=${encodeURIComponent(this.historyEndDate)}`;
|
||||||
|
if (this.historyStatus) qs += `&status=${encodeURIComponent(this.historyStatus)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraQuery) qs += extraQuery;
|
||||||
|
|
||||||
|
// 同步 URL(不含 _ajax)
|
||||||
|
const visibleQs = qs.replace(/&?_ajax=1/, '');
|
||||||
|
history.pushState({}, '', `{{ route('admin.remote.stock') }}${visibleQs ? '?' + visibleQs : ''}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`{{ route('admin.remote.stock') }}?${qs}`,
|
||||||
|
{ headers: { 'X-Requested-With': 'XMLHttpRequest' } }
|
||||||
|
);
|
||||||
|
const html = await res.text();
|
||||||
|
const ref = (tab === 'machines') ? this.$refs.machinesContent : this.$refs.historyContent;
|
||||||
|
if (ref) {
|
||||||
|
ref.innerHTML = html;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Alpine.initTree(ref);
|
||||||
|
this.bindPaginationLinks(ref, tab);
|
||||||
|
if (window.HSStaticMethods) {
|
||||||
|
setTimeout(() => window.HSStaticMethods.autoInit(), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Search failed:', e);
|
||||||
|
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Failed to load content') }}', type: 'error' } }));
|
||||||
|
} finally {
|
||||||
|
this.tabLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 攔截分頁連結
|
||||||
|
bindPaginationLinks(container, tab) {
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll('a[href]').forEach(a => {
|
||||||
|
const href = a.getAttribute('href');
|
||||||
|
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.origin);
|
||||||
|
const pageKey = (tab === 'machines') ? 'machine_page' : 'history_page';
|
||||||
|
if (!url.searchParams.has(pageKey) || a.closest('td.px-6')) return;
|
||||||
|
|
||||||
|
a.addEventListener('click', (e) => {
|
||||||
|
if (a.title) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const page = url.searchParams.get(pageKey) || 1;
|
||||||
|
const perPage = url.searchParams.get('per_page') || '';
|
||||||
|
let extra = `&${pageKey}=${page}`;
|
||||||
|
if (perPage) extra += `&per_page=${perPage}`;
|
||||||
|
this.searchInTab(tab, extra);
|
||||||
|
});
|
||||||
|
} catch (err) { }
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('select[onchange]').forEach(sel => {
|
||||||
|
const origOnchange = sel.getAttribute('onchange');
|
||||||
|
sel.removeAttribute('onchange');
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const val = sel.value;
|
||||||
|
const pageKey = (tab === 'machines') ? 'machine_page' : 'history_page';
|
||||||
|
try {
|
||||||
|
if (val.startsWith('http') || val.startsWith('/')) {
|
||||||
|
const url = new URL(val, window.location.origin);
|
||||||
|
const page = url.searchParams.get(pageKey) || 1;
|
||||||
|
const perPage = url.searchParams.get('per_page') || '';
|
||||||
|
let extra = `&${pageKey}=${page}`;
|
||||||
|
if (perPage) extra += `&per_page=${perPage}`;
|
||||||
|
this.searchInTab(tab, extra);
|
||||||
|
} else if (origOnchange && origOnchange.includes('per_page')) {
|
||||||
|
this.searchInTab(tab, `&per_page=${val}`);
|
||||||
|
}
|
||||||
|
} catch (err) { }
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async selectMachine(machine) {
|
async selectMachine(machine) {
|
||||||
@@ -136,7 +247,7 @@ window.stockApp = function(initialMachineId) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getCommandBadgeClass(status) {
|
getCommandBadgeClass(status) {
|
||||||
switch(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 '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 '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 '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';
|
||||||
@@ -228,8 +339,7 @@ window.stockApp = function(initialMachineId) {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-2 pb-20"
|
<div class="space-y-2 pb-20" x-data="stockApp('{{ $selectedMachine ? $selectedMachine->id : '' }}')"
|
||||||
x-data="stockApp('{{ $selectedMachine ? $selectedMachine->id : '' }}')"
|
|
||||||
@keydown.escape.window="showEditModal = false">
|
@keydown.escape.window="showEditModal = false">
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -254,7 +364,8 @@ window.stockApp = function(initialMachineId) {
|
|||||||
|
|
||||||
<!-- Tab Navigation (Only visible when not in specific machine detail) -->
|
<!-- Tab Navigation (Only visible when not in specific machine detail) -->
|
||||||
<template x-if="viewMode !== '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">
|
<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'"
|
<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="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">
|
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
|
||||||
@@ -271,265 +382,133 @@ window.stockApp = function(initialMachineId) {
|
|||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|
||||||
<!-- History View: Operation Records -->
|
<!-- History View: Operation Records -->
|
||||||
<template x-if="viewMode === 'history'">
|
<div x-show="viewMode === 'history'" x-cloak>
|
||||||
<div class="space-y-6 animate-luxury-in">
|
<div class="space-y-6 animate-luxury-in">
|
||||||
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
|
<div class="luxury-card rounded-3xl p-8 overflow-hidden relative">
|
||||||
<div class="overflow-x-auto">
|
<!-- Spinner Overlay -->
|
||||||
<table class="w-full text-left border-separate border-spacing-y-0 text-sm">
|
<div x-show="tabLoading" x-transition:enter="transition ease-out duration-300"
|
||||||
<thead>
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
|
||||||
<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>
|
x-transition:leave-end="opacity-0"
|
||||||
<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>
|
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center"
|
||||||
<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>
|
x-cloak>
|
||||||
<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>
|
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
|
||||||
<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>
|
<div
|
||||||
<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>
|
class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin">
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin"
|
||||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
style="animation-duration: 3s; direction: reverse;"></div>
|
||||||
<template x-for="item in history" :key="item.id">
|
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor"
|
||||||
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(item.machine)">
|
viewBox="0 0 24 24">
|
||||||
<div class="flex items-center gap-4">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
||||||
<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">
|
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p
|
||||||
<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>
|
class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
|
||||||
|
{{ __('Loading Data') }}...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AJAX 可替換區域 -->
|
||||||
|
<div
|
||||||
|
:class="tabLoading ? 'opacity-30 pointer-events-none transition-opacity duration-300' : 'transition-opacity duration-300'">
|
||||||
|
<div x-ref="historyContent">
|
||||||
|
@include('admin.remote.partials.tab-history', ['history' => $history])
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Master View: Machine List -->
|
<!-- Master View: Machine List -->
|
||||||
<template x-if="viewMode === 'list'">
|
<div x-show="viewMode === 'list'" x-cloak>
|
||||||
<div class="space-y-6 animate-luxury-in">
|
<div class="space-y-6 animate-luxury-in">
|
||||||
<div class="luxury-card rounded-3xl p-8 overflow-hidden">
|
<div class="luxury-card rounded-3xl p-8 overflow-hidden relative">
|
||||||
|
<!-- AJAX Spinner (Machine List) -->
|
||||||
|
<div x-show="tabLoading"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center" x-cloak>
|
||||||
|
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
|
||||||
|
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
|
||||||
|
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">{{ __('Loading Data') }}...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Filters Area -->
|
<!-- Filters Area -->
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div class="relative group">
|
<form @submit.prevent="searchInTab('machines')" class="relative group">
|
||||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
|
<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"
|
<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"
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linejoin="round">
|
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" x-model="searchQuery"
|
<input type="text" x-model="machineSearch"
|
||||||
placeholder="{{ __('Search...') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-72">
|
placeholder="{{ __('Search machines...') }}"
|
||||||
</div>
|
class="luxury-input py-2.5 pl-12 pr-6 block w-72">
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto pb-4">
|
<div :class="tabLoading ? 'opacity-30 pointer-events-none transition-opacity duration-300' : 'transition-opacity duration-300'">
|
||||||
<table class="w-full text-left border-separate border-spacing-y-0 text-sm whitespace-nowrap">
|
<div x-ref="machinesContent">
|
||||||
<thead>
|
@include('admin.remote.partials.tab-machines', ['machines' => $machines])
|
||||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Alerts') }}</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Last Sync') }}</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
|
||||||
<template x-for="machine in machines.filter(m =>
|
|
||||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
m.serial_no.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)" :key="machine.id">
|
|
||||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
|
||||||
<td class="px-6 py-6 cursor-pointer" @click="selectMachine(machine)">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="w-12 h-12 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm">
|
|
||||||
<template x-if="machine.image_urls && machine.image_urls[0]">
|
|
||||||
<img :src="machine.image_urls[0]" class="w-full h-full object-cover">
|
|
||||||
</template>
|
|
||||||
<template x-if="!machine.image_urls || !machine.image_urls[0]">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-5.25v9" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-[17px] font-black text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight" x-text="machine.name"></div>
|
|
||||||
<div class="text-[11px] font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.serial_no"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-center">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<template x-if="machine.status === 'online' || !machine.status">
|
|
||||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
|
||||||
<div class="relative flex h-2 w-2">
|
|
||||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
|
||||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
|
||||||
</div>
|
|
||||||
<span class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-[0.1em] uppercase">{{ __('Online') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="machine.status === 'offline'">
|
|
||||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
|
|
||||||
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
|
|
||||||
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 tracking-[0.1em] uppercase">{{ __('Offline') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="machine.status && machine.status !== 'online' && machine.status !== 'offline'">
|
|
||||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
|
|
||||||
<div class="relative flex h-2 w-2">
|
|
||||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
|
|
||||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-rose-500"></span>
|
|
||||||
</div>
|
|
||||||
<span class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-[0.1em] uppercase">{{ __('Abnormal') }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-center">
|
|
||||||
<div class="flex flex-col items-center gap-1.5">
|
|
||||||
<template x-if="machine.low_stock_count > 0">
|
|
||||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-rose-500/10 text-rose-500 text-[10px] font-black border border-rose-500/20 uppercase tracking-widest leading-none shadow-sm shadow-rose-500/5">
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse"></span>
|
|
||||||
<span x-text="machine.low_stock_count"></span> {{ __('Low') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template x-if="machine.expiring_soon_count > 0">
|
|
||||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-500 text-[10px] font-black border border-amber-500/20 uppercase tracking-widest leading-none shadow-sm shadow-amber-500/5">
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
|
||||||
<span x-text="machine.expiring_soon_count"></span> {{ __('Expiring') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template x-if="!machine.low_stock_count && !machine.expiring_soon_count">
|
|
||||||
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-600 uppercase tracking-[0.1em]">{{ __('All Stable') }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-center">
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<span class="text-sm font-black text-slate-700 dark:text-slate-200" x-text="formatTime(machine.last_heartbeat_at)"></span>
|
|
||||||
<span class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mt-0.5" x-text="machine.last_heartbeat_at ? machine.last_heartbeat_at.split('T')[0] : '--'"></span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-6 text-right">
|
|
||||||
<button @click="selectMachine(machine)"
|
|
||||||
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"
|
|
||||||
title="{{ __('Manage') }}">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Detail View: Cabinet Management -->
|
<!-- Detail View: Cabinet Management -->
|
||||||
<template x-if="viewMode === 'detail'">
|
<div x-show="viewMode === 'detail'" x-cloak>
|
||||||
<div class="space-y-8 animate-luxury-in">
|
<div class="space-y-8 animate-luxury-in">
|
||||||
|
|
||||||
<!-- Machine Header Info -->
|
<!-- 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="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="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">
|
<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]">
|
<template x-if="selectedMachine?.image_urls && selectedMachine?.image_urls[0]">
|
||||||
<img :src="selectedMachine.image_urls[0]" class="w-full h-full object-cover">
|
<img :src="selectedMachine.image_urls[0]" class="w-full h-full object-cover">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!selectedMachine?.image_urls || !selectedMachine?.image_urls[0]">
|
<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">
|
<svg class="w-12 h-12 stroke-[1.2]" fill="none" stroke="currentColor"
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 x-text="selectedMachine?.name" class="text-4xl font-black text-slate-800 dark:text-white tracking-tighter leading-tight"></h1>
|
<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">
|
<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>
|
<span x-text="selectedMachine?.serial_no"
|
||||||
<div class="flex items-center gap-2 text-slate-400 uppercase tracking-widest text-[10px] font-black">
|
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">
|
<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"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
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>
|
</svg>
|
||||||
<span x-text="selectedMachine?.location || '{{ __('No Location') }}'"></span>
|
<span x-text="selectedMachine?.location || '{{ __('No Location') }}'"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,13 +517,19 @@ window.stockApp = function(initialMachineId) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-5">
|
<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">
|
<div
|
||||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Total Slots') }}</span>
|
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-3xl font-black text-slate-700 dark:text-slate-200" x-text="slots.length"></span>
|
<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>
|
||||||
<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]">
|
<div
|
||||||
<span class="text-[10px] font-black text-rose-500 uppercase tracking-widest mb-1">{{ __('Low Stock') }}</span>
|
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-3xl font-black text-rose-600" x-text="slots.filter(s => s != null && s.stock <= 5).length"></span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -556,25 +541,36 @@ window.stockApp = function(initialMachineId) {
|
|||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<div class="flex items-center gap-2.5">
|
<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="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>
|
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{
|
||||||
|
__('Expired') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2.5">
|
<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
|
||||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{ __('Warning') }}</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>
|
||||||
<div class="flex items-center gap-2.5">
|
<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
|
||||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">{{ __('Normal') }}</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>
|
||||||
</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]">
|
<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 -->
|
<!-- 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 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="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>
|
<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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -584,19 +580,23 @@ window.stockApp = function(initialMachineId) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slots Grid -->
|
<!-- 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">
|
<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">
|
<template x-for="slot in slots" :key="slot.id">
|
||||||
<div @click="openEdit(slot)"
|
<div @click="openEdit(slot)" :class="getSlotColorClass(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">
|
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) -->
|
<!-- Slot Header (Pinned to top) -->
|
||||||
<div class="absolute top-4 left-5 right-5 flex justify-between items-center z-20">
|
<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">
|
<div
|
||||||
<span class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white" x-text="slot.slot_no"></span>
|
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>
|
</div>
|
||||||
<template x-if="slot.stock <= 2">
|
<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">
|
<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') }}
|
{{ __('Low') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -604,14 +604,19 @@ window.stockApp = function(initialMachineId) {
|
|||||||
|
|
||||||
<!-- Product Image -->
|
<!-- Product Image -->
|
||||||
<div class="relative w-20 h-20 mb-4 mt-1">
|
<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">
|
<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">
|
<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">
|
<img :src="slot.product.image_url"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110">
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!slot.product">
|
<template x-if="!slot.product">
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
<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">
|
<svg class="w-8 h-8 opacity-20" fill="none" stroke="currentColor"
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -621,22 +626,27 @@ window.stockApp = function(initialMachineId) {
|
|||||||
<!-- Slot Info -->
|
<!-- Slot Info -->
|
||||||
<div class="text-center w-full space-y-3">
|
<div class="text-center w-full space-y-3">
|
||||||
<template x-if="slot.product">
|
<template x-if="slot.product">
|
||||||
<div class="text-base font-black truncate w-full opacity-90 tracking-tight" x-text="slot.product.name"></div>
|
<div class="text-base font-black truncate w-full opacity-90 tracking-tight"
|
||||||
|
x-text="slot.product.name"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Stock Level -->
|
<!-- Stock Level -->
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<div class="flex items-baseline gap-1">
|
<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-2xl font-black tracking-tighter leading-none"
|
||||||
|
x-text="slot.stock"></span>
|
||||||
<span class="text-xs font-black opacity-30">/</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>
|
<span class="text-sm font-bold opacity-50"
|
||||||
|
x-text="slot.max_stock || 10"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expiry Date -->
|
<!-- Expiry Date -->
|
||||||
<div class="flex flex-col items-center">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -646,21 +656,17 @@ window.stockApp = function(initialMachineId) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<!-- Integrated Edit Modal -->
|
<!-- Integrated Edit Modal -->
|
||||||
<div x-show="showEditModal"
|
<div x-show="showEditModal" class="fixed inset-0 z-[100] overflow-y-auto" style="display: none;"
|
||||||
class="fixed inset-0 z-[100] overflow-y-auto"
|
x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0"
|
||||||
style="display: none;"
|
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200"
|
||||||
x-transition:enter="ease-out duration-300"
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||||
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="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>
|
<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">​</span>
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
@@ -670,17 +676,22 @@ window.stockApp = function(initialMachineId) {
|
|||||||
<!-- Modal Header -->
|
<!-- Modal Header -->
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight leading-none">
|
<h3
|
||||||
{{ __('Edit Slot') }} <span x-text="selectedSlot?.slot_no || ''" class="text-cyan-500"></span>
|
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>
|
</h3>
|
||||||
<template x-if="selectedSlot && selectedSlot.product">
|
<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>
|
<p x-text="selectedSlot?.product?.name"
|
||||||
|
class="text-base font-black text-slate-400 uppercase tracking-widest mt-3 ml-0.5">
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showEditModal = false"
|
<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">
|
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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -689,20 +700,26 @@ window.stockApp = function(initialMachineId) {
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Stock Count Widget -->
|
<!-- Stock Count Widget -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-base font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Stock Quantity') }}</label>
|
<label class="text-base font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||||||
<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">
|
__('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">
|
<div class="flex-1">
|
||||||
<input type="number" x-model="formData.stock" min="0" :max="selectedSlot ? selectedSlot.max_stock : 99"
|
<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">
|
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">
|
<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>
|
{{ __('Max Capacity:') }} <span class="text-slate-600 dark:text-slate-300"
|
||||||
|
x-text="selectedSlot?.max_stock || 0"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<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">
|
<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') }}
|
{{ __('Clear') }}
|
||||||
</button>
|
</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">
|
<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') }}
|
{{ __('Max') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -712,14 +729,15 @@ window.stockApp = function(initialMachineId) {
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<!-- Expiry Date -->
|
<!-- Expiry Date -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Expiry Date') }}</label>
|
<label class="text-sm font-black text-slate-500 uppercase tracking-widest pl-1">{{
|
||||||
<input type="date" x-model="formData.expiry_date"
|
__('Expiry Date') }}</label>
|
||||||
class="luxury-input w-full py-4 px-5">
|
<input type="date" x-model="formData.expiry_date" class="luxury-input w-full py-4 px-5">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batch Number -->
|
<!-- Batch Number -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Batch Number') }}</label>
|
<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"
|
<input type="text" x-model="formData.batch_no" placeholder="B2026-XXXX"
|
||||||
class="luxury-input w-full py-4 px-5">
|
class="luxury-input w-full py-4 px-5">
|
||||||
</div>
|
</div>
|
||||||
@@ -729,35 +747,58 @@ window.stockApp = function(initialMachineId) {
|
|||||||
|
|
||||||
<!-- Footer Actions -->
|
<!-- Footer Actions -->
|
||||||
<div class="flex justify-end gap-x-4 mt-10 pt-8 border-t border-slate-100 dark:border-slate-800/50">
|
<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="showEditModal = false" class="btn-luxury-ghost px-8">{{
|
||||||
<button type="button" @click="saveChanges()" :disabled="updating" class="btn-luxury-primary px-12 min-w-[160px]">
|
__('Cancel') }}</button>
|
||||||
<div x-show="updating" class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin mr-3"></div>
|
<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>
|
<span x-text="updating ? '{{ __('Saving...') }}' : '{{ __('Confirm Changes') }}'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Hide default number spinners */
|
/* Hide default number spinners */
|
||||||
input::-webkit-outer-spin-button,
|
input::-webkit-outer-spin-button,
|
||||||
input::-webkit-inner-spin-button {
|
input::-webkit-inner-spin-button {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
input[type=number] {
|
|
||||||
|
input[type=number] {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
.hide-scrollbar {
|
||||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
-ms-overflow-style: none;
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e133; border-radius: 10px; }
|
scrollbar-width: none;
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #cbd5e166; }
|
}
|
||||||
</style>
|
|
||||||
@endsection
|
.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
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"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",
|
"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",
|
"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",
|
"toggleClasses" => "hs-select-toggle luxury-select-toggle",
|
||||||
|
"toggleTemplate" => '<button type="button" aria-expanded="false"><span class="me-2" data-icon></span><span class="text-slate-800 dark:text-slate-200" data-title></span><div class="ms-auto"><svg class="size-4 text-slate-400 transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></div></button>',
|
||||||
"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",
|
"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",
|
"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>'
|
"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>'
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
$limits = [10, 25, 50, 100];
|
$limits = [10, 25, 50, 100];
|
||||||
@endphp
|
@endphp
|
||||||
<select onchange="const params = new URLSearchParams(window.location.search); params.set('per_page', this.value); params.delete('page'); window.location.href = window.location.pathname + '?' + params.toString();"
|
<select onchange="const params = new URLSearchParams(window.location.search); params.set('per_page', this.value); params.delete('page'); window.location.href = window.location.pathname + '?' + params.toString();"
|
||||||
class="h-7 pl-2 pr-8 rounded-lg bg-white dark:bg-slate-800 border-none text-[11px] font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 outline-none transition-all cursor-pointer shadow-sm leading-none py-0">
|
class="h-7 pl-2 pr-7 rounded-lg bg-white dark:bg-slate-800 border-none text-[11px] font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 outline-none transition-all cursor-pointer shadow-sm leading-none py-0 !bg-none">
|
||||||
@foreach($limits as $l)
|
@foreach($limits as $l)
|
||||||
<option value="{{ $l }}" {{ $currentLimit == $l ? 'selected' : '' }}>{{ $l }}</option>
|
<option value="{{ $l }}" {{ $currentLimit == $l ? 'selected' : '' }}>{{ $l }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none opacity-40 group-hover:opacity-100 transition-opacity">
|
<div class="absolute inset-y-0 right-0 flex items-center pr-1.5 pointer-events-none text-cyan-500/50 group-hover:text-cyan-500 transition-colors">
|
||||||
<svg class="size-3 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
|
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
{{-- Unified Quick Jump Selection (Desktop & Mobile) --}}
|
{{-- Unified Quick Jump Selection (Desktop & Mobile) --}}
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<select onchange="window.location.href = this.value"
|
<select onchange="window.location.href = this.value"
|
||||||
class="h-9 pl-4 pr-10 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-[11px] sm:text-xs font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 outline-none transition-all cursor-pointer shadow-sm hover:border-slate-300 dark:hover:border-slate-600 {{ $paginator->lastPage() <= 1 ? 'opacity-50 cursor-not-allowed pointer-events-none' : '' }}"
|
class="h-9 pl-4 pr-10 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-[11px] sm:text-xs font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 outline-none transition-all cursor-pointer shadow-sm hover:border-slate-300 dark:hover:border-slate-600 !bg-none {{ $paginator->lastPage() <= 1 ? 'opacity-50 cursor-not-allowed pointer-events-none' : '' }}"
|
||||||
{{ $paginator->lastPage() <= 1 ? 'disabled' : '' }}>
|
{{ $paginator->lastPage() <= 1 ? 'disabled' : '' }}>
|
||||||
@for ($i = 1; $i <= $paginator->lastPage(); $i++)
|
@for ($i = 1; $i <= $paginator->lastPage(); $i++)
|
||||||
<option value="{{ $paginator->url($i) }}" {{ $i == $paginator->currentPage() ? 'selected' : '' }}>
|
<option value="{{ $paginator->url($i) }}" {{ $i == $paginator->currentPage() ? 'selected' : '' }}>
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
</option>
|
</option>
|
||||||
@endfor
|
@endfor
|
||||||
</select>
|
</select>
|
||||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none opacity-40 group-hover:opacity-100 transition-opacity">
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-cyan-500/50 group-hover:text-cyan-500 transition-colors">
|
||||||
<svg class="size-3.5 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
|
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -51,12 +51,15 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|
|||||||
// 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth)
|
// 機台管理員 B000 登入驗證 (由於此階段機台未帶 Token 無法通過 iot.auth)
|
||||||
Route::prefix('app')->group(function () {
|
Route::prefix('app')->group(function () {
|
||||||
Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1');
|
Route::post('admin/login/B000', [\App\Http\Controllers\Api\V1\App\MachineAuthController::class, 'loginB000'])->middleware('throttle:30,1');
|
||||||
|
|
||||||
|
// 機台啟動引導與參數下載 (需人員登入 Token)
|
||||||
|
Route::middleware('auth:sanctum')->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']);
|
||||||
@@ -71,6 +74,10 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|
|||||||
// 機台故障與異常上報 (B013)
|
// 機台故障與異常上報 (B013)
|
||||||
Route::post('machine/error/B013', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportError']);
|
Route::post('machine/error/B013', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportError']);
|
||||||
|
|
||||||
|
// 遠端指令出貨控制 (B055)
|
||||||
|
Route::get('machine/dispense/B055', [App\Http\Controllers\Api\V1\App\MachineController::class, 'getDispenseQueue']);
|
||||||
|
Route::put('machine/dispense/B055', [App\Http\Controllers\Api\V1\App\MachineController::class, 'reportDispenseResult']);
|
||||||
|
|
||||||
// 交易、發票與出貨 (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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user