Compare commits

...

43 Commits

Author SHA1 Message Date
daf8b1ebcc [FEAT] 強化機台技術員 Token 安全性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
1. 為 B000 登入發放之技術員 Token 增加 8 小時有效期限,防止 Token 永久有效之風險。
2026-04-13 17:24:57 +08:00
8f008ffb61 [FEAT] 實作 B014 機台參數下載 API 與 B000 登入認證強化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 強化 B000 登入接口:驗證成功後回傳 Sanctum Token 供後續初始化使用。
2. 實作 B014 (getSettings) API:整合機台、金流與發票設定,並映射至 Android App 預期欄位。
3. 強化安全性:B014 API 掛載 auth:sanctum 並執行 RBAC 權限檢查。
4. 更新 API 說明文件 (iot-spec.md, api-docs.php) 及技術規範 (SKILL.md)。
2026-04-13 17:04:52 +08:00
729890d7c7 [DOCS] 更新通訊系統與全域工具列開發藍圖
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
1. 更新 docs/future_todo.md:詳列第一階段核心工具(工具列、公告系統、快捷入口、身分模擬)與第二階段行銷功能(盲盒抽獎)的開發時程。
2026-04-13 16:19:37 +08:00
ad256d3d3b [FEAT] 廣告排程功能與 UI 優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 新增廣告排程功能,支援設定發布時間與下架時間。
2. 整合 Flatpickr 時間選擇器,提供與機台日誌一致的極簡奢華風 UI。
3. 優化廣告列表中的數字字體,套用 font-mono 與 tabular-nums,與客戶管理模組風格同步。
4. 修正 Alpine.js 資料同步邏輯,確保編輯模式下排程時間能正確回填。
2026-04-13 11:44:26 +08:00
5415b14a53 [DOCS] 更新 .gitignore 以排除 pptx 目錄
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m10s
1. 在 .gitignore 中新增 /docs/pptx 路徑,避免產出的簡報檔案意外進入版本控制。
2026-04-13 11:01:21 +08:00
c97776892e [FEAT] 優化客戶合約管理介面與修復日期偏移問題
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 52s
1. 整合「客戶詳情」與「合約歷程」至單一側邊欄,改用分頁 (Tabs) 切換介面。
2. 優化清單「服務期程」顯示:根據客戶模式(租賃/買斷)動態顯示對應期間,並使用完整文字標籤取代縮寫。
3. 修復日期 Bug:在 Company 模型指定日期序列化格式為 Y-m-d,解決時區轉換導致的日期減少一天問題。
4. 新增合約歷程資料表模型、遷移檔以及對應的多語系翻譯(中、英、日)。
5. 移除清單操作列中重複的合約歷程圖示。
2026-04-08 17:41:26 +08:00
a599b14df1 [FEAT] 優化機台硬體通訊協議與管理介面互動性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m6s
1. 修復帳號管理與角色權限頁面搜尋功能,支援 Enter 鍵快捷提交。
2. 完成 B013 (機台故障上報) API 實作,改用非同步隊列 (ProcessMachineError) 處理日誌上報。
3. 精簡 B013 API 參數,移除冗餘的 message 欄位,統一由雲端對照表翻譯。
4. 更新技術規格文件 (SKILL.md) 與系統 API 文件配置 (api-docs.php)。
5. 修正平台管理員帳號在搜尋過濾時的資料隔離邏輯。
2026-04-08 14:52:00 +08:00
c343df34ee [FEAT] 實作 B012 商品同步 API 與統一圖片絕對網址格式
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
1. 實作 B012 API:新增 /api/v1/app/machine/products/B012 端點,支援 GET (全量) 與 PATCH (增量) 同步邏輯。
2. 統一圖片 URL:在 B005 與 B012 API 中使用 asset() 確保回傳絕對網址 (Absolute URL),解決 App 端下載相對路徑的問題。
3. 文件更新:同步更新 SKILL.md 的欄位定義,並在 api-docs.php 補上 B012 的正式規格說明。
4. 資料庫變更:新增 machine_slots 表的 type 欄位與相關註解遷移。
5. 格式優化:為技術規格文件中的 API 欄位與狀態碼加上反引號,提升文件中心可讀性。
2026-04-07 17:05:28 +08:00
253ae8afd4 [FEAT] 實作機台序號編輯功能與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
1. 更新機台控制器 (MachineController),在更新時執行 serial_no 的必填與唯一性驗證。
2. 修改機台管理首頁 (index.blade.php),在快速編輯彈窗中加入機台序號輸入欄位。
3. 修正基礎設定中機台編輯頁面 (edit.blade.php),將原本唯讀的機台序號欄位改為可編輯輸入框,並加入必填標記。
4. 補齊並統一繁體中文、英文、日文翻譯檔中關於「機台序號」的翻譯 Key。
2026-04-07 14:55:24 +08:00
f2147ae6c4 [FEAT] 完善 IoT API 規范化、機台管理介面優化與 B005 改為 GET
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
1. 將 B005 (廣告同步) 從 POST 改為 GET,符合 RESTful 規範。
2. 完善 B009 (庫存回報) 回應規格,加入業務代碼 (200 OK)。
3. API 文件 UI 優化:新增 Method Badge (方法標籤),並修正 JSON 中文/斜線轉義問題。
4. 機台管理介面優化:實作「唯讀庫存與效期」面板,並將日誌圖示改為「👁️」。
5. 標準化 ID 識別邏輯:資料表全面移除對 sku 的依賴,改以 id 為主、barcode 為輔。
6. 新增 Migration:正式移除 sku 欄位並同步 barcode 指向。
7. 更新多語系支援 (zh_TW, en, ja)。
2026-04-07 14:37:57 +08:00
b60afc3abe [FIX] 修正與標準化 B005 廣告下載 API 以相容既有 Android App
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
1. 修改 MachineController@getAdvertisements 回傳欄位,將 t070v02 改為播放秒數,t070v03 改為位置代碼 (Flag)。
2. 根據 Android App 原始碼分析結果,調整位置對應:3 為待機廣告 (HomeActivity),1 為販賣頁廣告 (FontendActivity)。
3. 在 AdvertisementController@getMachineAds 增加 sort_order 排序,確保後台管理介面視圖與 API 輸出順序一致。
4. 更新廣告下載 API 的技術文件 (SKILL.md),明確標記欄位用途與位置代碼。
5. 在 routes/api.php 補上 B005 與 B009 的路由定義。
2026-04-07 11:41:24 +08:00
bbdc5bad9f [FEAT] 重構機台狀態判定邏輯並優化全站多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m18s
1. 重構機台在線狀態判定機制:移除資料庫 status 欄位,改由 Model 根據心跳時間動態計算。
2. 修正儀表板 (Dashboard) 與機台管理頁面的多語系顯示問題,解決換行導致翻譯失效的 Bug。
3. 修正個人檔案頁面的麵包屑 (Breadcrumbs) 導航,補齊「個人設定」層級。
4. 更新 IoT API (B010, B600) 的認證機制與日誌處理邏輯。
5. 同步更新繁中、英文、日文語言檔,確保 UI 標籤一致性。
2026-04-07 10:21:07 +08:00
08b6c60d2e [FEAT] 實作 B000 機台登入 API 並同步 API 規格文件
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 45s
1. 於 routes/api.php 新增 B000 登入驗證路由,並設定速率限制。
2. 更新 .agents/skills/api-technical-specs/SKILL.md,補上 B000 規格並根據 Java App 實作精簡 B010 指令碼。
3. 同步更新 config/api-docs.php 中的 API 說明文件。
4. 調整 RemoteController 指令歷史紀錄上限為 5 筆以優化效能。
5. 統一機台編輯頁面之圖片上傳文字說明與標題。
6. 補齊多語系翻譯檔案 (zh_TW, en, ja) 出現的新字串。
2026-04-02 17:24:13 +08:00
e085058d63 [FEAT] 遠端指令中心優化與規格同步
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m11s
1. 實作遠端指令去重機制 (Supersede):避免重複下達相同待執行指令。
2. 修正遠端指令發送後的 Toast 提示邏輯,確保頁面跳轉後正確顯示回饋。
3. 增加 RemoteCommand 操作者 (user_id) 紀錄與狀態列舉擴充 (superseded)。
4. 修復機台列表「最後頁面」欄位對照錯誤,同步更新 Machine Model 與 API 規格。
5. 優化遠端指令中心 UI:放大卡片字體、調整側面欄間距,符合極簡奢華風規範。
6. 更新 API 技術規格書 (SKILL.md) 與 config/api-docs.php,補全所有機台代碼 (66-611) 與指令。
7. 補全繁體中文、英文、日文多語系翻譯檔案。
2026-04-02 14:57:41 +08:00
e7ad7e3dc3 [FEAT] 整合遠端管理指揮中心與 UI 佈局優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 48s
1. 實作「遠端管理指揮中心」,整合重啟、結帳、鎖定、找零、出貨等指令至單一介面。
2. 對接 B010 心跳 API 與 B017 庫存 API,實作異步指令下發與效期/批號同步邏輯。
3. 修正 sidebar-menu.blade.php 中的舊版路由連結,解決 RouteNotFoundException 錯誤。
4. 修正 index.blade.php 中的 AJAX 請求名稱,補上 admin. 前綴以符合路由分群。
5. 優化主內容區頂部間距,將 pt-10 縮減為 pt-5,提昇介面緊湊度。
2026-04-01 16:59:29 +08:00
3dbb394862 [REFACTOR] 移除機台管理與庫存設定中的貨道同步套用功能與日誌面板冗餘分頁
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
1. 移除 MachineController 貨道更新 API 的 apply_all_same_product 驗證規則。
2. 簡化 MachineService 的 updateSlot 邏輯,取消「同步套用至同機台其他相同商品」的批次異動功能以確保資料準確性。
3. 清理 index.blade.php 機台管理頁面中的「貨道狀態 (Slot Status)」分頁、相關 Alpine.js 函式與專用的貨道編輯 Modal。
4. 修正 stock.blade.php 庫存管理介面,移除編輯 Modal 內的同步切換開關。
2026-04-01 16:10:40 +08:00
969e4df629 [STYLE] 修復機台庫存管理功能並全面升級極簡奢華風 UI
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
1. [FIX] 修復 MachineController 500 錯誤:注入缺失的 MachineService 執行個體。
2. [STYLE] 貨道卡片重構:改為垂直堆疊佈局,移除冗餘標籤,並優化庫存 (x/y) 與效期格式。
3. [STYLE] 極致化間距調優:壓縮全域 Padding 與 Gap,並將貨道編號絕對定位於頂部,提升顯示密度。
4. [FIX] 穩定性修復:解決 Alpine.js 在返回列表時的 selectedMachine 空值存取報錯。
5. [STYLE] UI 細節修飾:隱藏輸入框微調箭頭,強化編號字體粗細與位置精準度。
6. [DOCS] 翻譯同步:更新 zh_TW, en, ja 翻譯檔中關於庫存與貨道的語系 Key。
7. [FEAT] 整合遠端管理模組:新增並導航至 resources/views/admin/remote/stock.blade.php。
2026-04-01 15:26:21 +08:00
7c47ad67fa [FIX] 解決與 demo 分支的合併衝突
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m9s
2026-04-01 13:03:08 +08:00
953d6a41f3 [FEAT] 優化廣告與機台管理介面及效能
1. [廣告管理] 修復編輯素材時刪除按鈕顯示邏輯並優化預覽功能。
2. [廣告管理] 修正請求回傳格式為 JSON,解決 AJAX 解析錯誤。
3. [機台管理] 實作 Alpine.js 無感頁籤切換(機台列表與效期管理)。
4. [機台管理] 移除冗餘返回按鈕,改為動態標題與頁籤重設邏輯。
5. [機台管理] 統一後端查詢,減少切換分頁時的延遲感。
6. [商品管理] 支援商品圖片 WebP 自動轉換,並調整上傳大小限制 (10MB)。
7. [UI] 修正多個管理模組的 JS 時序競爭與 Preline HSSelect 重置問題。
2026-04-01 13:01:45 +08:00
2e49129d77 [FEAT] 重構子帳號角色管理至子帳號頁籤,並全面標準化商品與機台模組 UI 樣式
Detail:
- 將「子帳號角色」從全域資料設定移入子帳號管理頁籤項下
- 移除冗餘的子帳號角色獨立權限,整合入子帳號管理權限
- 標準化商品列表與分類列表的圖示容器、懸停變色與奢華風陰影
- 修正分類名稱 (Category Name) 在 zh_TW 中的多語系缺漏
- 同步優化機台列表與權限管理介面的圖示交互效果
2026-04-01 09:53:30 +08:00
08fc86d3f8 [STYLE] 商品管理與分類管理 UI 標準化,補全多語系翻譯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 50s
2026-04-01 09:50:57 +08:00
e27eee78f5 [STYLE] 標準化商品管理與廣告彈窗 UI,完善商品分類多語系 CRUD 功能
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
2026-04-01 08:38:03 +08:00
759fae4380 [FEAT]:新增 Demo Day PPTX 自動生成腳本與相關套件 2026-03-31 16:26:13 +08:00
54d62c5378 [FEAT] 實作機台廣告管理模組與多語系支援
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m7s
1. 新增廣告管理列表與機台配置介面,包含多語系 (zh_TW, en, ja) 與完整 CRUD
2. 實作基於 Alpine 的廣告素材預覽輪播功能
3. 優化廣告素材下拉選單,強制綁定所屬公司以達成多租戶資料隔離
4. 重構廣告配置中廣告影片的縮圖渲染邏輯,移除 <video> 標籤以大幅提升頁面載入速度與節省頻寬
5. 放寬個人檔案頭像上傳限制,支援 WebP 格式
2026-03-31 13:30:41 +08:00
d14eda7d69 [FIX] 修復商品多語系儲存與讀取錯誤、新增自動語系名稱顯示、補強商品規格欄位及密碼顯示切換功能
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
2026-03-30 17:11:15 +08:00
9bbfaa39e6 [FEAT] 整合機台設定之機台權限管理功能,優化篩選器佈局並修復 Alpine.js 語法錯誤
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 50s
2026-03-30 16:37:18 +08:00
2c9dc793d7 [FEAT] 機台權限功能增強、UI 輕巧化與多語系優化
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
2026-03-30 16:15:04 +08:00
f3b2c3e018 [FIX] 遷移機台授權為獨立模組:修復變數命名、補齊多語系並強化多租戶數據隔離
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 54s
2026-03-30 15:30:46 +08:00
44ef355c54 [FEAT] 完善機台授權模組:新增搜尋過濾功能、機台資訊排版優化、更換圖格裝飾並完成後端效能優化。
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
2026-03-30 13:48:22 +08:00
ea0333d77e [STYLE] 系統用語標準化與客戶管理 UI 點選透明度優化 (所屬單位 -> 公司名稱、姓名 -> 名稱)
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 59s
2026-03-30 10:16:05 +08:00
e780e195e2 style(UI): 微調放大客戶管理中業務類型的字體大小
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 56s
2026-03-30 09:14:52 +08:00
fdd3589d7b feat(Admin/Company): 擴充業務類型與合約期間功能,補齊多語系翻譯詞條
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m9s
2026-03-30 09:08:02 +08:00
c875ab7d29 [FEAT] 移除「商品狀態」冗餘模組、優化麵包屑導航與完善帳號角色過濾邏輯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s
2026-03-27 16:53:43 +08:00
740eaa30b7 [FEAT] 優化後端帳號權限邏輯、開發商品管理功能及聯絡資訊 UI 改版
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
2026-03-27 13:43:08 +08:00
8ec5473ec7 [FEAT] 商品管理模組重構、UI 清晰度優化與多語系標籤字體調整 2026-03-26 17:32:15 +08:00
ac51027dda [DOCS] 建立獨立 API 技術規格技能 (api-technical-specs) 並精簡 IoT 通訊規範
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 47s
2026-03-26 15:03:00 +08:00
17b5c1a316 [DOCS] 現代化 IoT API (B010) 通訊協議與文件中心 UI/UX 重整 2026-03-26 14:59:49 +08:00
7883a755d2 [STYLE] 統一機台所屬單位用語,並實裝「系統」預設值邏輯 🦾⚙️
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-26 13:21:25 +08:00
03f8fdb654 [STYLE] 調整機台新增表單:將公司設為選填並強化型號必填標示 🦾⚙️
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m2s
2026-03-26 13:16:55 +08:00
f60e5a9c72 [FEAT] 優化機台 API 通訊識別、補齊前端必填驗證、並配置 Demo 站隊列自動化部署 🦾🚀
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 49s
2026-03-26 13:09:48 +08:00
19076c363c [STYLE] 更新系統 Logo 為無損圓形去背版並補齊登入頁繁體中文翻譯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
2026-03-26 09:22:07 +08:00
675e285e8c [STYLE] 補齊帳號管理按鈕 Tooltip 翻譯語系
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-25 17:19:23 +08:00
b7ff8ac01c [FEAT] 完善帳號管理狀態切換功能、優化多語系提示與 UI 樣式一致性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 42s
2026-03-25 17:16:41 +08:00
129 changed files with 16103 additions and 3010 deletions

View File

@@ -72,7 +72,7 @@ trigger: always_on
### 5.1 初始角色建立 ### 5.1 初始角色建立
當系統管理員為新客戶(該租戶尚未有任何角色)建立第一個帳號時,應遵循以下邏輯: 當系統管理員為新客戶(該租戶尚未有任何角色)建立第一個帳號時,應遵循以下邏輯:
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null``is_system = 0`)中選取一個作為基礎。 1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null``is_system = 1`)中選取一個作為基礎,但必須排除「超級管理員 (`super-admin`)」
2. **自動克隆**:系統會將該範本的權限內容複製一份至該租戶下。 2. **自動克隆**:系統會將該範本的權限內容複製一份至該租戶下。
3. **統一命名**:克隆後的角色名稱在該租戶公司內應統一命名為**「管理員」**。 3. **統一命名**:克隆後的角色名稱在該租戶公司內應統一命名為**「管理員」**。
4. **帳號綁定**:該新客戶帳號將被指派至此新建立的「管理員」角色。 4. **帳號綁定**:該新客戶帳號將被指派至此新建立的「管理員」角色。

View File

@@ -15,6 +15,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 觸發詞 / 情境 | 對應 Skill | 路徑 | | 觸發詞 / 情境 | 對應 Skill | 路徑 |
|---|---|---| |---|---|---|
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` | | 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` |
| B010, B017, B600, B055, API 規格, 通訊協議, 狀態碼, 頁面碼, 範例, JSON | **API 技術規格與通訊協議規範** | `.agents/skills/api-technical-specs/SKILL.md` |
| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` | | 介面, 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` |
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` | | RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |

View File

@@ -0,0 +1,260 @@
---
name: API 技術規格與通訊協議規範
description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與後端 (Cloud) 通訊的 API 細節、參數命名規則、狀態代碼對照與認證機制,作為系統開發的唯一規格來源。
---
# API 技術規格與通訊協議規範 (API Technical Specs)
本文件集中定義所有機台與雲端通訊的 API 規格,確保硬體端與軟體端在資料交換格式與業務定義上保持完全一致。
## 1. 核心命名原則
- **語意化優先**:捨棄舊版 M_ 前綴,統一使用 snake_case (如 firmware_version)。
- **類型嚴格**:文件定義的類型 (Integer, Float, String) 必須在後端 Model 與前端文件中心嚴格遵守。
## 2. 身份認證 (Authentication)
本系統採用兩階段認證模式:
### 2.1 維運人員認證 (User Authentication)
- **核發端點**B000 (登入)。
- **使用端點**B014 (參數下載)。
- **方式**:使用 Laravel Sanctum 核發之 **User Token**
- **Header**`Authorization: Bearer <user_token>`
### 2.2 機台通訊認證 (Machine Authentication)
- **適用 API**B010, B012, B013, B600 等後續通訊。
- **方式**:使用機台專屬之 **api_token**
- **Header**`Authorization: Bearer <api_token>`
---
## 3. 機台核心 API (IoT Endpoints)
### 3.1 B000: 機台本地管理員同步登入
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點。
- **URL**: POST /api/v1/app/admin/login/B000
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
| Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
| Su_Password | String | 是 | 密碼 | password123 |
| ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
| type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
- **Response Body:**
> [!IMPORTANT]
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| message | String | 驗證結果 (Success 或 Failed) | Success |
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
---
### 3.2 B005: 廣告清單同步
用於機台端獲取目前應播放的廣告檔案 URL 清單。
- **URL**: GET /api/v1/app/machine/ad/B005
- **Request Body:** 無 (GET 請求)
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| data | Array | 廣告物件陣列 | [{"t070v04": "https://..."}] |
**data 陣列內部欄位:**
- t070v01: 廣告名稱 (Name)
- t070v02: 播放長度 (Duration) — 秒數,若後台未設定,預設為 15 秒。
- t070v03: 廣告位置 (Position/Flag) — (3: 待機廣告, 1: 販賣頁, 2: 來店禮)。
- t070v04: 廣告 URL。
- t070v05: 播放順位 (Sort Order)。
---
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
#### B009 權限驗證邏輯 (RBAC Compliance)
系統會依據 account 欄位進行三層式權限核查:
1. **系統層 (System Admin)**:當 company_id 為 null 時,具備全局管理權限,直接放行。
2. **公司層 (Company Admin)**:當 is_admin 為 true 時,檢查機台的 company_id 是否與該帳號一致。
3. **人員層 (Operator/User)**:當帳號僅為一般人員時,檢查 machine_user 授權表,確認該帳號有被分配至此機台。
- **URL**: PUT /api/v1/app/products/supplementary/B009
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| account | String | 是 | 操作人員帳號 | 0999123456 |
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
- **data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| tid | Integer | 貨道編號 (Slot No) | 1 |
| t060v00 | String | 商品資料庫 ID (或是 Barcode) | "1" |
| num | Integer | 實體剩餘庫存數量 | 10 |
| type | Integer | 貨道物理類型 (1: 履帶, 2: 彈簧)。若未提供,預設為 1。 | 1 |
> [!TIP]
> **自動化上限同步邏輯**
> 當後端收到 B009 時,會根據 type 自動從該商品的配置中選取 spring_limit 或 track_limit 並自動更新該貨道的 max_stock 欄位。機台端無需手動計算上限。
- **Response Body (Success 200):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 同步是否成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| message | String | 回應訊息 | Slot report synchronized success |
| status | String | 固定回傳 49 代表同步完成 | "49" |
---
### 3.4 B010: 心跳上報與狀態同步
用於確認機台在線狀態、更新感測數據、提交事件日誌並獲取雲端指令。
- **URL**: POST /api/v1/app/machine/status/B010
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| current_page | Integer | 是 | 當前頁面代碼 (見下表) | 1 |
| firmware_version | String | 是 | 韌體版本號 | 1.0.5 |
| model | String | 是 | 機台型號 | STAR-V1 |
| temperature | Float | 否 | 環境溫度 | 25.5 |
| door_status | Integer | 否 | 門狀態 (0:關 / 1:開) | 0 |
| log | String | 否 | 事件日誌簡述 | Door opened |
| log_level | String | 否 | info, warn, error | info |
| log_payload | Object | 否 | 額外日誌 JSON 對象 | {"code":500} |
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否處理成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| message | String | 回應訊息 | OK |
| status | String | **雲端指令代碼** (見下表) | 49 |
#### B010 代碼對照表
**頁面代碼 (current_page)**
- 0: 離線 / 1: 主頁面 / 2: 販賣頁 / 3: 管理頁
- 4: 補貨頁 / 5: 教學頁 / 6: 購買中 / 7: 鎖定頁
- 60: 出貨成功 / 61: 貨道測試 / 62: 付款選擇
- 63: 等待付款 / 64: 出貨 / 65: 收據簽單
- 66: 通行碼 / 67: 取貨碼 / 68: 訊息顯示
- 69: 取消購買 / 610: 購買結束 / 611: 來店禮
- 612: 出貨失敗
**雲端指令代碼 (status)**
- 49: reload B017 (貨道同步)
- 51: reboot (重啟系統)
- 60: reboot card machine (刷卡機重啟)
- 61: checkout (觸發結帳)
- 70: unlock (解鎖)
- 71: lock (鎖定)
- 85: reload B0552 (遠端出貨)
---
### 3.5 B012: 商品配置與商品主檔同步 (Unified Sync)
用於機台端獲取目前所有可販售商品的詳細配置。App 端應依據呼叫的方法決定數據處理方式。
- **URL**: GET|PATCH /api/v1/app/machine/products/B012
- **Authentication**: Bearer Token (Header)
- **Request Body:** 無 (由 Token 自動識別機台)
#### 運作邏輯 (Client-side Logic):
- **GET**:執行 **全量同步**。App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll() 以確保與伺服器完全一致。
- **PATCH**:執行 **增量更新**。App 於收到成功回應後,僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。
| 欄位 | 型別 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| t060v00 | String | 商品資料庫 ID | "1" |
| t060v01 | String | 商品名稱 | 可口可樂 330ml |
| t060v01_en | String | 英文名稱 | Coca Cola |
| t060v01_jp | String | 日文名稱 | コカコーラ |
| t060v03 | String | 商品規格/簡述 | Cold Drink |
| t060v06 | String | 圖片 URL | https://.../coke.png |
| t060v09 | Float | 標準零售價 | 25.0 |
| t060v11 | Integer | **貨道庫存上限** (預設履帶) | 10 |
| t060v30 | Float | 會員價 | 20.0 |
| t063v03 | Float | 本機銷售價格 (同定價) | 25.0 |
| t060v40 | String | 行銷計畫 (Marketing Plan) | Buy 1 Get 1 |
| t060v41 | String | 物料編碼 (Material Code) | SKU-001 |
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
| track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 |
---
### 3.6 B013: 機台故障與異常狀態上報 (Error/Status Report)
用於接收機台發出的即時硬體狀態代碼(如卡貨、門未關),並自動由雲端後端翻譯為易讀日誌。
- **URL**: POST /api/v1/app/machine/error/B013
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| tid | Integer | 否 | 涉及之貨道編號 (Slot No) | 12 |
| error_code | String | 是 | 硬體狀態代碼 (4 位 16 進位) | "0403" |
- **回應 (Success 202):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求已接收 | true |
| code | Integer | 200 | 200 |
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0402** | Dispense successful | info | 出貨成功 |
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
| **0202** | Product empty | warning | 貨道缺貨 |
| **0412** | Elevator rise error | error | 昇降機上升異常 |
| **0415** | Pickup door error | error | 取貨門異常 |
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
| **5403** | Elevator failure | error | 昇降系統故障 |
---
### 3.7 B014: 機台參數與金鑰下載 (Config Download)
用於機台引導階段 (Provisioning),向雲端請求支付金鑰、發票設定及機台正式 API Token。
- **URL**: POST /api/v1/app/machine/setting/B014
- **Authentication**: **User Token** (Sanctum Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
- **Response Body (Success 200):**
| 欄位 (Key) | 說明 | 備註 |
| :--- | :--- | :--- |
| **t050v01** | 機台序號 | 即 machine_id |
| **api_token** | **機台正式 Token** | 初始化後應存於本地,後續 API 認證用 |
| **t050v41** | 玉山特店編號 | ESUN Merchant ID |
| **t050v42** | 玉山終端編號 | ESUN Terminal ID |
| **t050v43** | 玉山 Hash Key | ESUN Hash |
| **t050v34** | 發票特店 ID | Invoice Merchant ID |
| **t050v35** | 發票 Hash Key | Invoice Key |
| **t050v36** | 發票 Hash IV | Invoice IV |
| **TP_APP_ID** | 趨勢支付 AppID | TrendPay ID |
| **TP_APP_KEY** | 趨勢支付 Key | TrendPay Key |
> [!CAUTION]
> **安全性規範**B014 會回傳敏感金鑰與正式 Token背景必須強制進行 RBAC 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。

View File

@@ -60,4 +60,19 @@ public function handle(MachineService $service): void
- [ ] 是否使用了 `ApiResponse` Trait - [ ] 是否使用了 `ApiResponse` Trait
- [ ] 業務邏輯是否已封裝至 `App\Services` - [ ] 業務邏輯是否已封裝至 `App\Services`
- [ ] 是否使用了 Redis Queue 進行非同步處理? - [ ] 是否使用了 Redis Queue 進行非同步處理?
- [ ] 是否在 API 層級進行了基礎的參數驗證?
## 6. API 規格定義 (API Specifications)
> [!IMPORTANT]
> **規格分離原則**:本技能僅規範「通訊處理邏輯」。關於具體的 API 欄位、參數命名、狀態代碼對照與範例,請務必參閱專屬技能規範:
> - **[API 技術規格與通訊協議規範](file:///home/mama/projects/star-cloud/.agents/skills/api-technical-specs/SKILL.md)**
### 常見端點處理模式
1. **B010 (心跳)**:高頻點,必須進入 Redis Queue。更新 `last_heartbeat_at` 與感測器快照。
2. **B600 (交易)**:高價值點,必須進入任務隊列並支援重試。建立 `Transaction` 紀錄。
3. **B017 (貨道)**:回覆較大資料量,應確保 Service 層具備緩存 (Cache) 機制。
---
> [!CAUTION]
> **身份識別機制**:禁止在 Body 傳輸 `machine``key`。系統強制透過 `Bearer Token` 識別並自動關聯資料。

View File

@@ -181,7 +181,7 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
### 搜尋式下拉選單 (Searchable Select) - 【進階推薦】 ### 搜尋式下拉選單 (Searchable Select) - 【進階推薦】
- **組件**: `<x-searchable-select />` - **組件**: `<x-searchable-select />`
- **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:所屬單位、機台編號)。 - **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:公司名稱、機台編號)。
- **奢華特徵**: - **奢華特徵**:
- **動態旋轉箭頭**: 透過 `::after` 偽元素實作,選單展開時箭頭執行 `300ms` 的 180 度旋轉動畫。 - **動態旋轉箭頭**: 透過 `::after` 偽元素實作,選單展開時箭頭執行 `300ms` 的 180 度旋轉動畫。
- **即時過濾**: 輸入關鍵字即時隱藏不匹配項。 - **即時過濾**: 輸入關鍵字即時隱藏不匹配項。

View File

@@ -96,6 +96,7 @@ jobs:
php artisan optimize:clear && php artisan optimize:clear &&
php artisan optimize && php artisan optimize &&
php artisan view:cache && php artisan view:cache &&
php artisan queue:restart &&
php artisan db:seed --class=RoleSeeder --force && php artisan db:seed --class=RoleSeeder --force &&
php artisan db:seed --class=AdminUserSeeder --force php artisan db:seed --class=AdminUserSeeder --force
" "

1
.gitignore vendored
View File

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

View File

@@ -0,0 +1,270 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineAdvertisement;
use App\Models\System\Advertisement;
use App\Models\System\Company;
use App\Traits\ImageHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class AdvertisementController extends AdminController
{
use ImageHandler;
public function index(Request $request)
{
$user = auth()->user();
$tab = $request->input('tab', 'list');
// Tab 1: 廣告列表
$advertisements = Advertisement::with('company')->latest()->paginate(10);
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
$allAds = Advertisement::playing()->get();
// Tab 2: 機台廣告設置 (所需資料)
// 取得使用者有權限的機台列表 (已透過 Global Scope 過濾)
$machines = Machine::select('id', 'name', 'serial_no', 'company_id')->get();
$companies = $user->isSystemAdmin() ? Company::orderBy('name')->get() : collect();
return view('admin.ads.index', [
'advertisements' => $advertisements,
'machines' => $machines,
'tab' => $tab,
'allAds' => $allAds,
'companies' => $companies,
]);
}
/**
* 素材 CRUD: 儲存廣告
*/
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'type' => 'required|in:image,video',
'duration' => 'required|in:15,30,60',
'file' => [
'required',
'file',
'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi',
$request->type === 'image' ? 'max:10240' : 'max:51200', // Image 10MB, Video 50MB
],
'company_id' => 'nullable|exists:companies,id',
'start_at' => 'nullable|date',
'end_at' => 'nullable|date|after_or_equal:start_at',
]);
$user = auth()->user();
$file = $request->file('file');
if ($request->type === 'image') {
$path = $this->storeAsWebp($file, 'ads');
} else {
$path = $file->store('ads', 'public');
}
if ($user->isSystemAdmin()) {
$companyId = $request->filled('company_id') ? $request->company_id : null;
} else {
$companyId = $user->company_id;
}
$advertisement = Advertisement::create([
'company_id' => $companyId,
'name' => $request->name,
'type' => $request->type,
'duration' => (int) $request->duration,
'url' => Storage::disk('public')->url($path),
'is_active' => true,
'start_at' => $request->start_at,
'end_at' => $request->end_at,
]);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Advertisement created successfully.'),
'data' => $advertisement
]);
}
return redirect()->back()->with('success', __('Advertisement created successfully.'));
}
public function update(Request $request, Advertisement $advertisement)
{
$rules = [
'name' => 'required|string|max:255',
'type' => 'required|in:image,video',
'duration' => 'required|in:15,30,60',
'is_active' => 'boolean',
'company_id' => 'nullable|exists:companies,id',
'start_at' => 'nullable|date',
'end_at' => 'nullable|date|after_or_equal:start_at',
];
if ($request->hasFile('file')) {
$rules['file'] = [
'file',
'mimes:jpeg,png,jpg,gif,webp,mp4,mov,avi',
$request->type === 'image' ? 'max:10240' : 'max:51200',
];
}
$request->validate($rules);
$data = $request->only(['name', 'type', 'duration', 'start_at', 'end_at']);
$data['is_active'] = $request->has('is_active');
$user = auth()->user();
if ($user->isSystemAdmin()) {
$data['company_id'] = $request->filled('company_id') ? $request->company_id : null;
}
if ($request->hasFile('file')) {
// 刪除舊檔案
// 處理 URL 可能包含 storage 或原始路徑的情況
$oldPath = str_replace(Storage::disk('public')->url(''), '', $advertisement->url);
// 去除開頭可能的斜線
$oldPath = ltrim($oldPath, '/');
Storage::disk('public')->delete($oldPath);
// 存入新檔案
$file = $request->file('file');
if ($request->type === 'image') {
$path = $this->storeAsWebp($file, 'ads');
} else {
$path = $file->store('ads', 'public');
}
$data['url'] = Storage::disk('public')->url($path);
}
$advertisement->update($data);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Advertisement updated successfully.'),
'data' => $advertisement
]);
}
return redirect()->back()->with('success', __('Advertisement updated successfully.'));
}
public function destroy(Request $request, Advertisement $advertisement)
{
// 檢查是否有機台正投放中
if ($advertisement->machineAdvertisements()->exists()) {
return redirect()->back()->with('error', __('Cannot delete advertisement being used by machines.'));
}
// 刪除實體檔案
$path = str_replace(Storage::disk('public')->url(''), '', $advertisement->url);
Storage::disk('public')->delete($path);
$advertisement->delete();
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Advertisement deleted successfully.')
]);
}
return redirect()->back()->with('success', __('Advertisement deleted successfully.'));
}
/**
* AJAX: 取得特定機台的廣告投放清單
*/
public function getMachineAds(Machine $machine)
{
$assignments = MachineAdvertisement::where('machine_id', $machine->id)
->with('advertisement')
->orderBy('sort_order', 'asc')
->get()
->groupBy('position');
return response()->json([
'success' => true,
'data' => $assignments
]);
}
/**
* 投放廣告至機台
*/
public function assign(Request $request)
{
$request->validate([
'machine_id' => 'required|exists:machines,id',
'advertisement_id' => 'required|exists:advertisements,id',
'position' => 'required|in:vending,visit_gift,standby',
'sort_order' => 'nullable|integer',
]);
// If sort_order is not provided, append to the end of the current position list
$newSortOrder = $request->sort_order;
if (is_null($newSortOrder)) {
$newSortOrder = MachineAdvertisement::where('machine_id', $request->machine_id)
->where('position', $request->position)
->max('sort_order') + 1;
}
MachineAdvertisement::updateOrCreate(
[
'machine_id' => $request->machine_id,
'position' => $request->position,
'advertisement_id' => $request->advertisement_id,
],
[
'sort_order' => $newSortOrder,
]
);
return response()->json([
'success' => true,
'message' => __('Advertisement assigned successfully.')
]);
}
/**
* 重新排序廣告播放順序
*/
public function reorderAssignments(Request $request)
{
$request->validate([
'assignment_ids' => 'required|array',
'assignment_ids.*' => 'exists:machine_advertisements,id'
]);
foreach ($request->assignment_ids as $index => $id) {
MachineAdvertisement::where('id', $id)->update(['sort_order' => $index]);
}
return response()->json([
'success' => true,
'message' => __('Order updated successfully.')
]);
}
/**
* 移除廣告投放
*/
public function removeAssignment($id)
{
$assignment = MachineAdvertisement::findOrFail($id);
$assignment->delete();
return response()->json([
'success' => true,
'message' => __('Assignment removed successfully.')
]);
}
}

View File

@@ -23,6 +23,12 @@ class MachinePhotoController extends Controller
'machine_id' => $machine->id, 'machine_id' => $machine->id,
'files' => $request->allFiles() 'files' => $request->allFiles()
]); ]);
$request->validate([
'machine_image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
'machine_image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
'machine_image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
]);
try { try {
$images = $machine->images ?? []; $images = $machine->images ?? [];

View File

@@ -25,7 +25,7 @@ class MachineSettingController extends AdminController
public function index(Request $request): View public function index(Request $request): View
{ {
$tab = $request->input('tab', 'machines'); $tab = $request->input('tab', 'machines');
$per_page = $request->input('per_page', 20); $per_page = $request->input('per_page', 10);
$search = $request->input('search'); $search = $request->input('search');
// 1. 處理機台清單 (Machines Tab) // 1. 處理機台清單 (Machines Tab)
@@ -36,16 +36,37 @@ class MachineSettingController extends AdminController
->orWhere('serial_no', 'like', "%{$search}%"); ->orWhere('serial_no', 'like', "%{$search}%");
}); });
} }
$machines = $machineQuery->latest()->paginate($per_page, ['*'], 'machines_page')->withQueryString(); $machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
// 2. 處理型號清單 (Models Tab) // 2. 處理型號清單 (Models Tab)
$modelQuery = MachineModel::query()->withCount('machines'); $modelQuery = MachineModel::query()->withCount('machines');
if ($tab === 'models' && $search) { if ($tab === 'models' && $search) {
$modelQuery->where('name', 'like', "%{$search}%"); $modelQuery->where('name', 'like', "%{$search}%");
} }
$models_list = $modelQuery->latest()->paginate($per_page, ['*'], 'models_page')->withQueryString(); $models_list = $modelQuery->latest()->paginate($per_page)->withQueryString();
// 3. 基礎下拉資料 (用於新增/編輯機台的彈窗) // 3. 處理機台權限 (Permissions Tab) - 僅顯示 is_admin 帳號
$users_list = null;
if ($tab === 'permissions') {
$userQuery = \App\Models\System\User::query()
->where('is_admin', true)
->with(['company', 'machines']);
if ($search) {
$userQuery->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%");
});
}
if ($request->filled('company_id')) {
$userQuery->where('company_id', $request->company_id);
}
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
}
// 4. 基礎下拉資料 (用於新增/編輯機台的彈窗)
$models = MachineModel::select('id', 'name')->get(); $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();
@@ -53,6 +74,7 @@ class MachineSettingController extends AdminController
return view('admin.basic-settings.machines.index', compact( return view('admin.basic-settings.machines.index', compact(
'machines', 'machines',
'models_list', 'models_list',
'users_list',
'models', 'models',
'paymentConfigs', 'paymentConfigs',
'companies', 'companies',
@@ -68,11 +90,11 @@ class MachineSettingController extends AdminController
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'serial_no' => 'required|string|unique:machines,serial_no', 'serial_no' => 'required|string|unique:machines,serial_no',
'company_id' => 'required|exists:companies,id', 'company_id' => 'nullable|exists:companies,id',
'machine_model_id' => 'required|exists:machine_models,id', 'machine_model_id' => 'required|exists:machine_models,id',
'payment_config_id' => 'nullable|exists:payment_configs,id', 'payment_config_id' => 'nullable|exists:payment_configs,id',
'location' => 'nullable|string|max:255', 'location' => 'nullable|string|max:255',
'images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048', 'images.*' => 'image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
]); ]);
$imagePaths = []; $imagePaths = [];
@@ -83,7 +105,7 @@ class MachineSettingController extends AdminController
} }
$machine = Machine::create(array_merge($validated, [ $machine = Machine::create(array_merge($validated, [
'status' => 'offline', 'api_token' => \Illuminate\Support\Str::random(60),
'creator_id' => auth()->id(), 'creator_id' => auth()->id(),
'updater_id' => auth()->id(), 'updater_id' => auth()->id(),
'card_reader_seconds' => 30, // 預設值 'card_reader_seconds' => 30, // 預設值
@@ -119,6 +141,7 @@ class MachineSettingController extends AdminController
try { try {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'serial_no' => 'sometimes|required|string|unique:machines,serial_no,' . $machine->id,
'card_reader_seconds' => 'required|integer|min:0', 'card_reader_seconds' => 'required|integer|min:0',
'payment_buffer_seconds' => 'required|integer|min:0', 'payment_buffer_seconds' => 'required|integer|min:0',
'card_reader_checkout_time_1' => 'nullable|string', 'card_reader_checkout_time_1' => 'nullable|string',
@@ -139,6 +162,12 @@ class MachineSettingController extends AdminController
'machine_model_id' => 'required|exists:machine_models,id', 'machine_model_id' => 'required|exists:machine_models,id',
'payment_config_id' => 'nullable|exists:payment_configs,id', 'payment_config_id' => 'nullable|exists:payment_configs,id',
'location' => 'nullable|string|max:255', 'location' => 'nullable|string|max:255',
'image_0' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
'image_1' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
'image_2' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240',
'remove_image_0' => 'nullable|boolean',
'remove_image_1' => 'nullable|boolean',
'remove_image_2' => 'nullable|boolean',
]); ]);
// 僅限系統管理員可修改公司 // 僅限系統管理員可修改公司
@@ -154,37 +183,71 @@ class MachineSettingController extends AdminController
throw $e; throw $e;
} }
$machine->update(array_merge($validated, [ // 排除虛擬欄位 (圖片上傳、移除標記),這些欄位不在資料表內
$dataToUpdate = \Illuminate\Support\Arr::except($validated, [
'image_0', 'image_1', 'image_2',
'remove_image_0', 'remove_image_1', 'remove_image_2'
]);
$machine->update(array_merge($dataToUpdate, [
'updater_id' => auth()->id(), 'updater_id' => auth()->id(),
])); ]));
// 處理圖片更新 (支援 3 個獨立槽位) // 處理圖片更新 (支援 3 個獨立槽位: image_0, image_1, image_2)
if ($request->hasFile('images')) { $currentImages = $machine->images ?? [];
$currentImages = $machine->images ?? []; $updated = false;
$newImages = $request->file('images');
$updated = false;
foreach ($newImages as $index => $file) { for ($i = 0; $i < 3; $i++) {
// 限制 3 個槽位 (0, 1, 2) $inputName = "image_$i";
if ($index < 0 || $index > 2) continue; $removeName = "remove_image_$i";
// 刪除該槽位的舊圖 // 如果有新圖片上傳
if (isset($currentImages[$index]) && !empty($currentImages[$index])) { if ($request->hasFile($inputName)) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$index]); // 刪除舊圖
if (isset($currentImages[$i]) && !empty($currentImages[$i])) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]);
} }
// 儲存新圖
// 處理並儲存新圖 $currentImages[$i] = $this->storeAsWebp($request->file($inputName), 'machines');
$currentImages[$index] = $this->storeAsWebp($file, 'machines');
$updated = true; $updated = true;
}
// 否則,如果有刪除標記
elseif ($request->input($removeName) === '1') {
if (isset($currentImages[$i]) && !empty($currentImages[$i])) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($currentImages[$i]);
unset($currentImages[$i]);
$updated = true;
}
} }
}
if ($updated) { if ($updated) {
ksort($currentImages); ksort($currentImages);
$machine->update(['images' => array_values($currentImages)]); $machine->update(['images' => array_values($currentImages)]);
}
} }
return redirect()->route('admin.basic-settings.machines.index') return redirect()->route('admin.basic-settings.machines.index')
->with('success', __('Machine settings updated successfully.')); ->with('success', __('Machine settings updated successfully.'));
} }
public function regenerateToken(Request $request, $serial): \Illuminate\Http\JsonResponse
{
$machine = Machine::where('serial_no', $serial)->firstOrFail();
$newToken = \Illuminate\Support\Str::random(60);
$machine->update(['api_token' => $newToken]);
Log::info('Machine API Token Regenerated', [
'machine_id' => $machine->id,
'serial_no' => $machine->serial_no,
'user_id' => auth()->id()
]);
return response()->json([
'success' => true,
'message' => __('API Token regenerated successfully.'),
'api_token' => $newToken
]);
}
} }

View File

@@ -14,7 +14,8 @@ class CompanyController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$query = Company::query()->withCount(['users', 'machines']); $query = Company::query()->withCount(['users', 'machines'])
->with(['contracts.creator:id,name']);
// 搜尋 // 搜尋
if ($search = $request->input('search')) { if ($search = $request->input('search')) {
@@ -48,13 +49,20 @@ class CompanyController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'code' => 'required|string|max:50|unique:companies,code', 'code' => 'required|string|max:50|unique:companies,code',
'original_type' => 'required|string|in:buyout,lease',
'tax_id' => 'nullable|string|max:50', 'tax_id' => 'nullable|string|max:50',
'contact_name' => 'nullable|string|max:255', 'contact_name' => 'nullable|string|max:255',
'contact_phone' => 'nullable|string|max:50', 'contact_phone' => 'nullable|string|max:50',
'contact_email' => 'nullable|email|max:255', 'contact_email' => 'nullable|email|max:255',
'valid_until' => 'nullable|date', 'start_date' => 'required|date',
'end_date' => 'nullable|date',
'warranty_start_date' => 'nullable|date',
'warranty_end_date' => 'nullable|date',
'software_start_date' => 'nullable|date',
'software_end_date' => 'nullable|date',
'status' => 'required|boolean', 'status' => 'required|boolean',
'note' => 'nullable|string', 'note' => 'nullable|string',
'settings' => 'nullable|array',
// 帳號相關欄位 (可選) // 帳號相關欄位 (可選)
'admin_username' => 'nullable|string|max:255|unique:users,username', 'admin_username' => 'nullable|string|max:255|unique:users,username',
'admin_password' => 'nullable|string|min:8', 'admin_password' => 'nullable|string|min:8',
@@ -62,17 +70,44 @@ class CompanyController extends Controller
'admin_role' => 'nullable|string|exists:roles,name', 'admin_role' => 'nullable|string|exists:roles,name',
]); ]);
// 確保 settings 中的值為布林值
if (isset($validated['settings'])) {
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
}
DB::transaction(function () use ($validated) { DB::transaction(function () use ($validated) {
$company = Company::create([ $company = Company::create([
'name' => $validated['name'], 'name' => $validated['name'],
'code' => $validated['code'], 'code' => $validated['code'],
'original_type' => $validated['original_type'],
'current_type' => $validated['original_type'], // 新增時同步
'tax_id' => $validated['tax_id'] ?? null, 'tax_id' => $validated['tax_id'] ?? null,
'contact_name' => $validated['contact_name'] ?? null, 'contact_name' => $validated['contact_name'] ?? null,
'contact_phone' => $validated['contact_phone'] ?? null, 'contact_phone' => $validated['contact_phone'] ?? null,
'contact_email' => $validated['contact_email'] ?? null, 'contact_email' => $validated['contact_email'] ?? null,
'valid_until' => $validated['valid_until'] ?? null, 'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'warranty_start_date' => $validated['warranty_start_date'] ?? null,
'warranty_end_date' => $validated['warranty_end_date'] ?? null,
'software_start_date' => $validated['software_start_date'] ?? null,
'software_end_date' => $validated['software_end_date'] ?? null,
'status' => $validated['status'], 'status' => $validated['status'],
'note' => $validated['note'] ?? null, 'note' => $validated['note'] ?? null,
'settings' => $validated['settings'] ?? [],
]);
// 記錄合約歷程
$company->contracts()->create([
'type' => $company->original_type,
'start_date' => $company->start_date,
'end_date' => $company->end_date,
'warranty_start_date' => $company->warranty_start_date,
'warranty_end_date' => $company->warranty_end_date,
'software_start_date' => $company->software_start_date,
'software_end_date' => $company->software_end_date,
'note' => __('Initial contract registration'),
'creator_id' => auth()->id(),
]); ]);
// 如果有填寫帳號資訊,則建立管理員帳號 // 如果有填寫帳號資訊,則建立管理員帳號
@@ -123,20 +158,70 @@ class CompanyController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'code' => 'required|string|max:50|unique:companies,code,' . $company->id, 'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
'current_type' => 'required|string|in:buyout,lease',
'tax_id' => 'nullable|string|max:50', 'tax_id' => 'nullable|string|max:50',
'contact_name' => 'nullable|string|max:255', 'contact_name' => 'nullable|string|max:255',
'contact_phone' => 'nullable|string|max:50', 'contact_phone' => 'nullable|string|max:50',
'contact_email' => 'nullable|email|max:255', 'contact_email' => 'nullable|email|max:255',
'valid_until' => 'nullable|date', 'start_date' => 'required|date',
'end_date' => 'nullable|date',
'warranty_start_date' => 'nullable|date',
'warranty_end_date' => 'nullable|date',
'software_start_date' => 'nullable|date',
'software_end_date' => 'nullable|date',
'status' => 'required|boolean', 'status' => 'required|boolean',
'note' => 'nullable|string', 'note' => 'nullable|string',
'settings' => 'nullable|array',
]); ]);
$company->update($validated); // 確保 settings 中的值為布林值,避免 JSON 存儲為字串導致前端判斷錯誤
if (isset($validated['settings'])) {
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
}
DB::transaction(function () use ($validated, $company) {
$company->update($validated);
// 記錄合約歷程
$company->contracts()->create([
'type' => $company->current_type,
'start_date' => $company->start_date,
'end_date' => $company->end_date,
'warranty_start_date' => $company->warranty_start_date,
'warranty_end_date' => $company->warranty_end_date,
'software_start_date' => $company->software_start_date,
'software_end_date' => $company->software_end_date,
'note' => $validated['note'] ?? __('Contract information updated'),
'creator_id' => auth()->id(),
]);
});
// 分支邏輯:若停用客戶,連帶停用其所有帳號
if ($validated['status'] == 0) {
$company->users()->update(['status' => 0]);
}
return redirect()->back()->with('success', __('Customer updated successfully.')); return redirect()->back()->with('success', __('Customer updated successfully.'));
} }
/**
* 切換客戶狀態
*/
public function toggleStatus(Company $company)
{
$newStatus = $company->status == 1 ? 0 : 1;
$company->update(['status' => $newStatus]);
// 若切換為停用,同步更新所有旗下帳號
if ($newStatus == 0) {
$company->users()->update(['status' => 0]);
return redirect()->back()->with('success', __('Customer and associated accounts disabled successfully.'));
}
return redirect()->back()->with('success', __('Customer enabled successfully.'));
}
/** /**
* Remove the specified resource from storage. * Remove the specified resource from storage.
*/ */
@@ -146,6 +231,11 @@ class CompanyController extends Controller
return redirect()->back()->with('error', __('Cannot delete company with active accounts.')); return redirect()->back()->with('error', __('Cannot delete company with active accounts.'));
} }
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重新命名唯一欄位
$timestamp = now()->getTimestamp();
$company->code = $company->code . '.deleted.' . $timestamp;
$company->save();
$company->delete(); $company->delete();
return redirect()->back()->with('success', __('Customer deleted successfully.')); return redirect()->back()->with('success', __('Customer deleted successfully.'));

View File

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

View File

@@ -25,15 +25,6 @@ class DataConfigController extends Controller
]); ]);
} }
// 管理者可賣商品
public function adminProducts()
{
return view('admin.placeholder', [
'title' => '商品狀態',
'description' => '管理者商品銷售權限',
]);
}
// 子帳號管理 // 子帳號管理
public function subAccounts() public function subAccounts()

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Admin\Machine;
use App\Http\Controllers\Admin\AdminController;
use App\Models\System\Company;
use App\Models\Machine\Machine;
use App\Models\System\User;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class MachinePermissionController extends AdminController
{
/**
* 顯示機台權限管理列表
*/
public function index(Request $request): View
{
$per_page = $request->input('per_page', 10);
$search = $request->input('search');
$company_id = $request->input('company_id');
$currentUser = auth()->user();
// 僅列出租戶中具有「is_admin」標記的角色帳號以供分配
$userQuery = User::query()
->with(['machines' => function($query) {
$query->withoutGlobalScope('machine_access')
->select('machines.id', 'machines.name', 'machines.serial_no');
}])
->whereNotNull('company_id');
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入)
if (!$currentUser->isSystemAdmin()) {
$userQuery->where('company_id', $currentUser->company_id);
} elseif ($company_id) {
// 系統管理員的篩選邏輯
$userQuery->where('company_id', $company_id);
}
if ($search) {
$userQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
$companies = $currentUser->isSystemAdmin() ? Company::all() : collect();
return view('admin.machines.permissions', compact('users_list', 'companies'));
}
/**
* AJAX: 取得特定帳號的機台分配狀態
*/
public function getAccountMachines(User $user): JsonResponse
{
$currentUser = auth()->user();
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示)
$machines = Machine::withoutGlobalScope('machine_access')
->where('company_id', $user->company_id)
->get(['id', 'name', 'serial_no']);
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
return response()->json([
'user' => $user,
'machines' => $machines,
'assigned_ids' => $assignedIds
]);
}
/**
* AJAX: 儲存特定帳號的機台分配
*/
public function syncAccountMachines(Request $request, User $user): JsonResponse
{
$currentUser = auth()->user();
// 安全檢查
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$request->validate([
'machine_ids' => 'nullable|array',
'machine_ids.*' => 'exists:machines,id'
]);
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司 (使用 withoutGlobalScope 避免管理員自身權限影響驗證邏輯)
if ($request->has('machine_ids')) {
$machineIds = array_unique($request->machine_ids);
$validCount = Machine::withoutGlobalScope('machine_access')
->where('company_id', $user->company_id)
->whereIn('id', $machineIds)
->count();
if ($validCount !== count($machineIds)) {
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
}
}
$user->machines()->sync($request->machine_ids ?? []);
return response()->json([
'success' => true,
'message' => __('Permissions updated successfully'),
'assigned_machines' => $user->machines()->select('machines.id', 'machines.name', 'machines.serial_no')->get()
]);
}
}

View File

@@ -3,56 +3,52 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Services\Machine\MachineService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
class MachineController extends AdminController class MachineController extends AdminController
{ {
/** public function __construct(protected MachineService $machineService)
* 顯示所有機台列表或效期管理 {
*/ }
public function index(Request $request): View public function index(Request $request): View
{ {
$tab = $request->input('tab', 'list'); $per_page = $request->input('per_page', 10);
$per_page = $tab === 'list' ? $request->input('per_page', 10) : $request->input('per_page', 12);
$query = Machine::query(); $query = Machine::query();
// 搜尋:名稱或序號 // 搜尋:名稱或序號
if ($search = $request->input('search')) { if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) { $query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%") $q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%"); ->orWhere('serial_no', 'like', "%{$search}%");
}); });
} }
if ($tab === 'list') { // 預加載統計資料
$machines = $query->when($request->status, function ($query, $status) { $machines = $query->orderBy("last_heartbeat_at", "desc")
return $query->where('status', $status); ->orderBy("id", "desc")
}) ->paginate($per_page)
->latest() ->withQueryString();
->paginate($per_page)
->withQueryString();
return view('admin.machines.index', compact('machines', 'tab')); return view('admin.machines.index', compact('machines'));
} else { }
// 效期管理模式:獲取機台及其貨道統計
$machines = $query->withCount(['slots as total_slots'])
->withCount(['slots as expired_count' => function ($q) {
$q->where('expiry_date', '<', now()->toDateString());
}])
->withCount(['slots as pending_count' => function ($q) {
$q->whereNull('expiry_date');
}])
->withCount(['slots as warning_count' => function ($q) {
$q->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(7)->toDateString()]);
}])
->latest()
->paginate($per_page)
->withQueryString();
return view('admin.machines.index', compact('machines', 'tab')); /**
} * 更新機台基本資訊 (目前僅名稱)
*/
public function update(Request $request, Machine $machine)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$machine->update($validated);
return redirect()->route('admin.machines.index')
->with('success', __('Machine updated successfully.'));
} }
/** /**
@@ -60,9 +56,11 @@ class MachineController extends AdminController
*/ */
public function show(int $id): View public function show(int $id): View
{ {
$machine = Machine::with(['logs' => function ($query) { $machine = Machine::with([
$query->latest()->limit(50); 'logs' => function ($query) {
}])->findOrFail($id); $query->latest()->limit(50);
}
])->findOrFail($id);
return view('admin.machines.show', compact('machine')); return view('admin.machines.show', compact('machine'));
} }
@@ -74,16 +72,20 @@ class MachineController extends AdminController
public function logsAjax(Request $request, Machine $machine) public function logsAjax(Request $request, Machine $machine)
{ {
$per_page = $request->input('per_page', 20); $per_page = $request->input('per_page', 20);
$startDate = $request->get('start_date', now()->format('Y-m-d')); $startDate = $request->get('start_date');
$endDate = $request->get('end_date', now()->format('Y-m-d')); $endDate = $request->get('end_date');
$logs = $machine->logs() $logs = $machine->logs()
->when($request->level, function ($query, $level) { ->when($request->level, function ($query, $level) {
return $query->where('level', $level); return $query->where('level', $level);
}) })
->whereDate('created_at', '>=', $startDate) ->when($startDate, function ($query, $start) {
->whereDate('created_at', '<=', $endDate) return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->when($request->type, function ($query, $type) { ->when($request->type, function ($query, $type) {
return $query->where('type', $type); return $query->where('type', $type);
}) })
@@ -102,69 +104,6 @@ class MachineController extends AdminController
} }
/**
* AJAX: 取得特定帳號的機台分配狀態
*/
public function getAccountMachines(\App\Models\System\User $user)
{
$currentUser = auth()->user();
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// 取得該公司所有機台 (限定 company_id 以實作資料隔離)
$machines = Machine::where('company_id', $user->company_id)
->get(['id', 'name', 'serial_no']);
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
return response()->json([
'user' => $user,
'machines' => $machines,
'assigned_ids' => $assignedIds
]);
}
/**
* AJAX: 儲存特定帳號的機台分配
*/
public function syncAccountMachines(Request $request, \App\Models\System\User $user)
{
$currentUser = auth()->user();
// 安全檢查
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$request->validate([
'machine_ids' => 'nullable|array',
'machine_ids.*' => 'exists:machines,id'
]);
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司
if ($request->has('machine_ids')) {
$machineIds = array_unique($request->machine_ids);
$validCount = Machine::where('company_id', $user->company_id)
->whereIn('id', $machineIds)
->count();
if ($validCount !== count($machineIds)) {
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
}
}
$user->machines()->sync($request->machine_ids ?? []);
return response()->json([
'success' => true,
'message' => __('Permissions updated successfully.'),
'assigned_machines' => $user->machines()->select('machines.id', 'machines.name', 'machines.serial_no')->get()
]);
}
/** /**
* 機台使用率統計 * 機台使用率統計
*/ */
@@ -172,7 +111,7 @@ class MachineController extends AdminController
{ {
// 取得當前使用者有權限的所有機台 (已透過 Global Scope 過濾) // 取得當前使用者有權限的所有機台 (已透過 Global Scope 過濾)
$machines = Machine::all(); $machines = Machine::all();
$date = $request->get('date', now()->toDateString()); $date = $request->get('date', now()->toDateString());
$service = app(\App\Services\Machine\MachineService::class); $service = app(\App\Services\Machine\MachineService::class);
$fleetStats = $service->getFleetStats($date); $fleetStats = $service->getFleetStats($date);
@@ -194,8 +133,8 @@ class MachineController extends AdminController
*/ */
public function slotsAjax(Machine $machine) public function slotsAjax(Machine $machine)
{ {
$slots = $machine->slots()->with('product:id,name,image')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')->get(); $slots = $machine->slots()->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')->get();
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'machine' => $machine->only(['id', 'name', 'serial_no']), 'machine' => $machine->only(['id', 'name', 'serial_no']),
@@ -204,32 +143,24 @@ class MachineController extends AdminController
} }
/** /**
* AJAX: 更新貨道效期 * AJAX: 更新貨道資訊 (庫存、效期、批號)
*/ */
public function updateSlotExpiry(Request $request, Machine $machine) public function updateSlotExpiry(Request $request, Machine $machine)
{ {
$request->validate([ $validated = $request->validate([
'slot_no' => 'required|integer', 'slot_no' => 'required|integer',
'stock' => 'nullable|integer|min:0',
'expiry_date' => 'nullable|date', 'expiry_date' => 'nullable|date',
'apply_all_same_product' => 'boolean' 'batch_no' => 'nullable|string|max:50',
]); ]);
$slotNo = $request->slot_no; $this->machineService->updateSlot($machine, $validated, auth()->id());
$expiryDate = $request->expiry_date;
$applyAll = $request->apply_all_same_product ?? false;
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail(); session()->flash('success', __('Slot updated successfully.'));
$slot->update(['expiry_date' => $expiryDate]);
if ($applyAll && $slot->product_id) {
$machine->slots()
->where('product_id', $slot->product_id)
->update(['expiry_date' => $expiryDate]);
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => __('Expiry updated successfully.') 'message' => __('Slot updated successfully.')
]); ]);
} }

View File

@@ -22,6 +22,7 @@ class MaintenanceController extends Controller
$this->authorize('viewAny', MaintenanceRecord::class); $this->authorize('viewAny', MaintenanceRecord::class);
$query = MaintenanceRecord::with(['machine', 'user', 'company']) $query = MaintenanceRecord::with(['machine', 'user', 'company'])
->whereHas('machine') // 確保僅顯示該帳號「看得見」的機台紀錄,避開因權限隔離導致的 null 報錯
->latest('maintenance_at'); ->latest('maintenance_at');
// 搜尋邏輯 // 搜尋邏輯
@@ -73,6 +74,7 @@ class MaintenanceController extends Controller
'content' => 'nullable|string', 'content' => 'nullable|string',
'maintenance_at' => 'required|date', 'maintenance_at' => 'required|date',
'photos.*' => 'nullable|image|max:5120', // 每張上限 5MB 'photos.*' => 'nullable|image|max:5120', // 每張上限 5MB
'is_confirmed' => 'required|accepted',
]); ]);
$machine = Machine::findOrFail($validated['machine_id']); $machine = Machine::findOrFail($validated['machine_id']);
@@ -97,6 +99,7 @@ class MaintenanceController extends Controller
'content' => $validated['content'], 'content' => $validated['content'],
'photos' => $photoPaths, 'photos' => $photoPaths,
'maintenance_at' => $validated['maintenance_at'], 'maintenance_at' => $validated['maintenance_at'],
'is_confirmed' => true, // 既然通過驗證(accepted),則存為 true
]); ]);
return redirect()->route('admin.maintenance.index') return redirect()->route('admin.maintenance.index')

View File

@@ -24,10 +24,10 @@ class PermissionController extends Controller
$query->where('name', 'like', "%{$search}%"); $query->where('name', 'like', "%{$search}%");
} }
// 篩選:所屬單位 (僅限系統管理員) // 篩選:公司名稱 (僅限系統管理員)
if ($user->isSystemAdmin() && request()->filled('company_id')) { if ($user->isSystemAdmin() && request()->filled('company_id')) {
if (request()->company_id === 'system') { if (request()->company_id === 'system') {
$query->where('is_system', true); $query->whereNull('company_id');
} else { } else {
$query->where('company_id', request()->company_id); $query->where('company_id', request()->company_id);
} }
@@ -36,7 +36,9 @@ class PermissionController extends Controller
$roles = $query->latest()->paginate($per_page)->withQueryString(); $roles = $query->latest()->paginate($per_page)->withQueryString();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect(); $companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
// 權限遞迴約束:租戶管理員只能看到並指派自己擁有的權限 // 權限分組邏輯中的標題與過濾
$isSubAccountRoles = request()->routeIs('*.sub-account-roles');
$title = $isSubAccountRoles ? __('Sub Account Roles') : __('Role Settings');
$permissionQuery = \Spatie\Permission\Models\Permission::query(); $permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) { if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name')); $permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
@@ -44,17 +46,13 @@ class PermissionController extends Controller
// 權限分組邏輯 // 權限分組邏輯
$all_permissions = $permissionQuery->get() $all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(function($perm) { ->groupBy(function($perm) {
if (str_starts_with($perm->name, 'menu.')) { if (str_starts_with($perm->name, 'menu.')) {
// 主選單權限menu.xxx (兩段)
// 子選單權限menu.xxx.yyy (三段)
return 'menu'; return 'menu';
} }
return 'other'; return 'other';
}); });
// 根據路由決定標題
$title = request()->routeIs('*.sub-account-roles') ? __('Sub Account Roles') : __('Role Settings');
$currentUserRoleIds = $user->roles->pluck('id')->toArray(); $currentUserRoleIds = $user->roles->pluck('id')->toArray();
return view('admin.permission.roles', compact('roles', 'all_permissions', 'title', 'currentUserRoleIds', 'companies')); return view('admin.permission.roles', compact('roles', 'all_permissions', 'title', 'currentUserRoleIds', 'companies'));
@@ -77,7 +75,9 @@ class PermissionController extends Controller
$all_permissions = $permissionQuery->get()->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other'); $all_permissions = $permissionQuery->get()->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
$title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role'); $title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role');
$back_url = request()->routeIs('*.sub-account-roles.create') ? route('admin.data-config.sub-account-roles') : route('admin.permission.roles'); $back_url = request()->routeIs('*.sub-account-roles.create')
? route('admin.data-config.sub-accounts', ['tab' => 'roles'])
: route('admin.permission.roles');
return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url')); return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url'));
} }
@@ -98,6 +98,7 @@ class PermissionController extends Controller
// 權限分組邏輯 // 權限分組邏輯
$all_permissions = $permissionQuery->get() $all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(function($perm) { ->groupBy(function($perm) {
if (str_starts_with($perm->name, 'menu.')) { if (str_starts_with($perm->name, 'menu.')) {
return 'menu'; return 'menu';
@@ -109,7 +110,9 @@ class PermissionController extends Controller
$title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions'); $title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions');
// 麵包屑/返回路徑 // 麵包屑/返回路徑
$back_url = request()->routeIs('*.sub-account-roles.edit') ? route('admin.data-config.sub-account-roles') : route('admin.permission.roles'); $back_url = request()->routeIs('*.sub-account-roles.edit')
? route('admin.data-config.sub-accounts', ['tab' => 'roles'])
: route('admin.permission.roles');
return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url')); return view('admin.permission.roles-edit', compact('role', 'all_permissions', 'title', 'back_url'));
} }
@@ -158,8 +161,8 @@ class PermissionController extends Controller
$role->syncPermissions($perms); $role->syncPermissions($perms);
} }
$target_route = request()->routeIs('*.sub-account-roles.*') ? 'admin.data-config.sub-account-roles' : 'admin.permission.roles'; $target_route = request()->routeIs('*.sub-account-roles.*') ? route('admin.data-config.sub-accounts', ['tab' => 'roles']) : route('admin.permission.roles');
return redirect()->route($target_route)->with('success', __('Role created successfully.')); return redirect()->to($target_route)->with('success', __('Role created successfully.'));
} }
/** /**
@@ -195,11 +198,13 @@ class PermissionController extends Controller
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system; $is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
$role->update([ $updateData = [
'name' => $validated['name'], 'name' => $validated['name'],
'is_system' => $is_system, 'is_system' => $is_system,
'company_id' => $is_system ? null : $role->company_id, 'company_id' => $is_system ? null : $role->company_id,
]); ];
$role->update($updateData);
$perms = $validated['permissions'] ?? []; $perms = $validated['permissions'] ?? [];
@@ -217,8 +222,8 @@ class PermissionController extends Controller
} }
$role->syncPermissions($perms); $role->syncPermissions($perms);
$target_route = request()->routeIs('*.sub-account-roles.*') ? 'admin.data-config.sub-account-roles' : 'admin.permission.roles'; $target_route = request()->routeIs('*.sub-account-roles.*') ? route('admin.data-config.sub-accounts', ['tab' => 'roles']) : route('admin.permission.roles');
return redirect()->route($target_route)->with('success', __('Role updated successfully.')); return redirect()->to($target_route)->with('success', __('Role updated successfully.'));
} }
/** /**
@@ -242,46 +247,99 @@ class PermissionController extends Controller
$role->delete(); $role->delete();
if (request()->routeIs('*.sub-account-roles.*')) {
return redirect()->route('admin.data-config.sub-accounts', ['tab' => 'roles'])->with('success', __('Role deleted successfully.'));
}
return redirect()->back()->with('success', __('Role deleted successfully.')); return redirect()->back()->with('success', __('Role deleted successfully.'));
} }
// 帳號管理 // 帳號管理
public function accounts(Request $request) public function accounts(Request $request)
{ {
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']); $user = auth()->user();
$isSubAccountRoute = $request->routeIs('admin.data-config.sub-accounts');
$tab = $request->input('tab', 'accounts');
// 租戶隔離:如果不是系統管理員,則只看自己公司的成員 // 初始化變數
if (!auth()->user()->isSystemAdmin()) { $users = collect();
$query->where('company_id', auth()->user()->company_id); $roles = collect();
$paginated_roles = collect();
$all_permissions = collect();
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
if ($isSubAccountRoute && $tab === 'roles') {
// 處理角色分頁邏輯 (移植自 roles())
$per_page = $request->input('per_page', 10);
$roles_query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
if (!$user->isSystemAdmin()) {
$roles_query->where('company_id', $user->company_id);
}
if ($search = $request->input('search')) {
$roles_query->where('name', 'like', "%{$search}%");
}
if ($user->isSystemAdmin() && $request->filled('company_id')) {
if ($request->company_id === 'system') {
$roles_query->where('is_system', true);
} else {
$roles_query->where('company_id', $request->company_id);
}
}
$paginated_roles = $roles_query->latest()->paginate($per_page)->withQueryString();
// 權限分組邏輯
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
$all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
} else {
// 處理帳號名單邏輯
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
if (!$user->isSystemAdmin()) {
$query->where('company_id', $user->company_id);
}
if ($search = $request->input('search')) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($user->isSystemAdmin() && $request->filled('company_id')) {
if ($request->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $request->company_id);
}
}
$per_page = $request->input('per_page', 10);
$users = $query->latest()->paginate($per_page)->withQueryString();
$roles_query = \App\Models\System\Role::query();
if (!$user->isSystemAdmin()) {
$roles_query->forCompany($user->company_id);
}
$roles = $roles_query->get();
} }
// 搜尋 $title = $isSubAccountRoute ? __('Sub Account Management') : __('Account Management');
if ($search = $request->input('search')) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// 公司篩選 (僅限 super-admin) return view('admin.data-config.accounts', compact(
if (auth()->user()->isSystemAdmin() && $request->filled('company_id')) { 'users', 'companies', 'roles', 'paginated_roles', 'all_permissions', 'title', 'tab', 'currentUserRoleIds'
$query->where('company_id', $request->company_id); ));
}
$per_page = $request->input('per_page', 10);
$users = $query->latest()->paginate($per_page)->withQueryString();
$companies = auth()->user()->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
$roles_query = \App\Models\System\Role::query();
if (!auth()->user()->isSystemAdmin()) {
$roles_query->forCompany(auth()->user()->company_id);
}
$roles = $roles_query->get();
// 根據路由決定標題
$title = request()->routeIs('*.sub-accounts') ? __('Sub Account Management') : __('Account Management');
return view('admin.data-config.accounts', compact('users', 'companies', 'roles', 'title'));
} }
/** /**
@@ -315,8 +373,8 @@ class PermissionController extends Controller
// 驗證角色與公司的匹配性 (RBAC Safeguard) // 驗證角色與公司的匹配性 (RBAC Safeguard)
if ($company_id !== null) { if ($company_id !== null) {
// 如果是租戶帳號,不能選超級管理員角色 // 如果是租戶帳號,絕對不能指派超級管理員角色 (super-admin)
if ($role->is_system && $role->name === 'super-admin') { if ($role->name === 'super-admin') {
return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.')); return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.'));
} }
// 如果角色有特定的 company_id必須匹配 // 如果角色有特定的 company_id必須匹配
@@ -324,7 +382,7 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.')); return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.'));
} }
} else { } else {
// 如果是系統層級帳號,只能選系統角色 (is_system = 1) // 如果是系統層級帳號,只能選全域系統角色 (is_system = 1)
if (!$role->is_system) { if (!$role->is_system) {
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.')); return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
} }
@@ -363,6 +421,7 @@ class PermissionController extends Controller
'status' => $validated['status'], 'status' => $validated['status'],
'company_id' => $company_id, 'company_id' => $company_id,
'phone' => $validated['phone'] ?? null, 'phone' => $validated['phone'] ?? null,
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
]); ]);
$user->assignRole($role); $user->assignRole($role);
@@ -377,8 +436,8 @@ class PermissionController extends Controller
{ {
$user = \App\Models\System\User::findOrFail($id); $user = \App\Models\System\User::findOrFail($id);
if ($user->hasRole('super-admin')) { if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
return redirect()->back()->with('error', __('System super admin accounts cannot be modified via this interface.')); return redirect()->back()->with('error', __('System super admin accounts can only be modified by other super admins.'));
} }
$validated = $request->validate([ $validated = $request->validate([
@@ -408,7 +467,8 @@ class PermissionController extends Controller
// 驗證角色與公司的匹配性 (RBAC Safeguard) // 驗證角色與公司的匹配性 (RBAC Safeguard)
if ($user->id !== auth()->id()) { // 排除編輯自己 (super-admin 有特殊邏輯) if ($user->id !== auth()->id()) { // 排除編輯自己 (super-admin 有特殊邏輯)
if ($target_company_id !== null) { if ($target_company_id !== null) {
if ($roleObj->is_system && $roleObj->name === 'super-admin') { // 租戶層級排除 super-admin
if ($roleObj->name === 'super-admin') {
return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.')); return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.'));
} }
if ($roleObj->company_id !== null && $roleObj->company_id != $target_company_id) { if ($roleObj->company_id !== null && $roleObj->company_id != $target_company_id) {
@@ -416,7 +476,7 @@ class PermissionController extends Controller
} }
} else { } else {
if (!$roleObj->is_system) { if (!$roleObj->is_system) {
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.')); return redirect()->back()->with('error', __('Only global system roles can be assigned to platform administrative accounts.'));
} }
} }
} }
@@ -429,6 +489,18 @@ class PermissionController extends Controller
'phone' => $validated['phone'] ?? null, 'phone' => $validated['phone'] ?? null,
]; ];
// 只有系統管理員在編輯租戶帳號時,且該帳號原本不是管理員,才可能觸發標記(視需求而定)
// 這裡我們維持 storeAccount 的邏輯:如果是系統管理員幫公司「開站」或「首配」,才自動標記
// 為求嚴謹,我們檢查該公司是否已經有 is_admin如果沒有當前這個人可以是第一個
if (auth()->user()->isSystemAdmin() && !empty($validated['company_id']) && !$user->is_admin) {
$hasAdmin = \App\Models\System\User::where('company_id', $validated['company_id'])
->where('is_admin', true)
->exists();
if (!$hasAdmin) {
$updateData['is_admin'] = true;
}
}
if (auth()->user()->isSystemAdmin()) { if (auth()->user()->isSystemAdmin()) {
// 防止超級管理員不小心把自己綁定到租客公司或降級 // 防止超級管理員不小心把自己綁定到租客公司或降級
if ($user->id === auth()->id()) { if ($user->id === auth()->id()) {
@@ -458,6 +530,7 @@ class PermissionController extends Controller
'guard_name' => 'web', 'guard_name' => 'web',
'company_id' => $target_company_id, 'company_id' => $target_company_id,
'is_system' => false, 'is_system' => false,
'is_admin' => true,
]); ]);
$newRole->syncPermissions($roleObj->getPermissionNames()); $newRole->syncPermissions($roleObj->getPermissionNames());
$roleObj = $newRole; $roleObj = $newRole;
@@ -485,8 +558,8 @@ class PermissionController extends Controller
{ {
$user = \App\Models\System\User::findOrFail($id); $user = \App\Models\System\User::findOrFail($id);
if ($user->hasRole('super-admin')) { if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
return redirect()->back()->with('error', __('System super admin accounts cannot be deleted.')); return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.'));
} }
if ($user->id === auth()->id()) { if ($user->id === auth()->id()) {
@@ -503,4 +576,20 @@ class PermissionController extends Controller
return redirect()->back()->with('success', __('Account deleted successfully.')); return redirect()->back()->with('success', __('Account deleted successfully.'));
} }
public function toggleAccountStatus($id)
{
$user = \App\Models\System\User::findOrFail($id);
// 非超級管理員禁止切換 Super Admin 狀態
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
return back()->with('error', __('Only Super Admins can change other Super Admin status.'));
}
$user->status = $user->status ? 0 : 1;
$user->save();
$statusText = $user->status ? __('Enabled') : __('Disabled');
return back()->with('success', __('Account :name status has been changed to :status.', ['name' => $user->name, 'status' => $statusText]));
}
} }

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Product\ProductCategory;
use App\Models\System\Translation;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ProductCategoryController extends Controller
{
/**
* 顯示商品分類清單 (主要用於 AJAX 或內嵌在商品管理頁面)
*/
public function index(Request $request)
{
$user = auth()->user();
$query = ProductCategory::with(['translations']);
if ($user->isSystemAdmin() && $request->filled('company_id')) {
$query->where('company_id', $request->company_id);
}
$categories = $query->latest()->get();
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'data' => $categories
]);
}
return redirect()->route('admin.data-config.products.index', ['tab' => 'categories']);
}
/**
* 儲存新分類
*/
public function store(Request $request)
{
$validated = $request->validate([
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'company_id' => 'nullable|exists:companies,id',
]);
try {
DB::beginTransaction();
$dictKey = Str::uuid()->toString();
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
: auth()->user()->company_id;
// 儲存多語系翻譯
foreach ($request->names as $locale => $value) {
if (empty($value)) continue;
Translation::withoutGlobalScopes()->create([
'group' => 'category',
'key' => $dictKey,
'locale' => $locale,
'value' => $value,
'company_id' => $company_id,
]);
}
$category = ProductCategory::create([
'company_id' => $company_id,
'name' => $request->names['zh_TW'] ?? (collect($request->names)->first() ?? 'Untitled'),
'name_dictionary_key' => $dictKey,
]);
DB::commit();
return redirect()->back()->with('success', __('Category created successfully'));
} catch (\Exception $e) {
DB::rollBack();
return redirect()->back()->with('error', $e->getMessage())->withInput();
}
}
/**
* 更新分類
*/
public function update(Request $request, $id)
{
$category = ProductCategory::findOrFail($id);
$validated = $request->validate([
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
]);
try {
DB::beginTransaction();
$dictKey = $category->name_dictionary_key ?: Str::uuid()->toString();
$company_id = $category->company_id;
foreach ($request->names as $locale => $value) {
if (empty($value)) {
Translation::withoutGlobalScopes()->where([
'group' => 'category',
'key' => $dictKey,
'locale' => $locale
])->delete();
continue;
}
Translation::withoutGlobalScopes()->updateOrCreate(
[
'group' => 'category',
'key' => $dictKey,
'locale' => $locale,
],
[
'value' => $value,
'company_id' => $company_id,
]
);
}
$category->update([
'name' => $request->names['zh_TW'] ?? $category->name,
'name_dictionary_key' => $dictKey,
]);
DB::commit();
return redirect()->back()->with('success', __('Category updated successfully'));
} catch (\Exception $e) {
DB::rollBack();
return redirect()->back()->with('error', $e->getMessage())->withInput();
}
}
/**
* 刪除分類
*/
public function destroy($id)
{
try {
$category = ProductCategory::findOrFail($id);
// 檢查是否已有商品使用此分類
if ($category->products()->count() > 0) {
return redirect()->back()->with('error', __('Cannot delete category that has products. Please move products first.'));
}
if ($category->name_dictionary_key) {
Translation::withoutGlobalScopes()->where('group', 'category')->where('key', $category->name_dictionary_key)->delete();
}
$category->delete();
return redirect()->back()->with('success', __('Category deleted successfully'));
} catch (\Exception $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
}

View File

@@ -0,0 +1,345 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Product\Product;
use App\Models\Product\ProductCategory;
use App\Models\System\Company;
use App\Models\System\Translation;
use App\Traits\ImageHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ProductController extends Controller
{
use \App\Traits\ImageHandler;
public function index(Request $request)
{
$user = auth()->user();
$query = Product::with(['category.translations', 'translations', 'company']);
// 搜尋
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('spec', 'like', "%{$search}%");
});
}
// 分類篩選
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
$per_page = $request->input('per_page', 10);
$companyId = $user->company_id;
if ($user->isSystemAdmin()) {
if ($request->filled('company_id')) {
$companyId = $request->company_id;
$query->where('company_id', $companyId);
}
}
$products = $query->latest()->paginate($per_page)->withQueryString();
$categories = ProductCategory::with('translations')->get();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
// 系統管理員在過濾特定公司時,應顯示該公司的功能開關 (如物料代碼、點數規則)
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
$routeName = 'admin.data-config.products.index';
return view('admin.products.index', [
'products' => $products,
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
'routeName' => $routeName
]);
}
public function create(Request $request)
{
$user = auth()->user();
$categories = ProductCategory::with('translations')->get();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
// If system admin, check if company_id is provided in URL to get settings
$companyId = $request->query('company_id') ?? $user->company_id;
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
return view('admin.products.create', [
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
]);
}
public function edit($id)
{
$user = auth()->user();
// 繞過 TenantScoped 載入翻譯,確保系統管理員能看到租戶公司的翻譯資料
$product = Product::with(['company'])->findOrFail($id);
$product->setRelation('translations',
Translation::withoutGlobalScopes()
->where('group', 'product')
->where('key', $product->name_dictionary_key)
->get()
);
$categories = ProductCategory::with('translations')->get();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
// Use the product's company settings for editing
$companySettings = $product->company ? ($product->company->settings ?? []) : [];
return view('admin.products.edit', [
'product' => $product,
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'category_id' => 'nullable|exists:product_categories,id',
'manufacturer' => 'nullable|string|max:255',
'track_limit' => 'required|integer|min:1',
'spring_limit' => 'required|integer|min:1',
'price' => 'required|numeric|min:0',
'cost' => 'required|numeric|min:0',
'member_price' => 'required|numeric|min:0',
'metadata' => 'nullable|array',
'is_active' => 'nullable|boolean',
'company_id' => 'nullable|exists:companies,id',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
]);
try {
DB::beginTransaction();
$dictKey = \Illuminate\Support\Str::uuid()->toString();
// Determine company_id: prioritized from request (for sys admin) then from user
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
: auth()->user()->company_id;
// 儲存多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
foreach ($request->names as $locale => $name) {
if (empty($name)) continue;
Translation::withoutGlobalScopes()->create([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
'value' => $name,
'company_id' => $company_id,
]);
}
$imageUrl = null;
if ($request->hasFile('image')) {
$path = $this->storeAsWebp($request->file('image'), 'products');
$imageUrl = Storage::url($path);
}
$product = Product::create([
'company_id' => $company_id,
'category_id' => $request->category_id,
'name' => $request->names['zh_TW'] ?? (collect($request->names)->first() ?? 'Untitled'), // Fallback if zh_TW is missing
'name_dictionary_key' => $dictKey,
'image_url' => $imageUrl,
'barcode' => $request->barcode,
'spec' => $request->spec,
'manufacturer' => $request->manufacturer,
'track_limit' => $request->track_limit,
'spring_limit' => $request->spring_limit,
'price' => $request->price,
'cost' => $request->cost,
'member_price' => $request->member_price,
'metadata' => $request->metadata ?? [],
'is_active' => $request->boolean('is_active', true),
]);
DB::commit();
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Product created successfully'),
'data' => $product
]);
}
return redirect()->route('admin.data-config.products.index')->with('success', __('Product created successfully'));
} catch (\Exception $e) {
DB::rollBack();
if ($request->wantsJson()) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
}
return redirect()->back()->with('error', $e->getMessage())->withInput();
}
}
public function update(Request $request, $id)
{
$product = Product::findOrFail($id);
$validated = $request->validate([
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'category_id' => 'nullable|exists:product_categories,id',
'manufacturer' => 'nullable|string|max:255',
'track_limit' => 'required|integer|min:1',
'spring_limit' => 'required|integer|min:1',
'price' => 'required|numeric|min:0',
'cost' => 'required|numeric|min:0',
'member_price' => 'required|numeric|min:0',
'metadata' => 'nullable|array',
'is_active' => 'nullable|boolean',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
'remove_image' => 'nullable|boolean',
]);
try {
DB::beginTransaction();
$dictKey = $product->name_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
$company_id = $product->company_id;
// 更新或建立多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
foreach ($request->names as $locale => $name) {
if (empty($name)) {
Translation::withoutGlobalScopes()->where([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale
])->delete();
continue;
}
Translation::withoutGlobalScopes()->updateOrCreate(
[
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
],
[
'value' => $name,
'company_id' => $company_id,
]
);
}
$data = [
'category_id' => $request->category_id,
'name' => $request->names['zh_TW'] ?? ($product->name ?? 'Untitled'),
'name_dictionary_key' => $dictKey,
'barcode' => $request->barcode,
'spec' => $request->spec,
'manufacturer' => $request->manufacturer,
'track_limit' => $request->track_limit,
'spring_limit' => $request->spring_limit,
'price' => $request->price,
'cost' => $request->cost,
'member_price' => $request->member_price,
'metadata' => $request->metadata ?? [],
'is_active' => $request->boolean('is_active', true),
];
if ($request->hasFile('image')) {
// Delete old image
if ($product->image_url) {
$oldPath = str_replace('/storage/', '', $product->image_url);
Storage::disk('public')->delete($oldPath);
}
$path = $this->storeAsWebp($request->file('image'), 'products');
$data['image_url'] = Storage::url($path);
} elseif ($request->boolean('remove_image')) {
if ($product->image_url) {
$oldPath = str_replace('/storage/', '', $product->image_url);
Storage::disk('public')->delete($oldPath);
}
$data['image_url'] = null;
}
$product->update($data);
DB::commit();
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Product updated successfully'),
'data' => $product
]);
}
return redirect()->route('admin.data-config.products.index')->with('success', __('Product updated successfully'));
} catch (\Exception $e) {
DB::rollBack();
if ($request->wantsJson()) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
}
return redirect()->back()->with('error', $e->getMessage())->withInput();
}
}
public function toggleStatus($id)
{
try {
$product = Product::findOrFail($id);
$product->is_active = !$product->is_active;
$product->save();
$status = $product->is_active ? __('Enabled') : __('Disabled');
return redirect()->back()->with('success', __('Product status updated to :status', ['status' => $status]));
} catch (\Exception $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function destroy($id)
{
try {
$product = Product::findOrFail($id);
// 刪除與此商品關聯的翻譯資料(繞過 TenantScoped
if ($product->name_dictionary_key) {
Translation::withoutGlobalScopes()->where('key', $product->name_dictionary_key)->delete();
}
// Delete image
if ($product->image_url) {
$oldPath = str_replace('/storage/', '', $product->image_url);
Storage::disk('public')->delete($oldPath);
}
$product->delete();
return redirect()->back()->with('success', __('Product deleted successfully'));
} catch (\Exception $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class QrCodeController extends Controller
{
/**
* Generate a QR Code image.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function generate(Request $request)
{
$data = $request->query('data');
$size = $request->query('size', 250);
if (!$data) {
return response()->noContent();
}
// Generate SVG QR Code
$qrCode = QrCode::size($size)
->format('svg')
->margin(1)
->generate($data);
return response($qrCode)->header('Content-Type', 'image/svg+xml');
}
}

View File

@@ -4,69 +4,131 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\Machine\RemoteCommand;
use Illuminate\Support\Facades\Auth;
class RemoteController extends Controller class RemoteController extends Controller
{ {
// 機台庫存 /**
public function stock() * 遠端管理指揮中心
*/
public function index(Request $request)
{ {
return view('admin.placeholder', [ $machines = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
'title' => '遠端修改機台庫存', $selectedMachine = null;
'description' => '遠端修改機台庫存數量', $history = RemoteCommand::where('command_type', '!=', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get();
if ($request->has('machine_id')) {
$selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
$query->where('command_type', '!=', 'reload_stock')
->latest()
->limit(5);
}])->find($request->machine_id);
}
if ($request->ajax()) {
return response()->json([
'success' => true,
'machine' => $selectedMachine,
'commands' => $selectedMachine ? $selectedMachine->commands : []
]);
}
return view('admin.remote.index', [
'machines' => $machines,
'selectedMachine' => $selectedMachine,
'history' => $history,
'title' => __('Remote Command Center'),
'subtitle' => __('Execute maintenance and operational commands remotely')
]); ]);
} }
// 機台重啟 /**
public function restart() * 儲存遠端指令
*/
public function storeCommand(Request $request)
{ {
return view('admin.placeholder', [ $validated = $request->validate([
'title' => '遠端重啟機台', 'machine_id' => 'required|exists:machines,id',
'description' => '遠端重啟機台系統', 'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense',
'amount' => 'nullable|integer|min:0',
'slot_no' => 'nullable|string',
'note' => 'nullable|string|max:255',
]); ]);
$payload = [];
if ($validated['command_type'] === 'change') {
$payload['amount'] = $validated['amount'];
} elseif ($validated['command_type'] === 'dispense') {
$payload['slot_no'] = $validated['slot_no'];
}
// 指令去重:將同機台、同類型的 pending 指令標記為「已取代」
RemoteCommand::where('machine_id', $validated['machine_id'])
->where('command_type', $validated['command_type'])
->where('status', 'pending')
->update([
'status' => 'superseded',
'note' => __('Superseded by new command'),
'executed_at' => now(),
]);
RemoteCommand::create([
'machine_id' => $validated['machine_id'],
'user_id' => auth()->id(),
'command_type' => $validated['command_type'],
'payload' => $payload,
'status' => 'pending',
'note' => $validated['note'] ?? null,
]);
session()->flash('success', __('Command has been queued successfully.'));
if ($request->expectsJson()) {
return response()->json([
'success' => true,
'message' => __('Command has been queued successfully.')
]);
}
return redirect()->back();
} }
// 卡機重啟 /**
public function restartCardReader() * 機台庫存管理 (現有功能保留)
*/
public function stock(Request $request)
{ {
return view('admin.placeholder', [ $machines = Machine::withCount([
'title' => '遠端重啟刷卡機', 'slots as slots_count',
'description' => '遠端重啟刷卡機設備', 'slots as low_stock_count' => function ($query) {
]); $query->where('stock', '<=', 5);
} },
'slots as expiring_soon_count' => function ($query) {
$query->whereNotNull('expiry_date')
->where('expiry_date', '<=', now()->addDays(7))
->where('expiry_date', '>=', now()->startOfDay());
}
])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc')->get();
$history = RemoteCommand::where('command_type', 'reload_stock')->with(['machine', 'user'])->latest()->limit(50)->get();
// 遠端結帳 $selectedMachine = null;
public function checkout() if ($request->has('machine_id')) {
{ $selectedMachine = Machine::with(['slots.product', 'commands' => function($query) {
return view('admin.placeholder', [ $query->where('command_type', 'reload_stock')
'title' => '遠端結帳', ->latest()
'description' => '遠端執行結帳流程', ->limit(50);
]); }])->find($request->machine_id);
} }
// 遠端鎖定頁 return view('admin.remote.stock', [
public function lock() 'machines' => $machines,
{ 'selectedMachine' => $selectedMachine,
return view('admin.placeholder', [ 'history' => $history,
'title' => '遠端鎖定頁', 'title' => __('Stock & Expiry Management'),
'description' => '遠端鎖定機台頁面', 'subtitle' => __('Real-time monitoring and adjustment of cargo lane inventory and expiration dates')
]);
}
// 遠端找零
public function change()
{
return view('admin.placeholder', [
'title' => '遠端找零',
'description' => '遠端執行找零功能',
]);
}
// 遠端出貨
public function dispense()
{
return view('admin.placeholder', [
'title' => '遠端出貨',
'description' => '遠端控制商品出貨',
]); ]);
} }
} }

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Models\System\User;
use App\Models\Machine\Machine;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use App\Jobs\Machine\ProcessStateLog;
class MachineAuthController extends Controller
{
/**
* B000: 機台本機管理員/補貨員 離線同步登入驗證
* 這個 API 僅用於機台的 Java App 登入畫面驗證帳密。不必進入 Queue。
*/
public function loginB000(Request $request): JsonResponse
{
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
$validated = $request->validate([
'machine' => 'required|string',
'Su_Account' => 'required|string',
'Su_Password' => 'required|string',
'ip' => 'nullable|string',
'type' => 'nullable|string',
]);
// 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
if (!$machine) {
Log::warning("B000 機台登入失敗: 伺服器找不到該機台", [
'machine_serial' => $validated['machine']
]);
return response()->json(['message' => 'Failed']);
}
// 3. 透過帳號尋找使用者 (允許使用 username 或 email)
$user = User::where('username', $validated['Su_Account'])
->orWhere('email', $validated['Su_Account'])
->first();
// 4. 驗證密碼
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $validated['machine']
]);
// 寫入機台日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("Login failed: :account", ['account' => $validated['Su_Account']]),
'warning',
[],
'login'
);
return response()->json(['message' => 'Failed']);
}
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
$isAuthorized = true;
} elseif ($user->is_admin) {
if ($machine->company_id === $user->company_id) {
$isAuthorized = true;
}
} else {
if ($user->machines()->where('machine_id', $machine->id)->exists()) {
$isAuthorized = true;
}
}
if (!$isAuthorized) {
Log::warning("B000 機台登入失敗: 權限不足", [
'user_id' => $user->id,
'machine_id' => $machine->id
]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("Unauthorized login attempt: :account", ['account' => $user->username]),
'warning',
[],
'login'
);
return response()->json(['message' => 'Forbidden']);
}
// 6. 驗證完美通過!
Log::info("B000 機台登入成功", [
'account' => $user->username,
'machine' => $machine->serial_no
]);
// 寫入成功登入日誌
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
__("User logged in: :name", ['name' => $user->name ?? $user->username]),
'info',
[],
'login'
);
return response()->json([
'message' => 'Success',
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
]);
}
}

View File

@@ -5,10 +5,15 @@ namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat; use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus; use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory; use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
class MachineController extends Controller class MachineController extends Controller
{ {
@@ -18,16 +23,126 @@ class MachineController extends Controller
public function heartbeat(Request $request) public function heartbeat(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key
// === 狀態異動觸發 (Redis 快取免查 DB) ===
$cacheKey = "machine:{$machine->serial_no}:state";
$oldState = Cache::get($cacheKey);
$currentPage = $data['current_page'] ?? null;
$doorStatus = $data['door_status'] ?? null;
$firmwareVersion = $data['firmware_version'] ?? null;
$model = $data['model'] ?? null;
if ($currentPage !== null || $doorStatus !== null || $firmwareVersion !== null || $model !== null) {
// 更新目前狀態到 Redis (保存 1 天)
$newState = $oldState ?? [];
if ($currentPage !== null) $newState['current_page'] = $currentPage;
if ($doorStatus !== null) $newState['door_status'] = $doorStatus;
if ($firmwareVersion !== null) $newState['firmware_version'] = $firmwareVersion;
if ($model !== null) $newState['model'] = $model;
Cache::put($cacheKey, $newState, 86400);
// 若有歷史紀錄才進行比對 (避開 Cache Miss 造成的雪崩)
if ($oldState !== null) {
// 1. 判斷頁面是否變更
if ($currentPage !== null && (string)$currentPage !== (string)($oldState['current_page'] ?? '')) {
// 只記錄「絕對狀態」,配合 lang 中 "Page X" 的翻譯
ProcessStateLog::dispatch($machine->id, $machine->company_id, "Page {$currentPage}", 'info');
}
// 2. 判斷門禁是否變更 (0: 關閉, 1: 開啟)
if ($doorStatus !== null && (string)$doorStatus !== (string)($oldState['door_status'] ?? '')) {
$doorMessage = $doorStatus == 1 ? "Door Opened" : "Door Closed";
$doorLevel = 'info'; // 不論開關門皆為 info避免觸發異常狀態
ProcessStateLog::dispatch($machine->id, $machine->company_id, $doorMessage, $doorLevel);
}
// 3. 判斷韌體版本是否變更
if ($firmwareVersion !== null && (string)$firmwareVersion !== (string)($oldState['firmware_version'] ?? '')) {
$oldVersion = $oldState['firmware_version'] ?? 'Unknown';
// 直接在 Controller 進行翻譯並填值,確保儲存到 DB 的是最終正確字串
$versionMessage = __("Firmware updated to :version", ['version' => $firmwareVersion]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$versionMessage,
'info',
['old' => $oldVersion, 'new' => $firmwareVersion]
);
}
// 4. 判斷型號是否變更
if ($model !== null && (string)$model !== (string)($oldState['model'] ?? '')) {
$oldModel = $oldState['model'] ?? 'Unknown';
$modelMessage = __("Model changed to :model", ['model' => $model]);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$modelMessage,
'info',
['old' => $oldModel, 'new' => $model]
);
}
}
}
// 異步處理狀態更新 // 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data); ProcessHeartbeat::dispatch($machine->serial_no, $data);
// 取出待處理指令
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->pending()
->first();
$status = '49'; // 預設 49 (OK / No Command)
$message = 'OK';
if ($command) {
switch ($command->command_type) {
case 'reboot':
$status = '51';
$message = 'reboot';
break;
case 'reboot_card':
$status = '60';
$message = 'reboot card machine';
break;
case 'checkout':
$status = '61';
$message = 'checkout';
break;
case 'lock':
$status = '71';
$message = 'lock';
break;
case 'unlock':
$status = '70';
$message = 'unlock';
break;
case 'change':
$status = '82';
$message = $command->payload['amount'] ?? '0';
break;
case 'dispense':
$status = '85';
$message = $command->payload['slot_no'] ?? '';
break;
case 'reload_stock':
$status = '49';
$message = 'reload B017';
break;
}
// 標記為已發送 (sent)
$command->update(['status' => 'sent', 'executed_at' => now()]);
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
'message' => 'OK', 'message' => $message,
'status' => '49' // 某些硬體可能需要的成功碼 'status' => $status
], 202); // 202 Accepted ], 202); // 202 Accepted
} }
@@ -37,7 +152,7 @@ class MachineController extends Controller
public function recordRestock(Request $request) public function recordRestock(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no; $data['serial_no'] = $machine->serial_no;
\App\Jobs\Machine\ProcessRestockReport::dispatch($data); \App\Jobs\Machine\ProcessRestockReport::dispatch($data);
@@ -58,6 +173,12 @@ class MachineController extends Controller
$machine = $request->get('machine'); $machine = $request->get('machine');
$slots = $machine->slots()->with('product')->get(); $slots = $machine->slots()->with('product')->get();
// 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'sent')
->update(['status' => 'success', 'executed_at' => now()]);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'code' => 200, 'code' => 200,
@@ -69,6 +190,8 @@ class MachineController extends Controller
'capacity' => $slot->capacity, 'capacity' => $slot->capacity,
'price' => $slot->price, 'price' => $slot->price,
'status' => $slot->status, 'status' => $slot->status,
'expiry_date' => $slot->expiry_date,
'batch_no' => $slot->batch_no,
]; ];
}) })
]); ]);
@@ -80,7 +203,7 @@ class MachineController extends Controller
public function syncTimer(Request $request) public function syncTimer(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessTimerStatus::dispatch($machine->serial_no, $data); ProcessTimerStatus::dispatch($machine->serial_no, $data);
@@ -93,7 +216,7 @@ class MachineController extends Controller
public function syncCoinInventory(Request $request) public function syncCoinInventory(Request $request)
{ {
$machine = $request->get('machine'); $machine = $request->get('machine');
$data = $request->all(); $data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessCoinInventory::dispatch($machine->serial_no, $data); ProcessCoinInventory::dispatch($machine->serial_no, $data);
@@ -139,4 +262,280 @@ class MachineController extends Controller
] ]
]); ]);
} }
/**
* B005: Download Machine Advertisements (Synchronous)
*/
public function getAdvertisements(Request $request)
{
$machine = $request->get('machine');
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
->with([
'advertisement' => function ($query) {
$query->playing();
}
])
->get()
->filter(fn($ma) => $ma->advertisement !== null)
->map(function ($ma) {
// 定義顯示順序權重 (待機 > 購物 > 成功禮)
$posWeight = [
'standby' => 1,
'vending' => 2,
'visit_gift' => 3
];
// 為了相容現有機台 App 邏輯:
// App 讀取 t070v03 作為位置標籤 (flag)
// 1. HomeActivity (待機) 讀取 "3"
// 2. FontendActivity (販賣頁) 讀取 "1"
$posIdMap = [
'standby' => '3',
'vending' => '1',
'visit_gift' => '2'
];
return [
't070v01' => $ma->advertisement->name,
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
't070v05' => (string) $ma->sort_order,
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
'raw_sort' => (int) $ma->sort_order
];
})
->sortBy([
['raw_pos_weight', 'asc'],
['raw_sort', 'asc']
])
->values()
->map(function ($item) {
unset($item['raw_pos_weight'], $item['raw_sort']);
return $item;
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $advertisements->values()
]);
}
/**
* B009: Report Machine Slot List / Supplementary (Synchronous)
*/
public function reportSlotList(Request $request, \App\Services\Machine\MachineService $machineService)
{
$machine = $request->get('machine');
$payload = $request->all();
$account = $payload['account'] ?? null;
// 1. 驗證帳號是否存在 (驗證執行補貨的人員身分)
$user = User::where('username', $account)
->orWhere('email', $account)
->first();
if (!$user) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not found',
'status' => ''
], 403);
}
// 2. 階層式權限驗證 (遵循 RBAC 多租戶規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
// [系統層]:系統管理員可異動所有機台
$isAuthorized = true;
} elseif ($user->is_admin) {
// [公司層]:公司管理員需驗證此機台是否隸屬於該公司
$isAuthorized = ($machine->company_id === $user->company_id);
} else {
// [人員層]:一般人員需檢查 machine_user 授權表
$isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists();
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Unauthorized: Account not authorized for this machine',
'status' => ''
], 403);
}
// 3. 映射舊版機台回傳格式 (Map legacy machine format)
// 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array)
$legacyData = $payload['data'] ?? [];
if (Arr::isAssoc($legacyData)) {
$legacyData = [$legacyData];
}
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => $item['tid'] ?? null,
'product_id' => $item['t060v00'] ?? null,
'stock' => $item['num'] ?? 0,
'type' => $item['type'] ?? null,
];
}, $legacyData);
// 過濾無效資料 (Filter invalid entries)
$mappedSlots = array_filter($mappedSlots, fn($s) => $s['slot_no'] !== null);
// 同步處理更新庫存 (直接更新不進隊列)
$machineService->syncSlots($machine, $mappedSlots);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
]);
}
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request)
{
$machine = $request->get('machine');
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->with(['translations'])
->active()
->get()
->map(function ($product) {
// 提取多語系名稱 (Extract translations)
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
return [
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $products
]);
}
/**
* B013: Report Machine Hardware Error/Status (Asynchronous)
*/
public function reportError(Request $request)
{
$machine = $request->get('machine');
$data = $request->only(['tid', 'error_code']);
// 異步分派處理 (Dispatch to queue)
ProcessMachineError::dispatch($machine->serial_no, $data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Error report accepted',
], 202); // 202 Accepted
}
/**
* B014: Download Machine Settings & Config (Synchronous, Requires User Auth)
* 用於機台引導階段,同步金流、發票與機台專屬 API Token。
*/
public function getSettings(Request $request)
{
$serialNo = $request->input('machine');
$user = $request->user();
// 1. 查找機台 (忽略全局範圍以進行認領)
$machine = Machine::withoutGlobalScopes()
->with(['paymentConfig', 'company'])
->where('serial_no', $serialNo)
->first();
if (!$machine) {
return response()->json([
'success' => false,
'code' => 404,
'message' => 'Machine not found'
], 404);
}
// 2. 權限加強驗證 (RBAC)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
$isAuthorized = true;
} elseif ($machine->company_id === $user->company_id) {
// 公司管理員或已授權員工才能存取
if ($user->is_admin || $user->machines()->where('machine_id', $machine->id)->exists()) {
$isAuthorized = true;
}
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Forbidden: You do not have permission to configure this machine'
], 403);
}
// 3. 獲取關聯設定
$paymentSettings = $machine->paymentConfig->settings ?? [];
$companySettings = $machine->company->settings ?? [];
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
$data = [
't050v01' => $machine->serial_no,
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
// 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '',
't050v43' => $paymentSettings['esun_hash'] ?? '',
// 電子發票 (綠界)
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
't050v35' => $companySettings['invoice_hash_key'] ?? '',
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
't050v38' => $companySettings['invoice_email'] ?? '',
// 趨勢支付 (TrendPay/Greenpay)
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// 各類行動支付特店 ID
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
];
return response()->json([
'success' => true,
'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列
]);
}
} }

View File

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

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ApiDocsController extends Controller
{
/**
* Display the API documentation page.
*/
public function index()
{
$docs = config('api-docs');
return view('docs.api-docs', compact('docs'));
}
}

View File

@@ -48,7 +48,7 @@ class ProfileController extends Controller
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
{ {
$request->validate([ $request->validate([
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:1024'], 'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:1024'],
]); ]);
$user = $request->user(); $user = $request->user();

View File

@@ -26,7 +26,7 @@ class EnsureTenantAccess
return redirect()->route('login')->with('error', __('Your account is associated with a deactivated company.')); return redirect()->route('login')->with('error', __('Your account is associated with a deactivated company.'));
} }
if ($company->valid_until && $company->valid_until->isPast()) { if ($company->end_date && $company->end_date->isPast()) {
auth()->logout(); auth()->logout();
return redirect()->route('login')->with('error', __('Your company contract has expired.')); return redirect()->route('login')->with('error', __('Your company contract has expired.'));
} }

View File

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

View File

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

View File

@@ -38,7 +38,6 @@ class Machine extends Model
'serial_no', 'serial_no',
'model', 'model',
'location', 'location',
'status',
'current_page', 'current_page',
'door_status', 'door_status',
'temperature', 'temperature',
@@ -69,7 +68,88 @@ class Machine extends Model
'updater_id', 'updater_id',
]; ];
protected $appends = ['image_urls']; protected $appends = ['image_urls', 'calculated_status'];
/**
* 動態計算機台當前狀態
* 1. 離線 (offline):超過 30 秒未收到心跳
* 2. 異常 (error):在線但過去 15 分鐘內有錯誤/警告日誌
* 3. 在線 (online):正常在線
*/
public function getCalculatedStatusAttribute(): string
{
// 判定離線
if (!$this->last_heartbeat_at || $this->last_heartbeat_at->diffInSeconds(now()) >= 30) {
return 'offline';
}
// 判定異常 (檢查過去 15 分鐘內是否有 error 或 warning 日誌)
$hasRecentErrors = $this->logs()
->whereIn('level', ['error', 'warning'])
->where('created_at', '>=', now()->subMinutes(15))
->exists();
if ($hasRecentErrors) {
return 'error';
}
return 'online';
}
/**
* Scope: 判定在線 (30 秒內有心跳)
*/
public function scopeOnline($query)
{
return $query->where('last_heartbeat_at', '>=', now()->subSeconds(30));
}
/**
* Scope: 判定離線 (超過 30 秒未收到心跳或從未收到心跳)
*/
public function scopeOffline($query)
{
return $query->where(function ($q) {
$q->whereNull('last_heartbeat_at')
->orWhere('last_heartbeat_at', '<', now()->subSeconds(30));
});
}
/**
* Scope: 判定異常 (過去 15 分鐘內有錯誤或警告日誌)
*/
public function scopeHasError($query)
{
return $query->whereExists(function ($q) {
$q->select(\Illuminate\Support\Facades\DB::raw(1))
->from('machine_logs')
->whereColumn('machine_logs.machine_id', 'machines.id')
->whereIn('level', ['error', 'warning'])
->where('created_at', '>=', now()->subMinutes(15));
});
}
/**
* Scope: 判定運行中 (在線且無近期異常)
*/
public function scopeRunning($query)
{
return $query->online()->whereNotExists(function ($q) {
$q->select(\Illuminate\Support\Facades\DB::raw(1))
->from('machine_logs')
->whereColumn('machine_logs.machine_id', 'machines.id')
->whereIn('level', ['error', 'warning'])
->where('created_at', '>=', now()->subMinutes(15));
});
}
/**
* Scope: 判定異常在線 (在線且有近期異常)
*/
public function scopeErrorOnline($query)
{
return $query->online()->hasError();
}
protected $casts = [ protected $casts = [
'last_heartbeat_at' => 'datetime', 'last_heartbeat_at' => 'datetime',
@@ -106,6 +186,11 @@ class Machine extends Model
return $this->hasMany(MachineSlot::class); return $this->hasMany(MachineSlot::class);
} }
public function commands()
{
return $this->hasMany(RemoteCommand::class);
}
public function machineModel() public function machineModel()
{ {
return $this->belongsTo(MachineModel::class); return $this->belongsTo(MachineModel::class);
@@ -133,21 +218,20 @@ class Machine extends Model
'3' => 'Admin Page', '3' => 'Admin Page',
'4' => 'Replenishment Page', '4' => 'Replenishment Page',
'5' => 'Tutorial Page', '5' => 'Tutorial Page',
'60' => 'Purchasing', '6' => 'Purchasing',
'61' => 'Locked Page', '7' => 'Locked Page',
'62' => 'Dispense Failed', '60' => 'Dispense Success',
'301' => 'Slot Test', '61' => 'Slot Test',
'302' => 'Slot Test', '62' => 'Payment Selection',
'401' => 'Payment Selection', '63' => 'Waiting for Payment',
'402' => 'Waiting for Payment', '64' => 'Dispensing',
'403' => 'Dispensing', '65' => 'Receipt Printing',
'404' => 'Receipt Printing', '66' => 'Pass Code',
'601' => 'Pass Code', '67' => 'Pickup Code',
'602' => 'Pickup Code', '68' => 'Message Display',
'603' => 'Message Display', '69' => 'Cancel Purchase',
'604' => 'Cancel Purchase', '610' => 'Purchase Finished',
'605' => 'Purchase Finished', '611' => 'Welcome Gift',
'611' => 'Welcome Gift Status',
'612' => 'Dispense Failed', '612' => 'Dispense Failed',
]; ];

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models\Machine;
use App\Models\System\Advertisement;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MachineAdvertisement extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'advertisement_id',
'position',
'sort_order',
'start_at',
'end_at',
];
protected $casts = [
'sort_order' => 'integer',
'start_at' => 'datetime',
'end_at' => 'datetime',
];
/**
* Get the advertisement associated with this assignment.
*/
public function advertisement()
{
return $this->belongsTo(Advertisement::class);
}
/**
* Get the machine associated with this assignment.
*/
public function machine()
{
return $this->belongsTo(Machine::class);
}
}

View File

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

View File

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

View File

@@ -21,11 +21,13 @@ class MaintenanceRecord extends Model
'content', 'content',
'photos', 'photos',
'maintenance_at', 'maintenance_at',
'is_confirmed',
]; ];
protected $casts = [ protected $casts = [
'photos' => 'array', 'photos' => 'array',
'maintenance_at' => 'datetime', 'maintenance_at' => 'datetime',
'is_confirmed' => 'boolean',
]; ];
public function machine() public function machine()

View File

@@ -11,16 +11,16 @@ class RemoteCommand extends Model
protected $fillable = [ protected $fillable = [
'machine_id', 'machine_id',
'command', 'user_id',
'command_type',
'payload', 'payload',
'status', 'status',
'response_payload', 'ttl',
'executed_at', 'executed_at',
]; ];
protected $casts = [ protected $casts = [
'payload' => 'array', 'payload' => 'array',
'response_payload' => 'array',
'executed_at' => 'datetime', 'executed_at' => 'datetime',
]; ];
@@ -28,4 +28,17 @@ class RemoteCommand extends Model
{ {
return $this->belongsTo(Machine::class); return $this->belongsTo(Machine::class);
} }
public function user()
{
return $this->belongsTo(\App\Models\System\User::class);
}
/**
* Scope for pending commands
*/
public function scopePending($query)
{
return $query->where('status', 'pending')->orderBy('created_at', 'asc');
}
} }

View File

@@ -10,26 +10,43 @@ use App\Traits\TenantScoped;
class Product extends Model class Product extends Model
{ {
use HasFactory, SoftDeletes, TenantScoped; use HasFactory, SoftDeletes, TenantScoped;
/**
* Scope a query to only include active products.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
protected $fillable = [ protected $fillable = [
'company_id', 'company_id',
'category_id', 'category_id',
'name', 'name',
'sku', 'name_dictionary_key',
'barcode', 'barcode',
'spec',
'manufacturer',
'description', 'description',
'price', 'price',
'member_price',
'cost', 'cost',
'track_limit',
'spring_limit',
'type', 'type',
'image_url', 'image_url',
'status', 'status',
'name_dictionary_key', 'is_active',
'metadata', 'metadata',
]; ];
protected $casts = [ protected $casts = [
'price' => 'decimal:2', 'price' => 'decimal:2',
'member_price' => 'decimal:2',
'cost' => 'decimal:2', 'cost' => 'decimal:2',
'track_limit' => 'integer',
'spring_limit' => 'integer',
'is_active' => 'boolean',
'metadata' => 'array', 'metadata' => 'array',
]; ];
@@ -37,4 +54,40 @@ class Product extends Model
{ {
return $this->belongsTo(ProductCategory::class, 'category_id'); return $this->belongsTo(ProductCategory::class, 'category_id');
} }
/**
* 自動附加到 JSON/陣列輸出的屬性(供 Alpine.js 等前端使用)
*/
protected $appends = ['localized_name'];
/**
* 取得當前語系的商品名稱。
* 回退順序:當前語系 zh_TW name 欄位
*/
public function getLocalizedNameAttribute(): string
{
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
$locale = app()->getLocale();
// 先找當前語系
$translation = $this->translations->firstWhere('locale', $locale);
if ($translation) {
return $translation->value;
}
// 回退至 zh_TW
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
if ($fallback) {
return $fallback->value;
}
}
return $this->name ?? '';
}
/**
* Get the translations for the product name.
*/
public function translations()
{
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'name_dictionary_key')
->where('group', 'product');
}
} }

View File

@@ -17,8 +17,44 @@ class ProductCategory extends Model
'name_dictionary_key', 'name_dictionary_key',
]; ];
/**
* 自動附加到 JSON/陣列輸出
*/
protected $appends = ['localized_name'];
public function products() public function products()
{ {
return $this->hasMany(Product::class, 'category_id'); return $this->hasMany(Product::class, 'category_id');
} }
/**
* Get the translations for the category name.
*/
public function translations()
{
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'name_dictionary_key')
->where('group', 'category');
}
/**
* 取得當前語系的商品名稱。
* 回退順序:當前語系 zh_TW name 欄位
*/
public function getLocalizedNameAttribute(): string
{
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
$locale = app()->getLocale();
// 先找當前語系
$translation = $this->translations->firstWhere('locale', $locale);
if ($translation) {
return $translation->value;
}
// 回退至 zh_TW
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
if ($fallback) {
return $fallback->value;
}
}
return $this->name ?? '';
}
} }

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Models\System;
use App\Models\Machine\MachineAdvertisement;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Advertisement extends Model
{
use HasFactory, TenantScoped;
protected $fillable = [
'company_id',
'name',
'type',
'duration',
'url',
'is_active',
'start_at',
'end_at',
];
protected $casts = [
'duration' => 'integer',
'is_active' => 'boolean',
'start_at' => 'datetime',
'end_at' => 'datetime',
];
/**
* Get the machine assignments for this advertisement.
*/
public function machineAdvertisements()
{
return $this->hasMany(MachineAdvertisement::class);
}
/**
* Get the company that owns the advertisement.
*/
public function company()
{
return $this->belongsTo(Company::class);
}
/**
* Scope a query to only include active advertisements.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope a query to only include advertisements that should be playing now.
*/
public function scopePlaying($query)
{
$now = now();
return $query->where('is_active', true)
->where(function ($q) use ($now) {
$q->whereNull('start_at')
->orWhere('start_at', '<=', $now);
})
->where(function ($q) use ($now) {
$q->whereNull('end_at')
->orWhere('end_at', '>=', $now);
});
}
}

View File

@@ -16,20 +16,42 @@ class Company extends Model
protected $fillable = [ protected $fillable = [
'name', 'name',
'code', 'code',
'original_type',
'current_type',
'tax_id', 'tax_id',
'contact_name', 'contact_name',
'contact_phone', 'contact_phone',
'contact_email', 'contact_email',
'status', 'status',
'valid_until', 'start_date',
'end_date',
'warranty_start_date',
'warranty_end_date',
'software_start_date',
'software_end_date',
'note', 'note',
'settings',
]; ];
protected $casts = [ protected $casts = [
'valid_until' => 'date', 'start_date' => 'date:Y-m-d',
'end_date' => 'date:Y-m-d',
'warranty_start_date' => 'date:Y-m-d',
'warranty_end_date' => 'date:Y-m-d',
'software_start_date' => 'date:Y-m-d',
'software_end_date' => 'date:Y-m-d',
'status' => 'integer', 'status' => 'integer',
'settings' => 'array',
]; ];
/**
* Get the contract history for the company.
*/
public function contracts(): HasMany
{
return $this->hasMany(CompanyContract::class)->latest();
}
/** /**
* Get the users for the company. * Get the users for the company.
*/ */

View File

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

View File

@@ -7,9 +7,10 @@ use Illuminate\Database\Eloquent\Model;
class Translation extends Model class Translation extends Model
{ {
use HasFactory; use HasFactory, \App\Traits\TenantScoped;
protected $fillable = [ protected $fillable = [
'company_id',
'group', 'group',
'key', 'key',
'locale', 'locale',

View File

@@ -31,6 +31,7 @@ class User extends Authenticatable
'avatar', 'avatar',
'role', 'role',
'status', 'status',
'is_admin',
]; ];
/** /**
@@ -51,6 +52,7 @@ class User extends Authenticatable
protected $casts = [ protected $casts = [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'is_admin' => 'boolean',
]; ];
/** /**

View File

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

View File

@@ -4,11 +4,62 @@ namespace App\Services\Machine;
use App\Models\Machine\Machine; use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog; use App\Models\Machine\MachineLog;
use App\Models\Machine\RemoteCommand;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Carbon\Carbon; use Carbon\Carbon;
class MachineService class MachineService
{ {
/**
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
*/
public const ERROR_CODE_MAP = [
// 出貨狀態類 (Prefix: 04 - BUY_STATUS)
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'],
'0402' => ['label' => 'Dispense successful', 'level' => 'info'],
'0403' => ['label' => 'Slot jammed', 'level' => 'error'],
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'],
'0406' => ['label' => 'Slot not found', 'level' => 'error'],
'0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'],
'0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'],
'0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'],
'040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'],
'0410' => ['label' => 'Elevator rising', 'level' => 'info'],
'0411' => ['label' => 'Elevator descending', 'level' => 'info'],
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'],
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'],
'0414' => ['label' => 'Pickup door closed', 'level' => 'info'],
'0415' => ['label' => 'Pickup door error', 'level' => 'error'],
'0416' => ['label' => 'Delivery door opened', 'level' => 'info'],
'0417' => ['label' => 'Delivery door open error', 'level' => 'error'],
'0418' => ['label' => 'Delivering product', 'level' => 'info'],
'0419' => ['label' => 'Delivery door closed', 'level' => 'info'],
'0420' => ['label' => 'Delivery door close error', 'level' => 'error'],
'0421' => ['label' => 'Hopper empty', 'level' => 'warning'],
'0422' => ['label' => 'Hopper overheated', 'level' => 'warning'],
'0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'],
'0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'],
'0426' => ['label' => 'Microwave door opened', 'level' => 'info'],
'0427' => ['label' => 'Microwave door error', 'level' => 'error'],
'04FF' => ['label' => 'Dispense stopped', 'level' => 'info'],
// 貨道狀態類 (Prefix: 02 - SLOT_STATUS)
'0201' => ['label' => 'Slot normal', 'level' => 'info'],
'0202' => ['label' => 'Product empty', 'level' => 'warning'],
'0203' => ['label' => 'Slot empty', 'level' => 'warning'],
'0206' => ['label' => 'Slot not closed', 'level' => 'warning'],
'0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'],
'0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'],
'0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'],
'0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'],
// 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS)
'5400' => ['label' => 'Machine normal', 'level' => 'info'],
'5401' => ['label' => 'Elevator sensor error', 'level' => 'error'],
'5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'],
'5403' => ['label' => 'Elevator failure', 'level' => 'error'],
];
/** /**
* Update machine heartbeat and status. * Update machine heartbeat and status.
* *
@@ -21,15 +72,14 @@ class MachineService
return DB::transaction(function () use ($serialNo, $data) { return DB::transaction(function () use ($serialNo, $data) {
$machine = Machine::where('serial_no', $serialNo)->firstOrFail(); $machine = Machine::where('serial_no', $serialNo)->firstOrFail();
// 參數相容性處理 (Mapping legacy fields to new fields) // 採用現代化語意命名 (Modern semantic naming)
$temperature = $data['temperature'] ?? $machine->temperature; $temperature = $data['temperature'] ?? $machine->temperature;
$currentPage = $data['current_page'] ?? $data['M_Stus2'] ?? $machine->current_page; $currentPage = $data['current_page'] ?? $machine->current_page;
$doorStatus = $data['door_status'] ?? $data['door'] ?? $machine->door_status; $doorStatus = $data['door_status'] ?? $machine->door_status;
$firmwareVersion = $data['firmware_version'] ?? $data['M_Ver'] ?? $machine->firmware_version; $firmwareVersion = $data['firmware_version'] ?? $machine->firmware_version;
$model = $data['model'] ?? $data['M_Stus'] ?? $machine->model; $model = $data['model'] ?? $machine->model;
$updateData = [ $updateData = [
'status' => 'online',
'temperature' => $temperature, 'temperature' => $temperature,
'current_page' => $currentPage, 'current_page' => $currentPage,
'door_status' => $doorStatus, 'door_status' => $doorStatus,
@@ -64,24 +114,50 @@ class MachineService
public function syncSlots(Machine $machine, array $slotsData): void public function syncSlots(Machine $machine, array $slotsData): void
{ {
DB::transaction(function () use ($machine, $slotsData) { DB::transaction(function () use ($machine, $slotsData) {
// 蒐集所有傳入的商品 ID (可能是 SKU 或 實際 ID)
$productCodes = collect($slotsData)->pluck('product_id')->filter()->unique()->toArray();
// 優先以 ID 查詢商品,若 ID 不存在則嘗試 Barcode (Prioritize ID lookup, fallback to Barcode)
$products = \App\Models\Product\Product::whereIn('id', $productCodes)
->orWhereIn('barcode', $productCodes)
->get();
foreach ($slotsData as $slotData) { foreach ($slotsData as $slotData) {
$slotNo = $slotData['slot_no'] ?? null; $slotNo = $slotData['slot_no'] ?? null;
if (!$slotNo) continue; if (!$slotNo) continue;
$existingSlot = $machine->slots()->where('slot_no', $slotNo)->first(); $existingSlot = $machine->slots()->where('slot_no', $slotNo)->first();
// 查找對應的實體 ID (支援 ID 與 Barcode 比對)
$productCode = $slotData['product_id'] ?? null;
$actualProductId = null;
if ($productCode) {
$actualProductId = $products->first(function ($p) use ($productCode) {
return (string)$p->id === (string)$productCode || $p->barcode === (string)$productCode;
})?->id;
}
// 根據貨道類型自動決定上限 (Auto-calculate max_stock based on slot type)
// 若未提供 type預設為 '1' (履帶/Track)
$slotType = $slotData['type'] ?? $existingSlot->type ?? '1';
if ($actualProductId) {
$product = $products->find($actualProductId);
if ($product) {
// 1: 履帶, 2: 彈簧
$calculatedMaxStock = ($slotType == '1') ? $product->track_limit : $product->spring_limit;
$slotData['capacity'] = $calculatedMaxStock ?? $slotData['capacity'] ?? null;
}
}
$updateData = [ $updateData = [
'product_id' => $slotData['product_id'] ?? null, 'product_id' => $actualProductId,
'type' => $slotType,
'stock' => $slotData['stock'] ?? 0, 'stock' => $slotData['stock'] ?? 0,
'capacity' => $slotData['capacity'] ?? ($existingSlot->capacity ?? 10), 'max_stock' => $slotData['capacity'] ?? ($existingSlot->max_stock ?? 10),
'price' => $slotData['price'] ?? ($existingSlot->price ?? 0), 'is_active' => true,
'last_restocked_at' => now(),
]; ];
// 如果商品變了,或者這是一次明確的補貨回報,清空效期等待管理員更新 // 如果這是一次明確的補貨回報,建議更新時間並記錄
// 這裡我們暫定只要有 report 進來,就需要重新確認效期
$updateData['expiry_date'] = null;
if ($existingSlot) { if ($existingSlot) {
$existingSlot->update($updateData); $existingSlot->update($updateData);
} else { } else {
@@ -91,6 +167,97 @@ class MachineService
}); });
} }
/**
* Update machine slot stock, expiry, and batch.
*
* @param Machine $machine
* @param array $data
* @param int|null $userId
* @return void
*/
public function updateSlot(Machine $machine, array $data, ?int $userId = null): void
{
DB::transaction(function () use ($machine, $data, $userId) {
$slotNo = $data['slot_no'];
$stock = $data['stock'] ?? null;
$expiryDate = $data['expiry_date'] ?? null;
$batchNo = $data['batch_no'] ?? null;
$slot = $machine->slots()->where('slot_no', $slotNo)->firstOrFail();
// 紀錄舊數據以供 Payload 使用
$oldData = [
'stock' => $slot->stock,
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
'batch_no' => $slot->batch_no,
];
$updateData = [
'expiry_date' => $expiryDate,
'batch_no' => $batchNo,
];
if ($stock !== null) $updateData['stock'] = (int)$stock;
$slot->update($updateData);
// 指令去重:將該機台所有尚未領取的舊庫存同步指令標記為「已取代」
RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'pending')
->update([
'status' => 'superseded',
'note' => __('Superseded by new adjustment'),
'executed_at' => now(),
]);
// 建立遠端指令紀錄 (Unified Command Concept)
RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $userId,
'command_type' => 'reload_stock',
'status' => 'pending',
'payload' => [
'slot_no' => $slotNo,
'old' => $oldData,
'new' => [
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
'expiry_date' => $expiryDate ?: null,
'batch_no' => $batchNo ?: null,
]
]
]);
});
}
/**
* B013: Record machine hardware error/status log with auto-translation.
*
* @param Machine $machine
* @param array $data
* @return MachineLog
*/
public function recordErrorLog(Machine $machine, array $data): MachineLog
{
$errorCode = $data['error_code'] ?? '0000';
$mapping = self::ERROR_CODE_MAP[$errorCode] ?? ['label' => 'Unknown Status', 'level' => 'error'];
$slotNo = $data['tid'] ?? null;
$label = $mapping['label'];
// 儲存原始英文格式作為 DB 備用,前端顯示會優先使用 model accessor 的動態翻譯內容
$message = $slotNo ? "Slot {$slotNo}: {$label} (Code: {$errorCode})" : "{$label} (Code: {$errorCode})";
return $machine->logs()->create([
'company_id' => $machine->company_id,
'type' => 'submachine',
'level' => $mapping['level'],
'message' => $message,
'context' => array_merge($data, [
'translated_label' => $label,
'raw_code' => $errorCode
]),
]);
}
/** /**
* Update machine slot stock (single slot). * Update machine slot stock (single slot).
* Legacy support for recordLog (Existing code). * Legacy support for recordLog (Existing code).
@@ -114,10 +281,10 @@ class MachineService
$start = Carbon::parse($date)->startOfDay(); $start = Carbon::parse($date)->startOfDay();
$end = Carbon::parse($date)->endOfDay(); $end = Carbon::parse($date)->endOfDay();
// 1. Online Count (Base on current status) // 1. Online Count (Base on new heartbeat logic)
$machines = Machine::all(); // This is filtered by TenantScoped $machines = Machine::all(); // This is filtered by TenantScoped
$totalMachines = $machines->count(); $totalMachines = $machines->count();
$onlineCount = $machines->where('status', 'online')->count(); $onlineCount = Machine::online()->count();
$machineIds = $machines->pluck('id')->toArray(); $machineIds = $machines->pluck('id')->toArray();

View File

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

View File

@@ -28,6 +28,24 @@ services:
- mysql - mysql
- redis - redis
laravel.queue:
image: 'sail-8.5/app'
container_name: star-cloud-queue
hostname: star-cloud-queue
command: php artisan queue:work --tries=3 --timeout=90
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
TZ: 'Asia/Taipei'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- mysql
- redis
restart: always
mysql: mysql:
image: 'mysql/mysql-server:8.0' image: 'mysql/mysql-server:8.0'
container_name: star-cloud-mysql container_name: star-cloud-mysql

View File

@@ -14,6 +14,7 @@
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-permission": "^7.2" "spatie/laravel-permission": "^7.2"
}, },
"require-dev": { "require-dev": {

174
composer.lock generated
View File

@@ -4,8 +4,62 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "a723334f883b537b67e4475890eb949e", "content-hash": "2889e194212440faeb9f8f3dd7513795",
"packages": [ "packages": [
{
"name": "bacon/bacon-qr-code",
"version": "2.0.8",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phly/keep-a-changelog": "^2.1",
"phpunit/phpunit": "^7 | ^8 | ^9",
"spatie/phpunit-snapshot-assertions": "^4.2.9",
"squizlabs/php_codesniffer": "^3.4"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
},
"time": "2022-12-07T17:46:57+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@@ -135,6 +189,56 @@
], ],
"time": "2024-02-09T16:56:22+00:00" "time": "2024-02-09T16:56:22+00:00"
}, },
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@@ -3554,6 +3658,74 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "simplesoftwareio/simple-qrcode",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^2.0",
"ext-gd": "*",
"php": ">=7.2|^8.0"
},
"require-dev": {
"mockery/mockery": "~1",
"phpunit/phpunit": "~9"
},
"suggest": {
"ext-imagick": "Allows the generation of PNG QrCodes.",
"illuminate/support": "Allows for use within Laravel."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
},
"providers": [
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SimpleSoftwareIO\\QrCode\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simple Software LLC",
"email": "support@simplesoftware.io"
}
],
"description": "Simple QrCode is a QR code generator made for Laravel.",
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
"keywords": [
"Simple",
"generator",
"laravel",
"qrcode",
"wrapper"
],
"support": {
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
},
"time": "2021-02-08T20:43:55+00:00"
},
{ {
"name": "spatie/laravel-package-tools", "name": "spatie/laravel-package-tools",
"version": "1.93.0", "version": "1.93.0",

466
config/api-docs.php Normal file
View File

@@ -0,0 +1,466 @@
<?php
return [
'title' => 'Star Cloud IoT API 說明文件',
'version' => 'v1.0.0',
'description' => '此文件提供 Star Cloud 智能販賣機 IoT 端點通訊協議說明,供硬體端與前端開發者調研與串接使用。',
'categories' => [
[
'name' => '機台核心通訊 (IoT Core)',
'apis' => [
[
'name' => 'B000: 維運人員登入認證 (Technician Login)',
'slug' => 'b000-tech-login',
'method' => 'POST',
'path' => '/api/v1/app/admin/login/B000',
'description' => '機台啟動引導的第一步。維運人員輸入個人帳密與機台編號進行認證,成功後核發臨時 Sanctum Token 供後續 B014 下載敏感設定使用。',
'headers' => [
'Content-Type' => 'application/json',
],
'parameters' => [
'username' => [
'type' => 'string',
'required' => true,
'description' => '維運人員帳號',
'example' => 'admin_test'
],
'password' => [
'type' => 'string',
'required' => true,
'description' => '維運人員密碼',
'example' => 'password123'
],
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號 (Serial No)',
'example' => 'SN202604130001'
],
],
'response_parameters' => [
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Success'
],
'token' => [
'type' => 'string',
'description' => '臨時身份認證 Token (Sanctum)',
'example' => '1|abcdefg...'
],
],
'request' => [
'username' => 'admin_test',
'password' => 'password123',
'machine' => 'SN202604130001'
],
'response' => [
'message' => 'Success',
'token' => '1|abcdefg...'
],
],
[
'name' => 'B014: 機台參數與金鑰下載 (Config Download)',
'slug' => 'b014-config-download',
'method' => 'POST',
'path' => '/api/v1/app/machine/setting/B014',
'description' => '機台引導階段的第二步。在人員登入後,透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。',
'headers' => [
'Authorization' => 'Bearer <user_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號',
'example' => 'SN202604130001'
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '是否成功',
'example' => true
],
'data' => [
'type' => 'array',
'description' => '配置物件陣列。包含t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
'example' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
]
],
],
'request' => [
'machine' => 'SN202604130001'
],
'response' => [
'success' => true,
'code' => 200,
'data' => [
[
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v42' => '9001',
't050v43' => 'hash_key',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
]
],
'notes' => '此 API 受 auth:sanctum 保護,必須在 Header 帶上從 B000 取得的 Token。'
],
[
'name' => 'B005: 廣告清單同步 (Ad Sync)',
'slug' => 'b005-ad-sync',
'method' => 'GET',
'path' => '/api/v1/app/machine/ad/B005',
'description' => '用於機台端獲取目前應播放的廣告檔案 URL 清單。此介面無需 Request Body。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'data' => [
'type' => 'array',
'description' => '廣告物件陣列。內部欄位包含t070v01 (名稱), t070v02 (秒數), t070v03 (位置1:販賣頁, 2:來店禮, 3:待機廣告), t070v04 (URL), t070v05 (順位)',
'example' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't070v01' => '測試機台廣告',
't070v02' => 15,
't070v03' => 3,
't070v04' => 'https://example.com/ad1.mp4',
't070v05' => 1
]
]
],
],
[
'name' => 'B009: 貨道庫存即時回報 (Inventory Report)',
'slug' => 'b009-inventory-report',
'method' => 'PUT',
'path' => '/api/v1/app/products/supplementary/B009',
'description' => '當人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。需進行 RBAC 權限核查。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'account' => [
'type' => 'string',
'required' => true,
'description' => '操作人員帳號',
'example' => '0999123456'
],
'data' => [
'type' => 'array',
'required' => true,
'description' => '貨道數據陣列。tid: 貨道號, t060v00: 商品 ID, num: 庫存量',
'example' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '同步是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'message' => [
'type' => 'string',
'description' => '回應訊息',
'example' => 'Slot report synchronized success'
],
'status' => [
'type' => 'string',
'description' => '固定回傳 49 代表同步完成',
'example' => '49'
],
],
'request' => [
'account' => '0999123456',
'data' => [
['tid' => '1', 't060v00' => '1', 'num' => '10']
]
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Slot report synchronized success',
'status' => '49'
],
],
[
'name' => 'B010: 心跳上報與狀態同步 (Heartbeat)',
'slug' => 'b010-heartbeat',
'method' => 'POST',
'path' => '/api/v1/app/machine/status/B010',
'description' => '機台定期向雲端回報當前頁面、版本、溫度及門禁狀態。身份由 Bearer Token 識別。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'current_page' => [
'type' => 'integer',
'required' => true,
'description' => '當前頁面編號。對照表:
0: 離線, 1: 主頁面, 2: 販賣頁, 3: 管理頁, 4: 補貨頁, 5: 教學頁
6: 購買中, 7: 鎖定頁, 60: 出貨成功, 61: 貨道測試, 62: 付款選擇
63: 等待付款, 64: 出貨, 65: 收據簽單, 66: 通行碼, 67: 取貨碼
68: 訊息顯示, 69: 取消購買, 610: 購買結束, 611: 來店禮, 612: 出貨失敗',
'example' => 1
],
'firmware_version' => [
'type' => 'string',
'required' => true,
'description' => '軟體或韌體版本號',
'example' => '1.0.5'
],
'model' => [
'type' => 'string',
'required' => false,
'description' => '機台型號',
'example' => 'STAR-V1'
],
'temperature' => [
'type' => 'float',
'required' => false,
'description' => '感測環境溫度',
'example' => 25.5
],
'door_status' => [
'type' => 'integer',
'required' => false,
'description' => '門禁狀態 (0: 關閉, 1: 開啟)',
'example' => 0
],
'log' => [
'type' => 'string',
'required' => false,
'description' => '事件日誌主訊息',
'example' => 'Door opened'
],
'log_level' => [
'type' => 'string',
'required' => false,
'description' => '日誌等級 (info, warning, error)',
'example' => 'info'
],
'log_payload' => [
'type' => 'object',
'required' => false,
'description' => '詳細上下文 (JSON 對象)',
'example' => ['error_code' => 500, 'component' => 'door_sensor']
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否處理成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'message' => [
'type' => 'string',
'description' => '回應訊息說明',
'example' => 'OK'
],
'status' => [
'type' => 'string',
'description' => '雲端指令代碼。對照表:
49: reload B017 (貨道同步), 51: reboot (重啟系統)
60: reboot card machine (刷卡機重啟), 61: checkout (觸發結帳)
70: unlock (解鎖), 71: lock (鎖定), 85: reload B0552 (遠端出貨)
待定義: change (遠端找零 - 目前 Java App 尚無對接)',
'example' => '49'
],
],
'request' => [
'current_page' => 1,
'firmware_version' => '1.0.5',
'model' => 'STAR-V1',
'temperature' => 25.5,
'door_status' => 0,
'log' => 'Door opened',
'log_level' => 'info',
'log_payload' => [
'error_code' => 500,
'component' => 'door_sensor'
],
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'status' => '49'
],
'notes' => '機台收到 B010 回應中的特定 `status` 代碼後,應根據對照表執行對應的指令動作或 API 呼叫 (如 B017)。若為空則代表無指令。'
],
[
'name' => 'B012: 商品配置與商品主檔同步 (Unified Sync)',
'slug' => 'b012-unified-sync',
'method' => 'GET/PATCH',
'path' => '/api/v1/app/machine/products/B012',
'description' => '用於機台端獲取目前所有可販售商品的詳細配置。GET 為全量同步PATCH 為增量更新。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否處理成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
'data' => [
'type' => 'array',
'description' => '商品明細物件陣列',
'example' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v03' => 'Cold Drink',
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
't060v30' => 20.0,
't063v03' => 25.0,
't060v40' => 'Buy 1 Get 1',
't060v41' => 'SKU-001',
'spring_limit' => 10,
'track_limit' => 15
]
]
]
],
'request' => [],
'response' => [
'success' => true,
'code' => 200,
'message' => 'OK',
'data' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v03' => 'Cold Drink',
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
't060v30' => 20.0,
't063v03' => 25.0,
't060v40' => 'Buy 1 Get 1',
't060v41' => 'SKU-001',
'spring_limit' => 10,
'track_limit' => 15
]
]
],
'notes' => '運作邏輯 (Client-side Logic): GET 執行全量同步App 應於收到成功回應後,先執行 deleteAll() 再進行 insertAll()。PATCH 執行增量更新App 僅對記憶體中的既存商品進行欄位值覆蓋 (Patching)。'
],
[
'name' => 'B013: 機台故障與異常狀態上報 (Error/Status Report)',
'slug' => 'b013-error-report',
'method' => 'POST',
'path' => '/api/v1/app/machine/error/B013',
'description' => '用於接收機台發出的即時硬體狀態代碼(如卡貨、門未關)。身份由 Bearer Token 識別,回傳成功代表伺服器已將任務列入異步隊列處理。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'tid' => [
'type' => 'integer',
'required' => false,
'description' => '涉及到之具體貨道編號 (Slot No)',
'example' => 12
],
'error_code' => [
'type' => 'string',
'required' => true,
'description' => '硬體狀態代碼 (4 位 16 進位字串)',
'example' => '0403'
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否已成功接收',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '內部業務狀態碼',
'example' => 200
],
],
'request' => [
'tid' => 12,
'error_code' => '0403',
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Error report accepted'
],
'notes' => '硬體代碼對照表見後端 MachineService::ERROR_CODE_MAP 定義。
0402: 出貨成功, 0403: 貨道卡貨, 0202: 貨道缺貨, 0415: 取貨門異常...等。'
]
]
]
]
];

View File

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

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Factories\System;
use App\Models\System\Company;
use Illuminate\Database\Eloquent\Factories\Factory;
class CompanyFactory extends Factory
{
protected $model = Company::class;
public function definition(): array
{
return [
'name' => $this->faker->company,
'code' => $this->faker->unique()->bothify('COMP###'),
'status' => 1,
'settings' => [
'enable_material_code' => false,
'enable_points' => false,
],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('maintenance_records', function (Blueprint $table) {
$table->boolean('is_confirmed')->default(false)->after('photos')->comment('已確認告知客戶並簽名');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('maintenance_records', function (Blueprint $table) {
$table->dropColumn('is_confirmed');
});
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 擴充 products 表
Schema::table('products', function (Blueprint $table) {
$table->string('spec')->nullable()->after('name')->comment('規格');
$table->string('manufacturer')->nullable()->after('barcode')->comment('生產公司');
$table->integer('track_limit')->default(0)->after('manufacturer')->comment('履帶貨道上限');
$table->integer('spring_limit')->default(0)->after('track_limit')->comment('彈簧貨道上限');
$table->decimal('member_price', 10, 2)->default(0)->after('price')->comment('會員價');
$table->json('metadata')->nullable()->after('is_active')->comment('進階 Metadata (點數、物料代碼等)');
});
// 擴充 companies 表
Schema::table('companies', function (Blueprint $table) {
$table->json('settings')->nullable()->after('note')->comment('客戶功能設定 (Feature Toggles)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn([
'spec',
'manufacturer',
'track_limit',
'spring_limit',
'member_price',
'metadata'
]);
});
Schema::table('companies', function (Blueprint $table) {
$table->dropColumn('settings');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->string('code', 100)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->string('code', 20)->change();
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('translations', function (Blueprint $table) {
$table->unsignedBigInteger('company_id')->nullable()->after('id')->index()->comment('所屬公司 ID');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('translations', function (Blueprint $table) {
$table->dropColumn('company_id');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->renameColumn('image', 'image_url');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->renameColumn('image_url', 'image');
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
// 重新命名現有欄位
$table->renameColumn('valid_until', 'end_date');
// 新增業務類型
$table->string('original_type')->default('lease')->after('code')->comment('原始類型: buyout, lease');
$table->string('current_type')->default('lease')->after('original_type')->comment('當前類型: buyout, lease');
// 新增起始日
$table->date('start_date')->nullable()->after('status')->comment('合約起始日');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->renameColumn('end_date', 'valid_until');
$table->dropColumn(['original_type', 'current_type', 'start_date']);
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('is_system');
});
// 資料遷移:將所有租戶中名稱為「管理員」的角色標示為 is_admin = true
// 這樣既有的授權篩選才不會斷掉
DB::table('roles')
->whereNotNull('company_id')
->where('name', '管理員')
->update(['is_admin' => true]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. 從 roles 移除 is_admin
if (Schema::hasColumn('roles', 'is_admin')) {
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
// 2. 在 users 新增 is_admin
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('status');
});
// 3. 資料遷移:針對現有租戶,將每一家公司最先建立的帳號(或是目前名稱為管理員角色的人)標記為 is_admin = true
// 取得所有租戶公司 ID
$companyIds = DB::table('companies')->pluck('id');
foreach ($companyIds as $companyId) {
// 優先找該公司 ID 最小的 user (通常是第一個建立的)
$userId = DB::table('users')
->where('company_id', $companyId)
->orderBy('id', 'asc')
->value('id');
if ($userId) {
DB::table('users')->where('id', $userId)->update(['is_admin' => true]);
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
Schema::table('roles', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('is_system');
});
}
};

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. 先將所有已刪除帳號的 is_admin 全部歸零,確保不會標記在「看不到的人」身上
DB::table('users')->whereNotNull('deleted_at')->update(['is_admin' => false]);
// 2. 針對每一家公司,重新撈取「目前還存活 (deleted_at is null)」的最早建立帳號
$companyIds = DB::table('companies')->pluck('id');
foreach ($companyIds as $companyId) {
// 找該公司中,目前 ID 最小且「尚未被刪除」的 User
$userId = DB::table('users')
->where('company_id', $companyId)
->whereNull('deleted_at')
->orderBy('id', 'asc')
->value('id');
if ($userId) {
// 將該帳號設為管理員,並確保該公司其它生存帳號如果是 true 的先清掉 (一對一標記)
DB::table('users')
->where('company_id', $companyId)
->where('id', '!=', $userId)
->update(['is_admin' => false]);
DB::table('users')->where('id', $userId)->update(['is_admin' => true]);
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 基本上這是資料修正,回復也不太有意義
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('advertisements', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->nullable()->constrained()->onDelete('cascade');
$table->string('name');
$table->string('type')->default('image'); // image, video
$table->integer('duration')->default(15); // 15, 30, 60
$table->string('url');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('advertisements');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('machine_advertisements', function (Blueprint $table) {
$table->id();
$table->foreignId('machine_id')->constrained()->onDelete('cascade');
$table->foreignId('advertisement_id')->constrained()->onDelete('cascade');
$table->string('position')->comment('vending, visit_gift, standby');
$table->integer('sort_order')->default(0);
$table->dateTime('start_at')->nullable();
$table->dateTime('end_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('machine_advertisements');
}
};

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. 取得權限 ID
$permission = DB::table('permissions')
->where('name', 'menu.data-config.sub-account-roles')
->first();
if ($permission) {
// 2. 移除角色與該權限的關聯 (雖然 Spatie 通常會處理,但手動確保清理乾淨)
DB::table('role_has_permissions')
->where('permission_id', $permission->id)
->delete();
// 3. 移除權限本身
DB::table('permissions')
->where('id', $permission->id)
->delete();
}
// 4. 清理權限快取 (如果有的話)
try {
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
} catch (\Exception $e) {
// 忽略快取清理失敗(例如在沒有 Redis 的環境中)
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 由於是要永久拿掉down 邏輯通常不需要重建,
// 若真要復原,應透過重跑 Seeder 或手動新增。
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
$table->string('note', 255)->nullable()->after('payload');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
$table->dropColumn('note');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->after('machine_id')->constrained()->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (DB::getDriverName() !== 'sqlite') {
DB::statement("ALTER TABLE remote_commands MODIFY COLUMN status ENUM('pending', 'sent', 'success', 'failed', 'superseded') DEFAULT 'pending'");
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (DB::getDriverName() !== 'sqlite') {
DB::statement("ALTER TABLE remote_commands MODIFY COLUMN status ENUM('pending', 'sent', 'success', 'failed') DEFAULT 'pending'");
}
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machine_slots', function (Blueprint $group) {
$group->string('type')->nullable()->after('slot_no')->comment('1: spring, 2: track');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_slots', function (Blueprint $group) {
$group->dropColumn('type');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machine_slots', function (Blueprint $group) {
$group->string('type')->nullable()->comment('1: track, 2: spring')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_slots', function (Blueprint $group) {
$group->string('type')->nullable()->comment('1: spring, 2: track')->change();
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machines', function (Blueprint $table) {
if (Schema::hasColumn('machines', 'status')) {
$table->dropColumn('status');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->string('status')->default('offline')->after('location');
});
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. 從 products 表移除 sku
Schema::table('products', function (Blueprint $table) {
// 因為公司外鍵正在使用這個複合索引,必須先補一個獨立索引給公司
$table->index('company_id');
// 現在可以安全移除含有 sku 的索引了
$table->dropIndex(['company_id', 'sku']);
$table->dropColumn('sku');
});
// 2. 從 order_items 表移除 sku 並新增 barcode
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('sku');
$table->string('barcode')->nullable()->after('product_name')->comment('商品條碼 (備份)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('barcode');
$table->string('sku')->nullable()->after('product_name')->comment('商品編號 (備份)');
});
Schema::table('products', function (Blueprint $table) {
$table->string('sku')->nullable()->after('name_dictionary_key')->comment('商品編號');
$table->index(['company_id', 'sku']);
});
}
};

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ class RoleSeeder extends Seeder
'menu.members', 'menu.members',
'menu.machines', 'menu.machines',
'menu.machines.list', 'menu.machines.list',
'menu.machines.permissions',
'menu.machines.utilization', 'menu.machines.utilization',
'menu.machines.maintenance', 'menu.machines.maintenance',
'menu.app', 'menu.app',
@@ -30,8 +31,11 @@ class RoleSeeder extends Seeder
'menu.analysis', 'menu.analysis',
'menu.audit', 'menu.audit',
'menu.data-config', 'menu.data-config',
'menu.data-config.products',
'menu.data-config.advertisements',
'menu.data-config.sub-accounts', 'menu.data-config.sub-accounts',
'menu.data-config.sub-account-roles', 'menu.data-config.points',
'menu.data-config.badges',
'menu.remote', 'menu.remote',
'menu.line', 'menu.line',
'menu.reservation', 'menu.reservation',
@@ -64,6 +68,7 @@ class RoleSeeder extends Seeder
'menu.members', 'menu.members',
'menu.machines', 'menu.machines',
'menu.machines.list', 'menu.machines.list',
'menu.machines.permissions',
'menu.machines.utilization', 'menu.machines.utilization',
'menu.machines.maintenance', 'menu.machines.maintenance',
'menu.app', 'menu.app',
@@ -72,8 +77,11 @@ class RoleSeeder extends Seeder
'menu.analysis', 'menu.analysis',
'menu.audit', 'menu.audit',
'menu.data-config', 'menu.data-config',
'menu.data-config.products',
'menu.data-config.advertisements',
'menu.data-config.sub-accounts', 'menu.data-config.sub-accounts',
'menu.data-config.sub-account-roles', 'menu.data-config.points',
'menu.data-config.badges',
'menu.remote', 'menu.remote',
'menu.line', 'menu.line',
'menu.reservation', 'menu.reservation',

View File

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

43
docs/future_todo.md Normal file
View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,8 @@ return [
| |
*/ */
'failed' => 'These credentials do not match our records.', 'failed' => '帳號或密碼錯誤,請重新確認。',
'password' => 'The provided password is incorrect.', 'password' => '您輸入的密碼不正確。',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'throttle' => '登入嘗試次數過多。請在 :seconds 秒後再試。',
]; ];

View File

@@ -179,8 +179,8 @@ return [
*/ */
'custom' => [ 'custom' => [
'attribute-name' => [ 'is_confirmed' => [
'rule-name' => 'custom-message', 'accepted' => '您必須勾選確認已告知客戶並取得簽名。',
], ],
], ],
@@ -196,12 +196,18 @@ return [
*/ */
'attributes' => [ 'attributes' => [
'username' => '帳號',
'name' => '姓名', 'name' => '姓名',
'email' => '電子郵件', 'email' => '電子郵件',
'password' => '密碼', 'password' => '密碼',
'current_password' => '目前密碼', 'current_password' => '目前密碼',
'password_confirmation' => '確認密碼', 'password_confirmation' => '確認密碼',
'phone' => '電話', 'phone' => '電話',
'machine_id' => '機台',
'category' => '類別',
'maintenance_at' => '維修日期',
'content' => '維修內容',
'is_confirmed' => '確認勾選框',
], ],
]; ];

171
package-lock.json generated
View File

@@ -1,9 +1,13 @@
{ {
"name": "html", "name": "star-cloud",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"flatpickr": "^4.6.13",
"pptxgenjs": "^4.0.1"
},
"devDependencies": { "devDependencies": {
"@alpinejs/collapse": "^3.15.3", "@alpinejs/collapse": "^3.15.3",
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
@@ -871,6 +875,7 @@
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Fuzzyma" "url": "https://github.com/sponsors/Fuzzyma"
@@ -896,6 +901,7 @@
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 14.18" "node": ">= 14.18"
}, },
@@ -930,6 +936,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
@@ -1123,6 +1138,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -1243,6 +1259,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1485,6 +1507,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/flatpickr": {
"version": "4.6.13",
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
"license": "MIT"
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -1669,6 +1697,39 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1731,12 +1792,19 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -1748,6 +1816,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/just-extend": { "node_modules/just-extend": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz",
@@ -1775,6 +1855,15 @@
"vite": "^5.0.0 || ^6.0.0" "vite": "^5.0.0 || ^6.0.0"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -1947,6 +2036,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2014,6 +2109,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2157,6 +2253,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pptxgenjs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.8.1",
"https": "^1.0.0",
"image-size": "^1.2.1",
"jszip": "^3.10.1"
}
},
"node_modules/preline": { "node_modules/preline": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/preline/-/preline-3.2.3.tgz", "resolved": "https://registry.npmjs.org/preline/-/preline-3.2.3.tgz",
@@ -2172,6 +2280,12 @@
"vanilla-calendar-pro": "^3.0.4" "vanilla-calendar-pro": "^3.0.4"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2179,6 +2293,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2210,6 +2333,21 @@
"pify": "^2.3.0" "pify": "^2.3.0"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -2321,6 +2459,18 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2331,6 +2481,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/sucrase": { "node_modules/sucrase": {
"version": "3.35.1", "version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -2373,6 +2532,7 @@
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -2469,6 +2629,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2496,6 +2657,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
@@ -2531,7 +2698,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vanilla-calendar-pro": { "node_modules/vanilla-calendar-pro": {
@@ -2551,6 +2717,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

View File

@@ -17,5 +17,9 @@
"preline": "^3.2.3", "preline": "^3.2.3",
"tailwindcss": "^3.1.0", "tailwindcss": "^3.1.0",
"vite": "^5.0.0" "vite": "^5.0.0"
},
"dependencies": {
"flatpickr": "^4.6.13",
"pptxgenjs": "^4.0.1"
} }
} }

135
pptx_gen.cjs Normal file
View File

@@ -0,0 +1,135 @@
const pptxgen = require("pptxgenjs");
const fs = require("fs");
const pres = new pptxgen();
pres.layout = "LAYOUT_16x9";
pres.title = "Star Cloud Demo Day - Technical Edition";
pres.author = "許家偉";
// --- Theme Colors ---
const COLORS = {
bg: "0F172A",
cardBg: "1E293B",
highlight: "2DD4BF",
secondary: "7DD3FC",
text: "FFFFFF",
muted: "94A3B8"
};
const FONTS = {
header: "Georgia",
body: "Calibri"
};
const makeShadow = (opacity = 0.2) => ({
type: "outer",
blur: 8,
offset: 3,
angle: 135,
color: "000000",
opacity: opacity
});
// --- Slide 1: Title ---
const slide1 = pres.addSlide();
slide1.background = { color: COLORS.bg };
slide1.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 0.15, h: "100%", fill: { color: COLORS.highlight } });
slide1.addText("Star Cloud Demo Day", { x: 1.0, y: 1.8, w: 8, h: 1, fontSize: 48, fontFace: FONTS.header, color: COLORS.text, bold: true, margin: 0 });
slide1.addText("2026-03-25 ~ 2026-03-31 成果發表", { x: 1.0, y: 2.8, w: 8, h: 0.5, fontSize: 24, fontFace: FONTS.body, color: COLORS.secondary, italic: true });
slide1.addShape(pres.shapes.RECTANGLE, { x: 7.5, y: 4.5, w: 2, h: 0.6, fill: { color: COLORS.highlight, transparency: 10 }, shadow: makeShadow() });
slide1.addText("演講者:許家偉", { x: 7.5, y: 4.5, w: 2, h: 0.6, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, align: "center", valign: "middle", bold: true });
// --- Slide 2: Overview ---
const slide2 = pres.addSlide();
slide2.background = { color: COLORS.bg };
slide2.addText("本週進度總覽 / Overview", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 32, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
slide2.addShape(pres.shapes.LINE, { x: 2, y: 3, w: 6, h: 0, line: { color: COLORS.highlight, width: 4 } });
const timelineItems = [
{ title: "機台權限管理", desc: "模組獨立化與效能優化" },
{ title: "商品管理整合", desc: "狀態整合與多語系修復" },
{ title: "廣告隔離機制", desc: "多租戶素材歸屬強化" }
];
timelineItems.forEach((item, idx) => {
const x = 2 + (idx * 3);
slide2.addShape(pres.shapes.OVAL, { x: x - 0.2, y: 2.8, w: 0.4, h: 0.4, fill: { color: COLORS.secondary }, line: { color: COLORS.text, width: 2 } });
slide2.addText(item.title, { x: x - 1, y: 3.3, w: 2, h: 0.4, fontSize: 18, fontFace: FONTS.header, color: COLORS.text, align: "center", bold: true });
slide2.addText(item.desc, { x: x - 1, y: 3.7, w: 2, h: 0.6, fontSize: 12, fontFace: FONTS.body, color: COLORS.muted, align: "center" });
});
// --- Slide 3: Highlight 1 - Machine Permissions (WITH EAGER LOADING) ---
const slide3 = pres.addSlide();
slide3.background = { color: COLORS.bg };
slide3.addText("亮點一:機台權限管理獨立化", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 28, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
const features = [
{ title: "直覺檢索", text: "管理員可快速檢索、分配所屬帳號對應的機台清單。" },
{ title: "即時過濾", text: "透過 Alpine.js 實作,輸入關鍵字即動態無刷新過濾。" },
{ title: "效能關鍵Eager Loading", text: "使用 with('machines') 解決 N+1 問題,萬筆資料秒開。" }
];
features.forEach((f, idx) => {
const y = 1.2 + (idx * 1.3);
slide3.addShape(pres.shapes.RECTANGLE, { x: 0.5, y: y, w: 4, h: 1.1, fill: { color: COLORS.cardBg }, line: { color: COLORS.secondary, width: 1 }, shadow: makeShadow() });
slide3.addText(f.title, { x: 0.7, y: y + 0.1, w: 3.5, h: 0.3, fontSize: 16, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
slide3.addText(f.text, { x: 0.7, y: y + 0.45, w: 3.5, h: 0.5, fontSize: 12, fontFace: FONTS.body, color: COLORS.text });
});
slide3.addImage({
path: "/home/mama/.gemini/antigravity/brain/58a170d0-7144-4e9f-9396-3e753a0bf69a/machine_permissions_table_1774944648298.png",
x: 4.8, y: 1.2, w: 4.8, h: 3.7, sizing: { type: 'contain' }, shadow: makeShadow(0.3)
});
// --- Slide 4: Highlight 2 ---
const slide4 = pres.addSlide();
slide4.background = { color: COLORS.bg };
slide4.addText("亮點二:商品管理整合與 UX 完善", { x: 0.5, y: 0.4, w: 9, h: 0.6, fontSize: 28, fontFace: FONTS.header, color: COLORS.highlight, bold: true });
const comparison = [
{ title: "功能整合 (Integrated)", items: ["• 「商品狀態」納入主流程", "• 減少跳轉,提升管理效率"] },
{ title: "細節優化 (Enhanced)", items: ["• 修復多語系(ZH/EN/JA)存取故障", "• 密碼欄位新增顯隱切換按鈕"] }
];
comparison.forEach((c, idx) => {
const x = 0.5 + (idx * 4.75);
slide4.addShape(pres.shapes.RECTANGLE, { x: x, y: 1.2, w: 4.25, h: 3.5, fill: { color: COLORS.cardBg }, line: { color: COLORS.highlight, width: 2 }, shadow: makeShadow(0.2) });
slide4.addText(c.title, { x: x + 0.2, y: 1.4, w: 3.8, h: 0.4, fontSize: 18, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
slide4.addText(c.items.map(i => ({ text: i, options: { breakLine: true, bullet: true } })), { x: x + 0.3, y: 2.0, w: 3.6, h: 2.5, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, margin: 0 });
});
// --- Slide 5: Highlight 3 ---
const slide5 = pres.addSlide();
slide5.background = { color: COLORS.bg };
slide5.addText("亮點三:多租戶廣告素材隔離機制", { x: 1.0, y: 0.4, w: 8, h: 1, fontSize: 32, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
slide5.addShape(pres.shapes.OVAL, { x: 4.25, y: 2.0, w: 1.5, h: 1.5, fill: { color: COLORS.highlight, transparency: 30 }, line: { color: COLORS.secondary, width: 2 } });
slide5.addText("安全性隔離\n(Shield)", { x: 4.25, y: 2.5, w: 1.5, h: 0.5, fontSize: 14, fontFace: FONTS.header, color: COLORS.text, align: "center", bold: true });
const isolations = [
{ x: 1, y: 1.8, label: "公司 A\n廣告素材", color: "A8DADC" },
{ x: 7.5, y: 1.8, label: "公司 B\n廣告素材", color: "A8DADC" },
{ x: 1, y: 3.8, label: "公司 C\n廣告素材", color: "A8DADC" },
{ x: 7.5, y: 3.8, label: "公司 D\n廣告素材", color: "A8DADC" }
];
isolations.forEach(i => {
slide5.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: i.x, y: i.y, w: 1.5, h: 0.8, fill: { color: COLORS.cardBg }, line: { color: COLORS.secondary, width: 1 }, rectRadius: 0.1 });
slide5.addText(i.label, { x: i.x, y: i.y, w: 1.5, h: 0.8, fontSize: 12, fontFace: FONTS.body, color: COLORS.text, align: "center", valign: "middle" });
slide5.addShape(pres.shapes.LINE, { x: i.x + (i.x < 5 ? 1.5 : 0), y: i.y + 0.4, w: i.x < 5 ? (4.25 - (i.x + 1.5)) : (7.5 - (i.x + 1.5)), h: 2.75 - (i.y + 0.4), line: { color: COLORS.secondary, width: 1, dashType: "dash" } });
});
slide5.addText("嚴格驗證 company_id確保素材 100% 歸屬隔離。", { x: 0.5, y: 5.0, w: 9, h: 0.4, fontSize: 14, fontFace: FONTS.body, color: COLORS.muted, align: "center", italic: true });
// --- Slide 6: Future Roadmap ---
const slide6 = pres.addSlide();
slide6.background = { color: COLORS.bg };
slide6.addText("未來計畫 / Roadmap", { x: 0.5, y: 0.4, w: 9, h: 1, fontSize: 36, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
const roadmap = [
{ icon: "Optim", title: "操作優化", text: "針對樂樂反饋優化操作流程與介面體驗。" },
{ icon: "Sync", title: "API 對接", text: "與 Terry 配合,實現機台通訊穩定與對接。" },
{ icon: "Remote", title: "遠端管理", text: "實作遠端控制與異常即時監控監管。" }
];
roadmap.forEach((r, idx) => {
const x = 0.5 + (idx * 3.1);
slide6.addShape(pres.shapes.RECTANGLE, { x: x, y: 2.0, w: 2.8, h: 2.5, fill: { color: COLORS.highlight, transparency: 85 }, line: { color: COLORS.highlight, width: 2 }, shadow: makeShadow() });
slide6.addText(r.title, { x: x, y: 2.3, w: 2.8, h: 0.5, fontSize: 20, fontFace: FONTS.header, color: COLORS.highlight, bold: true, align: "center" });
slide6.addText(r.text, { x: x + 0.2, y: 3.0, w: 2.4, h: 1.2, fontSize: 14, fontFace: FONTS.body, color: COLORS.text, align: "center" });
});
slide6.addText("Star Cloud 將持續進化,打造最領先的智能雲端平台。🚀", { x: 0.5, y: 5.0, w: 9, h: 0.4, fontSize: 14, fontFace: FONTS.body, color: COLORS.secondary, align: "center", bold: true });
pres.writeFile({ fileName: "star-cloud-demo-20260331.pptx" })
.then(fileName => console.log(`Presentation created: ${fileName}`))
.catch(err => { console.error("Error creating presentation:", err); process.exit(1); });

BIN
public/S1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
public/S1_cropped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
public/S1_edited.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

View File

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

View File

@@ -0,0 +1,866 @@
@extends('layouts.admin')
@php
$routeName = request()->route()->getName();
$baseRoute = 'admin.data-config.advertisements';
@endphp
@section('content')
<div class="space-y-2 pb-20"
x-data="adManager"
data-ads="{{ json_encode($advertisements->items()) }}"
data-machines="{{ json_encode($machines) }}"
data-all-ads="{{ json_encode($allAds) }}"
data-active-tab="{{ $tab }}"
data-urls='{
"store": "{{ route($baseRoute . ".store") }}",
"update": "{{ route($baseRoute . ".update", ":id") }}",
"delete": "{{ route($baseRoute . ".destroy", ":id") }}",
"getMachineAds": "{{ route($baseRoute . ".machine.get", ":id") }}",
"assign": "{{ route($baseRoute . ".assign") }}",
"reorder": "{{ route($baseRoute . ".assignments.reorder") }}",
"removeAssignment": "{{ route($baseRoute . ".assignment.remove", ":id") }}"
}'>
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Advertisement Management') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">
{{ __('Manage ad materials and machine playback settings') }}
</p>
</div>
<div class="flex items-center gap-3" x-show="activeTab === 'list'">
<button @click="openAddModal()" class="btn-luxury-primary">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('Add Advertisement') }}</span>
</button>
</div>
</div>
<!-- Tabs Navigation (Pills Style match Machine List) -->
<div class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50" aria-label="Tabs">
<button type="button"
@click="activeTab = 'list'"
:class="activeTab === 'list' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Advertisement List') }}
</button>
<button type="button"
@click="activeTab = 'machine'"
:class="activeTab === 'machine' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200'"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all duration-300">
{{ __('Machine Advertisement Settings') }}
</button>
</div>
<!-- Tab Contents -->
<div class="mt-6">
<!-- List Tab -->
<div x-show="activeTab === 'list'" class="luxury-card rounded-3xl p-8 animate-luxury-in" x-cloak>
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Preview') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Name') }}</th>
@if(auth()->user()->isSystemAdmin())
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-left">{{ __('Company Name') }}</th>
@endif
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Type') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Duration') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Schedule') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($advertisements as $ad)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-4">
<div @click="openPreview(@js($ad))"
class="w-16 h-9 rounded-lg bg-slate-100 dark:bg-slate-800 overflow-hidden shadow-sm border border-slate-200 dark:border-white/5 cursor-pointer hover:scale-105 hover:shadow-cyan-500/20 transition-all duration-300">
@if($ad->type === 'image')
<img src="{{ $ad->url }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full flex items-center justify-center bg-slate-900 group-hover:bg-cyan-900 transition-colors">
<svg class="size-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
</div>
@endif
</div>
</td>
<td @click="openPreview(@js($ad))"
class="px-6 py-4 whitespace-nowrap text-sm font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors cursor-pointer">
{{ $ad->name }}
</td>
@if(auth()->user()->isSystemAdmin())
<td class="px-6 py-4 whitespace-nowrap text-sm font-bold text-slate-600 dark:text-slate-300">
{{ $ad->company->name ?? __('System Default') }}
</td>
@endif
<td class="px-6 py-4 text-center">
<span class="text-[10px] font-black uppercase tracking-widest px-2 py-0.5 rounded-full {{ $ad->type === 'video' ? 'bg-indigo-500/10 text-indigo-500 border border-indigo-500/20' : 'bg-cyan-500/10 text-cyan-500 border border-cyan-500/20' }}">
{{ __($ad->type) }}
</span>
</td>
<td class="px-6 py-4 text-center whitespace-nowrap text-sm font-mono font-bold text-slate-700 dark:text-slate-200">
{{ $ad->duration }}s
</td>
<td class="px-6 py-4 text-center whitespace-nowrap">
<div class="flex flex-col items-center gap-0.5">
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('From') }}: {{ $ad->start_at?->format('Y-m-d H:i') ?? __('Immediate') }}</span>
<span class="text-[11px] font-mono font-bold text-slate-500 dark:text-slate-400 uppercase tracking-tight">{{ __('To') }}: {{ $ad->end_at?->format('Y-m-d H:i') ?? __('Indefinite') }}</span>
</div>
</td>
<td class="px-6 py-4 text-center">
@php
$now = now();
$isStarted = !$ad->start_at || $ad->start_at <= $now;
$isExpired = $ad->end_at && $ad->end_at < $now;
$isPlaying = $ad->is_active && $isStarted && !$isExpired;
@endphp
<div class="flex flex-col items-center gap-1">
@if(!$ad->is_active)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-slate-500/10 text-slate-500 border border-slate-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
@elseif($isExpired)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Expired') }}</span>
@elseif(!$isStarted)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-amber-500/10 text-amber-500 border border-amber-500/20 tracking-widest uppercase">{{ __('Waiting') }}</span>
@else
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Ongoing') }}</span>
@endif
</div>
</td>
<td class="px-6 py-4 text-right">
<div class="flex justify-end items-center gap-2">
<button @click="openEditModal(@js($ad))" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
</button>
<button @click="confirmDelete(@js($ad->id))" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="{{ auth()->user()->isSystemAdmin() ? 7 : 6 }}" class="px-6 py-20 text-center text-slate-400 italic">{{ __('No advertisements found.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-8">
{{ $advertisements->links('vendor.pagination.luxury') }}
</div>
</div>
<!-- Machine View Tab -->
<div x-show="activeTab === 'machine'" class="space-y-6" x-cloak>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<!-- Machine Filter -->
<div class="max-w-md mx-auto mb-10">
<label class="block text-sm font-black text-slate-700 dark:text-slate-200 mb-3 text-center uppercase tracking-widest">{{ __('Please select a machine first') }}</label>
<x-searchable-select
name="machine_selector"
:options="$machines->map(fn($m) => (object)['id' => $m->id, 'name' => $m->name . ' (' . $m->serial_no . ')'])"
placeholder="{{ __('Search Machine...') }}"
@change="selectMachine($event.target.value)"
/>
</div>
<div x-show="selectedMachineId" class="animate-luxury-in">
<!-- Positions Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
@foreach(['vending', 'visit_gift', 'standby'] as $pos)
<div class="space-y-4">
<div class="flex items-center justify-between px-2">
<h3 class="text-sm font-black text-slate-800 dark:text-white uppercase tracking-widest flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-500"></span>
{{ __($pos) }}
</h3>
<div class="flex items-center gap-2">
<button x-cloak x-show="machineAds['{{ $pos }}'] && machineAds['{{ $pos }}'].length > 0"
@click="startSequencePreview('{{ $pos }}')"
class="p-1.5 px-3 text-[10px] font-black bg-slate-800 dark:bg-slate-700 text-white rounded-lg hover:bg-slate-700 transition-colors uppercase flex items-center gap-1">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ __('Preview') }}
</button>
<button @click="openAssignModal('{{ $pos }}')" class="p-1.5 px-3 text-[10px] font-black bg-cyan-500 text-white rounded-lg hover:bg-cyan-600 transition-colors uppercase shadow-sm shadow-cyan-500/20 flex items-center gap-1">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4.5v15m7.5-7.5h-15" /></svg>
{{ __('Ad Settings') }}
</button>
</div>
</div>
<div class="luxury-card p-4 bg-slate-50/50 dark:bg-slate-900/40 border border-slate-100 dark:border-white/5 space-y-3 min-h-[150px]">
<template x-if="!machineAds['{{ $pos }}'] || machineAds['{{ $pos }}'].length === 0">
<div class="flex flex-col items-center justify-center h-full py-10">
<svg class="size-6 text-slate-300 dark:text-slate-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v6m-3-3h6m-9-3a9 9 0 1118 0 9 9 0 01-18 0z"/></svg>
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">{{ __('No assignments') }}</span>
</div>
</template>
<template x-for="(assign, index) in machineAds['{{ $pos }}']" :key="assign.id">
<div class="flex items-center gap-4 bg-white dark:bg-slate-800 p-3 rounded-xl border border-slate-100 dark:border-white/5 shadow-sm group hover:border-cyan-500/30 transition-all">
<!-- Sort Controls -->
<div class="flex flex-col gap-1 shrink-0 bg-slate-50 dark:bg-slate-900 rounded-lg p-1 border border-slate-100 dark:border-white/5">
<button @click.prevent="moveUp('{{ $pos }}', index)" :disabled="index === 0" class="p-1 text-slate-400 hover:text-cyan-500 disabled:opacity-30 disabled:hover:text-slate-400 transition-colors">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" /></svg>
</button>
<button @click.prevent="moveDown('{{ $pos }}', index)" :disabled="index === machineAds['{{ $pos }}'].length - 1" class="p-1 text-slate-400 hover:text-cyan-500 disabled:opacity-30 disabled:hover:text-slate-400 transition-colors">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
</button>
</div>
<button @click="openPreview(assign.advertisement)" class="w-12 h-12 rounded-lg bg-slate-100 dark:bg-slate-900 border border-white/5 overflow-hidden shrink-0 hover:border-cyan-500/50 hover:shadow-[0_0_15px_rgba(6,182,212,0.3)] transition-all relative group/thumb">
<div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity z-10">
<svg class="size-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
</div>
<template x-if="assign.advertisement.type === 'image'">
<img :src="assign.advertisement.url" class="w-full h-full object-cover">
</template>
<template x-if="assign.advertisement.type === 'video'">
<div class="w-full h-full flex items-center justify-center bg-slate-900 group-hover/thumb:bg-cyan-900 transition-colors">
<svg class="size-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
</div>
</template>
</button>
<div class="flex-1 min-w-0 flex flex-col justify-center cursor-pointer group-hover:text-cyan-500 transition-colors" @click="openPreview(assign.advertisement)">
<p class="text-xs font-black text-slate-700 dark:text-white truncate transition-colors" x-text="assign.advertisement.name"></p>
<p class="text-[11px] font-mono font-bold text-slate-400 uppercase tracking-tight mt-0.5" x-text="assign.advertisement.duration + 's'"></p>
</div>
<button @click="removeAssignment(assign.id)" class="p-1.5 text-slate-300 hover:text-rose-500 transition-colors">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</template>
</div>
</div>
@endforeach
</div>
</div>
<div x-show="!selectedMachineId" class="py-20 text-center text-slate-400 italic">
{{ __('Please select a machine to view and manage its advertisements.') }}
</div>
</div>
</div>
</div>
<!-- Modals -->
@include('admin.ads.partials.ad-modal')
@include('admin.ads.partials.assign-modal')
<!-- Preview Modal -->
<div x-show="isPreviewOpen"
class="fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@keydown.escape.window="isPreviewOpen = false">
<div class="fixed inset-0 bg-slate-950/90 backdrop-blur-xl" @click="isPreviewOpen = false"></div>
<div class="relative max-w-5xl w-full max-h-[90vh] flex flex-col items-center justify-center animate-luxury-in">
<!-- Close Button -->
<button @click="isPreviewOpen = false"
class="absolute -top-12 right-0 p-2 text-white/50 hover:text-white transition-colors">
<svg class="size-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<!-- Content Area -->
<div class="w-full bg-slate-900/40 rounded-[2rem] border border-white/5 overflow-hidden shadow-2xl flex items-center justify-center">
<template x-if="isPreviewOpen && previewAd.type === 'image'">
<img :src="previewAd.url" class="max-w-full max-h-[80vh] object-contain shadow-2xl">
</template>
<template x-if="isPreviewOpen && previewAd.type === 'video'">
<video :src="previewAd.url" controls autoplay class="max-w-full max-h-[80vh] shadow-2xl"></video>
</template>
</div>
<!-- Footer Info -->
<div class="mt-4 text-center">
<h4 class="text-white font-black text-lg uppercase tracking-widest" x-text="previewAd.name"></h4>
<p class="text-white/40 text-[10px] font-bold uppercase tracking-[0.2em] mt-1" x-text="previewAd.type + ' | ' + previewAd.duration + 's'"></p>
</div>
</div>
</div>
<!-- Sequence Preview Modal -->
<div x-show="isSequencePreviewOpen"
class="fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@keydown.escape.window="stopSequencePreview()">
<div class="fixed inset-0 bg-slate-950/95 backdrop-blur-md" @click="stopSequencePreview()"></div>
<div class="relative w-full max-w-5xl h-[85vh] flex flex-col items-center justify-center animate-luxury-in" x-show="currentSequenceAd">
<!-- Close Button -->
<button @click="stopSequencePreview()"
class="absolute -top-12 right-0 p-2 text-white/50 hover:text-white transition-colors">
<svg class="size-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<!-- Media Container -->
<div class="relative w-full h-full bg-slate-900 rounded-[2rem] border border-white/5 overflow-hidden shadow-2xl flex items-center justify-center">
<template x-if="currentSequenceAd && currentSequenceAd.advertisement.type === 'image'">
<img :src="currentSequenceAd.advertisement.url"
class="max-w-full max-h-full object-contain animate-luxury-in"
:key="'img-'+currentSequenceAd.id">
</template>
<template x-if="currentSequenceAd && currentSequenceAd.advertisement.type === 'video'">
<video :src="currentSequenceAd.advertisement.url"
autoplay muted playsinline
class="max-w-full max-h-full object-contain animate-luxury-in"
:key="'vid-'+currentSequenceAd.id"></video>
</template>
<!-- Progress Bar -->
<div class="absolute bottom-0 left-0 h-1.5 bg-cyan-500 transition-all duration-100 ease-linear"
:style="'width: ' + sequenceProgress + '%'"></div>
</div>
<!-- Header Info -->
<div class="absolute top-6 left-6 right-6 flex items-center justify-between z-10 w-[calc(100%-3rem)]">
<div class="bg-black/60 backdrop-blur-md rounded-xl px-4 py-2 border border-white/10 flex items-center gap-3">
<span class="text-white font-black tracking-widest text-sm" x-text="currentSequenceAd?.advertisement.name"></span>
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
<span class="text-cyan-400 font-black tracking-widest text-xs uppercase" x-text="(currentSequenceIndex + 1) + ' / ' + sequenceAds.length"></span>
<span class="w-1.5 h-1.5 rounded-full bg-white/20"></span>
<span class="text-white/80 font-mono font-bold tracking-tight text-xs tabular-nums" x-text="Math.ceil(sequenceRemainingTime) + 's'"></span>
</div>
<div class="flex items-center gap-2">
<button @click.stop="prevSequenceAd()" class="p-2.5 bg-black/60 backdrop-blur-md rounded-xl border border-white/10 text-white hover:bg-white/20 transition-all group">
<svg class="size-5 group-hover:-translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7"/></svg>
</button>
<!-- Play/Pause -->
<button @click.stop="toggleSequencePlay()" class="p-3 bg-cyan-500 backdrop-blur-md rounded-xl border border-cyan-400/30 text-white hover:bg-cyan-400 transition-all shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<svg x-show="!isSequencePaused" class="size-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
<svg x-show="isSequencePaused" class="size-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button @click.stop="nextSequenceAd()" class="p-2.5 bg-black/60 backdrop-blur-md rounded-xl border border-white/10 text-white hover:bg-white/20 transition-all group">
<svg class="size-5 group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
</div>
</div>
</div>
<x-delete-confirm-modal
:title="__('Delete Advertisement Confirmation')"
:message="__('Are you sure you want to delete this advertisement? This will also remove all assignments to machines.')"
/>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('adManager', () => ({
activeTab: 'list',
selectedMachineId: null,
machines: [],
allAds: [],
machineAds: {
vending: [],
visit_gift: [],
standby: []
},
urls: {},
// Ad CRUD Modal
isAdModalOpen: false,
isDeleteConfirmOpen: false,
deleteFormAction: '',
// Preview
isPreviewOpen: false,
previewAd: { url: '', type: '', name: '', duration: 15 },
adFormMode: 'add',
fileName: '',
mediaPreview: null,
adForm: {
id: null,
name: '',
type: 'image',
duration: 15,
is_active: true,
url: '',
start_at: '',
end_at: ''
},
// Assign Modal
isAssignModalOpen: false,
assignForm: {
machine_id: null,
advertisement_id: '',
position: ''
},
// Sequence Preview
isSequencePreviewOpen: false,
sequenceAds: [],
currentSequenceIndex: 0,
sequenceInterval: null,
sequenceRemainingTime: 0,
sequenceProgress: 0,
isSequencePaused: false,
get currentSequenceAd() {
return this.sequenceAds[this.currentSequenceIndex] || null;
},
startSequencePreview(pos) {
if (!this.machineAds[pos] || this.machineAds[pos].length === 0) return;
this.sequenceAds = this.machineAds[pos];
this.currentSequenceIndex = 0;
this.isSequencePreviewOpen = true;
this.isSequencePaused = false;
this.playSequenceAd();
},
stopSequencePreview() {
this.isSequencePreviewOpen = false;
this.clearSequenceTimers();
},
clearSequenceTimers() {
if (this.sequenceInterval) clearInterval(this.sequenceInterval);
},
playSequenceAd() {
this.clearSequenceTimers();
if (this.isSequencePaused) return;
const currentAd = this.currentSequenceAd?.advertisement;
if (!currentAd) return;
this.sequenceRemainingTime = currentAd.duration;
this.sequenceProgress = 0;
this.sequenceInterval = setInterval(() => {
if (this.isSequencePaused) return;
this.sequenceRemainingTime -= 0.1;
this.sequenceProgress = ((currentAd.duration - this.sequenceRemainingTime) / currentAd.duration) * 100;
if (this.sequenceRemainingTime <= 0) {
this.nextSequenceAd();
}
}, 100);
},
toggleSequencePlay() {
this.isSequencePaused = !this.isSequencePaused;
},
nextSequenceAd() {
this.currentSequenceIndex++;
if (this.currentSequenceIndex >= this.sequenceAds.length) {
this.currentSequenceIndex = 0; // Loop back
}
this.playSequenceAd();
},
prevSequenceAd() {
this.currentSequenceIndex--;
if (this.currentSequenceIndex < 0) {
this.currentSequenceIndex = this.sequenceAds.length - 1;
}
this.playSequenceAd();
},
openPreview(ad) {
this.previewAd = { ...ad };
this.isPreviewOpen = true;
},
// Sort Reordering logic
moveUp(position, index) {
if (index > 0) {
const list = this.machineAds[position];
const temp = list[index];
list[index] = list[index - 1];
list[index - 1] = temp;
this.syncSortOrder(position);
}
},
moveDown(position, index) {
const list = this.machineAds[position];
if (index < list.length - 1) {
const temp = list[index];
list[index] = list[index + 1];
list[index + 1] = temp;
this.syncSortOrder(position);
}
},
async syncSortOrder(position) {
const list = this.machineAds[position];
const assignmentIds = list.map(item => item.id);
try {
const response = await fetch(this.urls.reorder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ assignment_ids: assignmentIds })
});
const result = await response.json();
if (result.success) {
window.showToast?.(result.message, 'success');
} else {
window.showToast?.(result.message || 'Error', 'error');
this.fetchMachineAds(); // 如果更新失敗,重取恢復畫面原本樣子
}
} catch (e) {
console.error('Failed to update sort order', e);
window.showToast?.('System Error', 'error');
}
},
handleFileChange(e) {
const file = e.target.files[0];
if (!file) return;
// Validation
const isVideo = file.type.startsWith('video/');
const isImage = file.type.startsWith('image/');
const maxSize = isVideo ? 50 * 1024 * 1024 : 10 * 1024 * 1024; // 50MB for video, 10MB for image
if (file.size > maxSize) {
window.showToast?.(`{{ __("File is too large") }} (${isVideo ? '50MB' : '10MB'} MAX)`, 'error');
e.target.value = '';
return;
}
this.fileName = file.name;
// Set form type based on file
if (isVideo) this.adForm.type = 'video';
else if (isImage) this.adForm.type = 'image';
// Local Preview
const reader = new FileReader();
reader.onload = (event) => {
this.mediaPreview = event.target.result;
};
reader.readAsDataURL(file);
// Update Select UI
this.$nextTick(() => {
window.HSSelect.getInstance('#ad_type_select')?.setValue(this.adForm.type);
});
},
removeMedia() {
this.fileName = '';
this.mediaPreview = null;
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
// If editing, we still keep the original url in adForm.url but the UI shows "empty"
},
async submitAdForm() {
const form = this.$refs.adFormEl;
const formData = new FormData(form);
// Basic validation
if (!this.adForm.name) {
window.showToast?.('{{ __("Please enter a name") }}', 'error');
return;
}
if (this.adFormMode === 'add' && !this.fileName) {
window.showToast?.('{{ __("Please select a file") }}', 'error');
return;
}
try {
const url = this.adFormMode === 'add' ? this.urls.store : this.urls.update.replace(':id', this.adForm.id);
if (this.adFormMode === 'edit') formData.append('_method', 'PUT');
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.success) {
window.showToast?.(result.message, 'success');
this.isAdModalOpen = false;
location.reload(); // Reload to refresh the list
} else {
window.showToast?.(result.message || 'Error', 'error');
}
} catch (e) {
console.error('Failed to submit ad', e);
window.showToast?.('System Error', 'error');
}
},
formatDateForInput(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
},
async submitAssignment() {
try {
const response = await fetch(this.urls.assign, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(this.assignForm)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Server error response:', errorText);
throw new Error(`Server responded with ${response.status}`);
}
const result = await response.json();
if (result.success) {
this.isAssignModalOpen = false;
this.fetchMachineAds();
window.showToast?.(result.message, 'success');
} else {
window.showToast?.(result.message || 'Error', 'error');
}
} catch (e) {
console.error('Failed to assign ad', e);
}
},
init() {
this.urls = JSON.parse(this.$el.dataset.urls);
this.machines = JSON.parse(this.$el.dataset.machines || '[]');
this.allAds = JSON.parse(this.$el.dataset.allAds || '[]');
this.activeTab = this.$el.dataset.activeTab || 'list';
// Sync custom selects when modals open
this.$watch('isAdModalOpen', value => {
if (value) {
this.$nextTick(() => {
window.HSSelect.getInstance('#ad_type_select')?.setValue(this.adForm.type);
window.HSSelect.getInstance('#ad_duration_select')?.setValue(this.adForm.duration.toString());
if (document.querySelector('#ad_company_select')) {
window.HSSelect.getInstance('#ad_company_select')?.setValue(this.adForm.company_id || '');
}
// 確保 Flatpickr 實例同步顯示目前的時間值
if (this.$refs.startAtPicker?._flatpickr) {
this.$refs.startAtPicker._flatpickr.setDate(this.adForm.start_at);
}
if (this.$refs.endAtPicker?._flatpickr) {
this.$refs.endAtPicker._flatpickr.setDate(this.adForm.end_at);
}
});
}
});
},
selectMachine(id) {
if (!id || id === ' ') {
this.selectedMachineId = null;
return;
}
this.selectedMachineId = id;
this.fetchMachineAds();
},
async fetchMachineAds() {
const url = this.urls.getMachineAds.replace(':id', this.selectedMachineId);
try {
const response = await fetch(url);
const result = await response.json();
if (result.success) {
this.machineAds = {
vending: result.data.vending || [],
visit_gift: result.data.visit_gift || [],
standby: result.data.standby || []
};
}
} catch (e) {
console.error('Failed to fetch machine ads', e);
}
},
openAddModal() {
this.adFormMode = 'add';
this.adForm = { id: null, company_id: '', name: '', type: 'image', duration: 15, is_active: true, url: '', start_at: '', end_at: '' };
this.fileName = '';
this.mediaPreview = null;
this.isAdModalOpen = true;
},
openEditModal(ad) {
this.adFormMode = 'edit';
this.adForm = {
...ad,
start_at: this.formatDateForInput(ad.start_at),
end_at: this.formatDateForInput(ad.end_at)
};
this.fileName = '';
this.mediaPreview = ad.url; // Use existing URL as preview
this.isAdModalOpen = true;
},
openAssignModal(pos) {
this.assignForm = {
machine_id: this.selectedMachineId,
advertisement_id: '',
position: pos,
sort_order: this.machineAds[pos]?.length || 0
};
this.updateAssignSelect();
this.isAssignModalOpen = true;
},
updateAssignSelect() {
const machine = this.machines.find(m => m.id == this.selectedMachineId);
const companyId = machine ? machine.company_id : null;
// 篩選出同公司的素材(或是系統層級的共通素材如果 company_id 為 null
// 若沒有特別設定,通常 null 為系統共用
const filteredAds = this.allAds.filter(ad => ad.company_id == companyId || ad.company_id == null);
const wrapper = document.getElementById('assign_ad_select_wrapper');
if (!wrapper) return;
wrapper.innerHTML = '';
const selectEl = document.createElement('select');
selectEl.name = 'advertisement_id';
selectEl.id = 'assign_ad_select_' + Date.now();
selectEl.className = 'hidden';
const configStr = JSON.stringify({
"placeholder": "{{ __('Please select a material') }}",
"hasSearch": true,
"searchPlaceholder": "{{ __('Search...') }}",
"isHidePlaceholder": false,
"searchClasses": "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses": "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses": "hs-select-toggle luxury-select-toggle",
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[100] animate-luxury-in",
"optionClasses": "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate": '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>'
});
selectEl.setAttribute('data-hs-select', configStr);
if (filteredAds.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = "{{ __('No materials available') }}";
opt.disabled = true;
selectEl.appendChild(opt);
} else {
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = "{{ __('Please select a material') }}";
selectEl.appendChild(emptyOpt);
filteredAds.forEach(ad => {
const opt = document.createElement('option');
opt.value = ad.id;
opt.textContent = `${ad.name} (${ad.type === 'video' ? "{{ __('video') }}" : "{{ __('image') }}"}, ${ad.duration}s)`;
opt.setAttribute('data-title', opt.textContent);
if (ad.id === this.assignForm.advertisement_id) opt.selected = true;
selectEl.appendChild(opt);
});
}
wrapper.appendChild(selectEl);
// Set the initial value after appending but before autoInit
if (this.assignForm.advertisement_id) {
selectEl.value = this.assignForm.advertisement_id;
}
selectEl.addEventListener('change', (e) => {
this.assignForm.advertisement_id = e.target.value;
});
this.$nextTick(() => {
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit(['select']);
// If we have a value, ensure the Preline instance reflects it
if (this.assignForm.advertisement_id) {
setTimeout(() => {
window.HSSelect.getInstance(selectEl)?.setValue(this.assignForm.advertisement_id);
}, 50);
}
}
});
},
async removeAssignment(id) {
if (!confirm("{{ __('Are you sure you want to remove this assignment?') }}")) return;
const url = this.urls.removeAssignment.replace(':id', id);
try {
const response = await fetch(url, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const result = await response.json();
if (result.success) {
this.fetchMachineAds();
window.showToast?.(result.message, 'success');
}
} catch (e) {
console.error('Failed to remove assignment', e);
}
},
confirmDelete(id) {
this.deleteFormAction = this.urls.delete.replace(':id', id);
this.isDeleteConfirmOpen = true;
}
}));
});
</script>
@endsection

View File

@@ -0,0 +1,222 @@
<div x-show="isAdModalOpen"
class="fixed inset-0 z-[100] overflow-y-auto"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="isAdModalOpen = false"></div>
<div class="flex items-center justify-center min-h-screen p-4 sm:p-0">
<div class="relative inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-xl sm:w-full overflow-visible animate-luxury-in">
<!-- Modal Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight" x-text="adFormMode === 'add' ? '{{ __("Add Advertisement") }}' : '{{ __("Edit Advertisement") }}'"></h3>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{{ __('Manage your ad material details') }}</p>
</div>
<button @click="isAdModalOpen = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form x-ref="adFormEl"
:action="adFormMode === 'add' ? urls.store : urls.update.replace(':id', adForm.id)"
method="POST"
enctype="multipart/form-data"
@submit.prevent="submitAdForm"
class="space-y-6">
@csrf
<template x-if="adFormMode === 'edit'">
@method('PUT')
</template>
@if(auth()->user()->isSystemAdmin())
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Company Name') }}
</label>
<x-searchable-select
name="company_id"
id="ad_company_select"
:has-search="true"
:selected="null"
:options="['' => __('System Default (All Companies)')] + $companies->pluck('name', 'id')->toArray()"
@change="adForm.company_id = $event.target.value"
/>
</div>
@endif
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Material Name') }}
<span class="text-rose-500 ml-0.5">*</span>
</label>
<input type="text" name="name" x-model="adForm.name" required
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
placeholder="{{ __('Enter ad material name') }}">
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Material Type') }}
<span class="text-rose-500 ml-0.5">*</span>
</label>
<x-searchable-select
name="type"
id="ad_type_select"
:has-search="false"
:selected="null"
:options="['image' => __('image'), 'video' => __('video')]"
@change="adForm.type = $event.target.value; removeMedia();"
/>
</div>
<!-- Duration -->
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Duration (Seconds)') }}
<span class="text-rose-500 ml-0.5">*</span>
</label>
<x-searchable-select
name="duration"
id="ad_duration_select"
:has-search="false"
:selected="null"
:options="['15' => '15 ' . __('Seconds'), '30' => '30 ' . __('Seconds'), '60' => '60 ' . __('Seconds')]"
@change="adForm.duration = $event.target.value"
/>
</div>
</div>
<!-- Scheduling -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Publish Time') }}
</label>
<div class="relative group/input">
<input type="text" name="start_at" x-ref="startAtPicker" x-model="adForm.start_at"
x-init="flatpickr($refs.startAtPicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
onClose: (selectedDates, dateStr) => { adForm.start_at = dateStr; }
})"
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
placeholder="YYYY/MM/DD HH:MM">
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
{{ __('Expired Time') }}
</label>
<div class="relative group/input">
<input type="text" name="end_at" x-ref="endAtPicker" x-model="adForm.end_at"
x-init="flatpickr($refs.endAtPicker, {
enableTime: true,
dateFormat: 'Y/m/d H:i',
time_24hr: true,
locale: window.flatpickrLocale,
onClose: (selectedDates, dateStr) => { adForm.end_at = dateStr; }
})"
class="w-full h-12 bg-slate-50 dark:bg-slate-800/50 border-none rounded-xl px-4 pr-10 text-sm font-bold text-slate-800 dark:text-white focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-400"
placeholder="YYYY/MM/DD HH:MM">
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 group-hover/input:text-cyan-500 transition-colors pointer-events-none">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
</div>
</div>
</div>
<!-- File Upload (Luxury UI Pattern) -->
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">
<span x-text="adForm.type === 'image' ? '{{ __("Upload Image") }}' : '{{ __("Upload Video") }}'"></span>
<template x-if="adFormMode === 'add'">
<span class="text-rose-500 ml-0.5">*</span>
</template>
</label>
<div class="relative group">
<template x-if="!mediaPreview">
<div @click="$refs.fileInput.click()" class="aspect-video rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 flex flex-col items-center justify-center gap-4 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800/80 hover:border-cyan-500/50 transition-all duration-300 group">
<div class="p-4 rounded-2xl bg-white dark:bg-slate-800 shadow-sm border border-slate-100 dark:border-slate-700 group-hover:scale-110 group-hover:text-cyan-500 transition-all duration-300">
<svg class="size-8 text-slate-400 dark:text-slate-500 group-hover:text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
</div>
<div class="text-center px-4">
<p class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-tighter">{{ __('Click to upload') }}</p>
<p class="text-[10px] font-bold text-slate-400 mt-1 uppercase" x-text="adForm.type === 'image' ? 'JPG, PNG, WEBP (Max 10MB)' : 'MP4, MOV (Max 50MB)'"></p>
</div>
</div>
</template>
<template x-if="mediaPreview">
<div class="relative aspect-video rounded-3xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl group/media">
<template x-if="adForm.type === 'image'">
<img :src="mediaPreview" class="w-full h-full object-cover">
</template>
<template x-if="adForm.type === 'video'">
<div class="w-full h-full bg-slate-900 flex items-center justify-center">
<video :src="mediaPreview" class="max-w-full max-h-full object-contain" muted></video>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="p-4 rounded-full bg-block/20 backdrop-blur-sm border border-white/10">
<svg class="size-8 text-white/50" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5.14v14l11-7-11-7z"/></svg>
</div>
</div>
</div>
</template>
<div class="absolute inset-0 bg-slate-950/40 opacity-0 group-hover/media:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-3">
<button type="button" @click="$refs.fileInput.click()" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-cyan-500 hover:text-white transition-all duration-300 shadow-lg">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
</button>
<button type="button" @click="openPreview(adFormMode === 'edit' ? (fileName ? {url: mediaPreview, type: adForm.type, name: fileName} : adForm) : {url: mediaPreview, type: adForm.type, name: fileName})" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-emerald-500 hover:text-white transition-all duration-300 shadow-lg">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
</button>
<button type="button" @click="removeMedia" class="p-3 rounded-2xl bg-white text-slate-800 hover:bg-rose-500 hover:text-white transition-all duration-300 shadow-lg" x-show="mediaPreview">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
</div>
</div>
</template>
<input type="file" name="file" x-ref="fileInput"
:accept="adForm.type === 'image' ? 'image/jpeg,image/png,image/gif,image/webp' : 'video/mp4,video/quicktime,video/x-msvideo'"
class="hidden" @change="handleFileChange">
</div>
</div>
<!-- Status -->
<div class="flex items-center gap-3 px-2">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="is_active" x-model="adForm.is_active" :value="adForm.is_active ? '1' : '0'" class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div>
</label>
<span class="text-sm font-black text-slate-700 dark:text-slate-200 tracking-tight">{{ __('Active Status') }}</span>
</div>
<!-- Action Footer -->
<div class="flex items-center justify-end gap-3 pt-2">
<button type="button" @click="isAdModalOpen = false" class="px-6 py-3 text-sm font-black text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors uppercase tracking-widest">
{{ __('Cancel') }}
</button>
<button type="submit" class="btn-luxury-primary px-10 py-3">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>
<span>{{ __('Save Material') }}</span>
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,84 @@
<div x-show="isAssignModalOpen"
class="fixed inset-0 z-[100] overflow-y-auto"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="isAssignModalOpen = false"></div>
<div class="flex items-center justify-center min-h-screen p-4 sm:p-0">
<div class="relative inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full overflow-visible animate-luxury-in">
<!-- Modal Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Assign Advertisement') }}</h3>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{{ __('Select a material to play on this machine') }}</p>
</div>
<button @click="isAssignModalOpen = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form @submit.prevent="submitAssignment" class="space-y-6">
<!-- Machine & Position Info (Read-only) -->
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-2xl border border-slate-100 dark:border-white/5">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Target Position') }}</p>
<p class="text-sm font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-tight" x-text="{ vending: '{{ __('vending') }}', visit_gift: '{{ __('visit_gift') }}', standby: '{{ __('standby') }}' }[assignForm.position] || assignForm.position"></p>
</div>
</div>
<!-- Ad Material Selection -->
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest ml-1">{{ __('Select Material') }}</label>
<div id="assign_ad_select_wrapper">
<!-- Select dropdown will be dynamically built by updateAssignSelect() based on machine company context -->
</div>
</div>
<!-- Action Footer -->
<div class="flex items-center justify-end gap-3 pt-6">
<button type="button" @click="isAssignModalOpen = false" class="px-6 py-3 text-sm font-black text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors uppercase tracking-widest">
{{ __('Cancel') }}
</button>
<button type="submit" class="btn-luxury-primary px-10 py-3">
{{ __('Confirm Assignment') }}
</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Note: This logic is added to the main adManager data object in index.blade.php
// Here we just define the submit handler
async function submitAssignment() {
try {
const response = await fetch(this.urls.assign, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify(this.assignForm)
});
const result = await response.json();
if (result.success) {
this.isAssignModalOpen = false;
this.fetchMachineAds();
window.showToast?.(result.message, 'success');
} else {
window.showToast?.(result.message || 'Error', 'error');
}
} catch (e) {
console.error('Failed to assign ad', e);
}
}
</script>

View File

@@ -34,7 +34,29 @@
</div> </div>
</div> </div>
<form id="edit-form" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6"> <form id="edit-form" x-data="{
imagePreviews: [
{{ isset($machine->images[0]) ? "'" . Storage::disk('public')->url($machine->images[0]) . "'" : 'null' }},
{{ isset($machine->images[1]) ? "'" . Storage::disk('public')->url($machine->images[1]) . "'" : 'null' }},
{{ isset($machine->images[2]) ? "'" . Storage::disk('public')->url($machine->images[2]) . "'" : 'null' }}
],
removeImages: [false, false, false],
handleImageUpload(event, index) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviews[index] = e.target.result;
this.removeImages[index] = false;
};
reader.readAsDataURL(file);
},
removeImage(index) {
this.imagePreviews[index] = null;
this.removeImages[index] = true;
if (this.$refs['imageInput_' + index]) this.$refs['imageInput_' + index].value = '';
}
}" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6">
@csrf @csrf
@method('PUT') @method('PUT')
@@ -58,7 +80,9 @@
</div> </div>
@endif @endif
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="flex flex-col lg:flex-row gap-8 items-start">
<!-- Main Content Area -->
<div class="flex-1 space-y-8 order-2 lg:order-1 capitalize">
<!-- Left: Basic info & Hardware --> <!-- Left: Basic info & Hardware -->
<div class="lg:col-span-2 space-y-6"> <div class="lg:col-span-2 space-y-6">
<!-- Basic Information --> <!-- Basic Information -->
@@ -74,12 +98,12 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Name') }}</label> <label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required> <input type="text" name="name" value="{{ old('name', $machine->name) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Serial Number') }}</label> <label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Serial No') }} <span class="text-rose-500">*</span></label>
<input type="text" value="{{ $machine->serial_no }}" class="luxury-input w-full bg-slate-50/50 dark:bg-slate-900/50 text-slate-400 cursor-not-allowed" readonly> <input type="text" name="serial_no" value="{{ old('serial_no', $machine->serial_no) }}" class="luxury-input w-full" required>
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label> <label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Location') }}</label>
@@ -195,9 +219,73 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Right: System & Payment --> <!-- Right Column: Images & Primary System Settings -->
<div class="space-y-8"> <div class="w-full lg:w-96 space-y-8 order-1 lg:order-2 lg:sticky top-24">
<!-- Machine Images -->
<div class="luxury-card rounded-[2.5rem] p-8 animate-luxury-in">
<div class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</div>
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Machine Images') }}</h3>
</div>
<div class="space-y-8">
@for($i = 0; $i < 3; $i++)
<div class="space-y-3">
<div class="flex items-center justify-between px-1 text-xs font-black text-slate-400 uppercase tracking-widest">
<span>{{ __('Photo Slot') }} {{ $i + 1 }}</span>
<template x-if="imagePreviews[{{ $i }}]">
<span class="text-emerald-500 flex items-center gap-1 lowercase tracking-normal font-bold">
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="m4.5 12.75 6 6 9-13.5"/></svg>
{{ __('set') }}
</span>
</template>
</div>
<div class="relative group">
<input type="hidden" name="remove_image_{{ $i }}" :value="removeImages[{{ $i }}] ? '1' : '0'">
<template x-if="!imagePreviews[{{ $i }}]">
<div @click="$refs.imageInput_{{ $i }}.click()" class="aspect-video rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 flex flex-col items-center justify-center gap-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800/80 hover:border-cyan-500/50 transition-all duration-300 group/upload">
<div class="p-2.5 rounded-xl bg-white dark:bg-slate-800 shadow-sm border border-slate-100 dark:border-slate-700 group-hover/upload:scale-110 group-hover/upload:text-cyan-500 transition-all duration-300 text-slate-400">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
</div>
<div class="text-center">
<p class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-tighter">{{ __('Click to upload') }}</p>
</div>
</div>
</template>
<template x-if="imagePreviews[{{ $i }}]">
<div class="relative aspect-video rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-lg group/image">
<img :src="imagePreviews[{{ $i }}]" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-slate-950/40 opacity-0 group-hover/image:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-3">
<button type="button" @click="$refs.imageInput_{{ $i }}.click()" class="p-2.5 rounded-xl bg-white text-slate-800 hover:bg-cyan-500 hover:text-white transition-all duration-300 shadow-lg">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
</button>
<button type="button" @click="removeImage({{ $i }})" class="p-2.5 rounded-xl bg-white text-slate-800 hover:bg-rose-500 hover:text-white transition-all duration-300 shadow-lg">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
</div>
</div>
</template>
<input type="file" name="image_{{ $i }}" x-ref="imageInput_{{ $i }}" class="hidden" accept="image/*" @change="handleImageUpload($event, {{ $i }})">
</div>
</div>
@endfor
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-900/50 border border-slate-100 dark:border-white/5">
<p class="text-[10px] font-bold text-slate-400 leading-relaxed uppercase tracking-widest text-center">
{{ __('PNG, JPG, WEBP up to 10MB') }}
</p>
</div>
</div>
</div>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-20" style="animation-delay: 200ms"> <div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-20" style="animation-delay: 200ms">
<div class="flex items-center gap-3 mb-8"> <div class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500"> <div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500">

View File

@@ -2,6 +2,7 @@
@section('content') @section('content')
<div class="space-y-2 pb-20" x-data="{ <div class="space-y-2 pb-20" x-data="{
tab: '{{ $tab }}',
showCreateMachineModal: false, showCreateMachineModal: false,
showPhotoModal: false, showPhotoModal: false,
showDetailDrawer: false, showDetailDrawer: false,
@@ -18,14 +19,17 @@
showMaintenanceQrModal: false, showMaintenanceQrModal: false,
maintenanceQrMachineName: '', maintenanceQrMachineName: '',
maintenanceQrUrl: '', maintenanceQrUrl: '',
permissionSearchQuery: '',
openMaintenanceQr(machine) { openMaintenanceQr(machine) {
this.maintenanceQrMachineName = machine.name; this.maintenanceQrMachineName = machine.name;
const baseUrl = '{{ route('admin.maintenance.create', ['serial_no' => 'SERIAL_NO']) }}'; const baseUrl = '{{ route('admin.maintenance.create', ['serial_no' => 'SERIAL_NO']) }}';
this.maintenanceQrUrl = baseUrl.replace('SERIAL_NO', machine.serial_no); this.maintenanceQrUrl = baseUrl.replace('SERIAL_NO', machine.serial_no);
this.showMaintenanceQrModal = true; this.showMaintenanceQrModal = true;
}, },
openDetail(machine) { openDetail(machine, id, serial) {
this.currentMachine = machine; this.currentMachine = machine;
window.activeMachineId = id || machine?.id;
window.activeMachineSerial = serial || machine?.serial_no;
this.showDetailDrawer = true; this.showDetailDrawer = true;
}, },
openPhotoModal(machine) { openPhotoModal(machine) {
@@ -60,14 +64,137 @@
confirmDelete(action) { confirmDelete(action) {
this.deleteFormAction = action; this.deleteFormAction = action;
this.isDeleteConfirmOpen = true; this.isDeleteConfirmOpen = true;
},
// API Token Management
showApiToken: false,
loadingRegenerate: false,
isRegenerateConfirmOpen: false,
copyToken(machine) {
if (!machine?.api_token) return;
navigator.clipboard.writeText(machine.api_token).then(() => {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('API Token Copied') }}', type: 'success' } }));
});
},
regenerateToken() {
this.isRegenerateConfirmOpen = true;
},
executeRegeneration(id, serial) {
// 僅使用機台序號 (Serial Number) 作為識別碼
const targetSerial = serial || window.activeMachineSerial || id;
if (!targetSerial) {
console.error('ExecuteRegeneration failed: No serial number available');
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: '{{ __('Missing machine identification') }}', type: 'error' }
}));
return;
}
console.log('ExecuteRegeneration using serial:', targetSerial);
this.isRegenerateConfirmOpen = false;
this.loadingRegenerate = true;
fetch(`/admin/basic-settings/machines/${targetSerial}/regenerate-token`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=\'csrf-token\']').content,
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(res => res.json()).then(data => {
this.loadingRegenerate = false;
if(data.success) {
if (this.currentMachine) {
this.currentMachine.api_token = data.api_token;
}
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message, type: 'success' } }));
}
}).catch(() => {
this.loadingRegenerate = false;
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Error processing request') }}', type: 'error' } }));
});
},
// Permission Management
showPermissionModal: false,
isPermissionsLoading: false,
targetUserId: null,
targetUserName: '',
allMachines: [],
allMachinesCount: 0,
permissions: {},
openPermissionModal(user) {
this.targetUserId = user.id;
this.targetUserName = user.name;
this.showPermissionModal = true;
this.isPermissionsLoading = true;
this.permissions = {};
this.allMachines = [];
this.permissionSearchQuery = '';
fetch(`/admin/machines/permissions/accounts/${user.id}`)
.then(res => res.json())
.then(data => {
if (data.machines) {
this.allMachines = data.machines;
this.allMachinesCount = data.machines.length;
const tempPermissions = {};
data.machines.forEach(m => {
tempPermissions[m.id] = (data.assigned_ids || []).includes(m.id);
});
this.permissions = tempPermissions;
}
})
.catch(e => {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Failed to load permissions') }}', type: 'error' } }));
})
.finally(() => {
this.isPermissionsLoading = false;
});
},
togglePermission(machineId) {
this.permissions = { ...this.permissions, [machineId]: !this.permissions[machineId] };
},
toggleSelectAll() {
const filtered = this.allMachines.filter(m =>
!this.permissionSearchQuery ||
m.name.toLowerCase().includes(this.permissionSearchQuery.toLowerCase()) ||
m.serial_no.toLowerCase().includes(this.permissionSearchQuery.toLowerCase())
);
if (filtered.length === 0) return;
const allSelected = filtered.every(m => this.permissions[m.id]);
filtered.forEach(m => this.permissions[m.id] = !allSelected);
},
savePermissions() {
const machineIds = Object.keys(this.permissions).filter(id => this.permissions[id]);
fetch(`/admin/machines/permissions/accounts/${this.targetUserId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=\'csrf-token\']').content,
'Accept': 'application/json'
},
body: JSON.stringify({ machine_ids: machineIds })
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message, type: 'success' } }));
setTimeout(() => window.location.reload(), 500);
} else {
throw new Error(data.error || 'Update failed');
}
})
.catch(e => {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: e.message, type: 'error' } }));
});
} }
}"> }" @execute-regenerate.window="executeRegeneration($event.detail)">
<!-- 1. Header Area --> <!-- 1. Header Area -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Machine Settings') }}</h1> <h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Machine Settings') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ <p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Management of operational parameters and models') }}</p>
__('Management of operational parameters and models') }}</p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@if($tab === 'machines') @if($tab === 'machines')
@@ -77,7 +204,7 @@
</svg> </svg>
<span>{{ __('Add Machine') }}</span> <span>{{ __('Add Machine') }}</span>
</button> </button>
@else @elseif($tab === 'models')
<button @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" />
@@ -98,26 +225,43 @@
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'models' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}"> class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'models' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
{{ __('Models') }} {{ __('Models') }}
</a> </a>
<a href="{{ route('admin.basic-settings.machines.index', ['tab' => 'permissions']) }}"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'permissions' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
{{ __('Machine Permissions') }}
</a>
</div> </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">
<!-- Toolbar & Filters --> <!-- Toolbar & Filters -->
<div class="flex items-center justify-between mb-8"> <div class="flex items-center justify-between mb-8">
<form method="GET" action="{{ route('admin.basic-settings.machines.index') }}" class="relative group"> <div class="flex items-center gap-4">
<input type="hidden" name="tab" value="{{ $tab }}"> <form method="GET" action="{{ route('admin.basic-settings.machines.index') }}" class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10"> <input type="hidden" name="tab" value="{{ $tab }}">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" <span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" <svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
stroke-linejoin="round"> viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
<circle cx="11" cy="11" r="8"></circle> stroke-linejoin="round">
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <circle cx="11" cy="11" r="8"></circle>
</svg> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</span> </svg>
<input type="text" name="search" value="{{ request('search') }}" </span>
placeholder="{{ $tab === 'machines' ? __('Search machines...') : __('Search models...') }}" <input type="text" name="search" value="{{ request('search') }}"
class="luxury-input py-2.5 pl-12 pr-6 block w-64"> placeholder="{{ $tab === 'machines' ? __('Search machines...') : ($tab === 'models' ? __('Search models...') : __('Search accounts...')) }}"
</form> class="luxury-input py-2.5 pl-12 pr-6 block w-64">
</form>
@if($tab === 'permissions' && auth()->user()->isSystemAdmin())
<div class="w-72">
<form method="GET" action="{{ route('admin.basic-settings.machines.index') }}">
<input type="hidden" name="tab" value="permissions">
<input type="hidden" name="search" value="{{ request('search') }}">
<x-searchable-select name="company_id" :options="$companies" :selected="request('company_id')"
:placeholder="__('All Companies')" onchange="this.form.submit()" />
</form>
</div>
@endif
</div>
</div> </div>
@if($tab === 'machines') @if($tab === 'machines')
@@ -149,10 +293,10 @@
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80"> <tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($machines as $machine) @forelse($machines as $machine)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300"> <tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer" @click="openDetail({{ $machine->toJson() }})"> <td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<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 transition-all duration-300 overflow-hidden"> class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300 overflow-hidden">
@if(isset($machine->image_urls[0])) @if(isset($machine->image_urls[0]))
<img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover"> <img src="{{ $machine->image_urls[0] }}" class="w-full h-full object-cover">
@else @else
@@ -175,7 +319,7 @@
</div> </div>
</div> </div>
</td> </td>
<td class="px-6 py-6" @click="openDetail({{ $machine->toJson() }})"> <td class="px-6 py-6 cursor-pointer" @click='openDetail({{ $machine->toJson() }}, {{ $machine->id }}, "{{ $machine->serial_no }}")'>
<span <span
class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest"> class="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest">
{{ $machine->machineModel->name ?? '--' }} {{ $machine->machineModel->name ?? '--' }}
@@ -183,7 +327,7 @@
</td> </td>
<td class="px-6 py-6"> <td class="px-6 py-6">
@php @php
$isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInMinutes() < 5; $isOnline = $machine->last_heartbeat_at && $machine->last_heartbeat_at->diffInSeconds() < 30;
@endphp <div class="flex items-center gap-2.5"> @endphp <div class="flex items-center gap-2.5">
<div class="relative flex h-2.5 w-2.5"> <div class="relative flex h-2.5 w-2.5">
@if($isOnline) @if($isOnline)
@@ -212,7 +356,7 @@
<td class="px-6 py-6"> <td class="px-6 py-6">
<span <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"> class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest">
{{ $machine->company->name ?? __('None') }} {{ $machine->company->name ?? __('System') }}
</span> </span>
</td> </td>
<td class="px-6 py-6 text-right flex items-center justify-end gap-2"> <td class="px-6 py-6 text-right flex items-center justify-end gap-2">
@@ -245,8 +389,8 @@
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all inline-flex group/btn" 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') }}"> title="{{ __('View Details') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" <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" />
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg> </svg>
</button> </button>
</td> </td>
@@ -266,6 +410,108 @@
{{ $machines->appends(['tab' => 'machines'])->links('vendor.pagination.luxury') }} {{ $machines->appends(['tab' => 'machines'])->links('vendor.pagination.luxury') }}
</div> </div>
@elseif($tab === 'permissions')
<!-- Permissions Table -->
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Account Info') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-left">
{{ __('Company Name') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Authorized Machines') }}</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($users_list as $user)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 font-display text-left">
<div class="flex items-center gap-4 text-left">
<div
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
<div class="flex flex-col text-left">
<span
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{
$user->name }}</span>
<span
class="text-xs font-mono font-bold text-slate-500 tracking-widest uppercase">{{
$user->username }}</span>
</div>
</div>
</td>
<td class="px-6 py-6 text-left">
<span
class="px-2.5 py-1 rounded-lg text-xs font-bold border border-sky-100 dark:border-sky-900/30 bg-sky-50 dark:bg-sky-900/20 text-sky-600 dark:text-sky-400 tracking-widest uppercase">
{{ $user->company->name ?? __('System') }}
</span>
</td>
<td class="px-6 py-4 text-center">
<div
class="flex flex-wrap gap-2 justify-center lg:justify-start max-w-[420px] mx-auto lg:mx-0 max-h-[140px] overflow-y-auto pr-2 custom-scrollbar py-1 text-left">
@forelse($user->machines as $m)
<div
class="flex flex-col px-3 py-1.5 rounded-xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-white/5 hover:border-cyan-500/30 transition-all duration-300 text-left">
<span class="text-[11px] font-black text-slate-700 dark:text-slate-200 leading-tight">{{
$m->name }}</span>
<span class="text-[9px] font-mono font-bold text-cyan-500 tracking-tighter mt-0.5 opacity-80">{{
$m->serial_no }}</span>
</div>
@empty
<div class="w-full text-center lg:text-left">
<span
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest opacity-40 italic">--
{{ __('None') }} --</span>
</div>
@endforelse
</div>
</td>
<td class="px-6 py-6 text-right">
<button
@click='openPermissionModal({{ json_encode(["id" => $user->id, "name" => $user->name]) }})'
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500 hover:text-white transition-all duration-300 text-xs font-black uppercase tracking-widest shadow-sm shadow-cyan-500/5 group/auth">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 00-2 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>{{ __('Authorize') }}</span>
</button>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-6 py-24 text-center">
<div class="flex flex-col items-center gap-3 opacity-20">
<svg class="size-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2m16-10a4 4 0 11-8 0 4 4 0 018 0zM23 21v-2a4 4 0 00-3-3.87m-4-12a4 4 0 010 7.75" />
</svg>
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No accounts found') }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6 text-left mb-6">
@if($users_list)
{{ $users_list->appends(['tab' => 'permissions'])->links('vendor.pagination.luxury') }}
@endif
</div>
@else @else
<!-- Model Table --> <!-- Model Table -->
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -292,7 +538,7 @@
<td class="px-6 py-6"> <td class="px-6 py-6">
<div class="flex items-center gap-x-3"> <div class="flex items-center gap-x-3">
<div <div
class="flex-shrink-0 w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300"> class="flex-shrink-0 w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2"> stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
@@ -380,8 +626,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800"> class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
<div <div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center"> class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add <h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add Machine') }}</h3>
Machine') }}</h3>
<button @click="showCreateMachineModal = false" <button @click="showCreateMachineModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -396,23 +641,26 @@
<div class="px-8 py-8 space-y-6"> <div class="px-8 py-8 space-y-6">
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Machine Name') }}</label> {{ __('Machine Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="name" required class="luxury-input w-full" <input type="text" name="name" required class="luxury-input w-full"
placeholder="{{ __('Enter machine name') }}"> placeholder="{{ __('Enter machine name') }}">
</div> </div>
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Serial No') }}</label> {{ __('Serial No') }} <span class="text-rose-500">*</span>
</label>
<input type="text" name="serial_no" required class="luxury-input w-full" <input type="text" name="serial_no" required class="luxury-input w-full"
placeholder="{{ __('Enter serial number') }}"> placeholder="{{ __('Enter serial number') }}">
</div> </div>
<div class="relative z-20"> <div class="relative z-20">
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Owner') }}</label> {{ __('Owner') }}
<x-searchable-select name="company_id" required :placeholder="__('Select Owner')"> </label>
<x-searchable-select name="company_id" :placeholder="__('Select Owner')">
@foreach($companies as $company) @foreach($companies as $company)
<option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}"> <option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}">
{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }} {{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}
@@ -422,15 +670,17 @@
</div> </div>
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Location') }}</label> {{ __('Location') }}
</label>
<input type="text" name="location" class="luxury-input w-full" <input type="text" name="location" class="luxury-input w-full"
placeholder="{{ __('Enter machine location') }}"> placeholder="{{ __('Enter machine location') }}">
</div> </div>
<div class="relative z-10"> <div class="relative z-10">
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Model') }}</label> {{ __('Model') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="machine_model_id" required :placeholder="__('Select Model')"> <x-searchable-select name="machine_model_id" required :placeholder="__('Select Model')">
@foreach($models as $model) @foreach($models as $model)
<option value="{{ $model->id }}">{{ $model->name }}</option> <option value="{{ $model->id }}">{{ $model->name }}</option>
@@ -439,8 +689,9 @@
</div> </div>
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">
__('Machine Images') }} ({{ __('Max 3') }})</label> {{ __('Machine Images') }} ({{ __('Max 3') }})
</label>
<label <label
class="relative flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl cursor-pointer bg-slate-50/50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-800/80 transition-all group"> class="relative flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl cursor-pointer bg-slate-50/50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-800/80 transition-all group">
<template x-if="selectedFileCount === 0"> <template x-if="selectedFileCount === 0">
@@ -453,6 +704,9 @@
<p <p
class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest"> class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest">
{{ __('Click to upload') }}</p> {{ __('Click to upload') }}</p>
<p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 mt-1">
{{ __('PNG, JPG, WEBP up to 10MB') }} ({{ __('Max 3') }})
</p>
</div> </div>
</template> </template>
<template x-if="selectedFileCount > 0"> <template x-if="selectedFileCount > 0">
@@ -475,8 +729,7 @@
</div> </div>
<div <div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800"> class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showCreateMachineModal = false" class="btn-luxury-ghost">{{ <button type="button" @click="showCreateMachineModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
__('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button> <button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button>
</div> </div>
</form> </form>
@@ -500,8 +753,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800"> class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
<div <div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center"> class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add <h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Add Machine Model') }}</h3>
Machine Model') }}</h3>
<button @click="showCreateModelModal = false" <button @click="showCreateModelModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -517,16 +769,14 @@
<div class="px-8 py-8 space-y-6"> <div class="px-8 py-8 space-y-6">
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Model Name') }}</label>
__('Model Name') }}</label>
<input type="text" name="name" required class="luxury-input w-full" <input type="text" name="name" required class="luxury-input w-full"
placeholder="{{ __('Enter model name') }}"> placeholder="{{ __('Enter model name') }}">
</div> </div>
</div> </div>
<div <div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800"> class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{ <button type="button" @click="showCreateModelModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
__('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Create') }}</button> <button type="submit" class="btn-luxury-primary px-8">{{ __('Create') }}</button>
</div> </div>
</form> </form>
@@ -550,8 +800,7 @@
class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800"> class="inline-block align-bottom bg-white dark:bg-slate-900 rounded-3xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full animate-luxury-in border border-slate-100 dark:border-slate-800">
<div <div
class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center"> class="px-8 pt-8 pb-6 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ <h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Edit Machine Model') }}</h3>
__('Edit Machine Model') }}</h3>
<button @click="showEditModelModal = false" <button @click="showEditModelModal = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -567,16 +816,14 @@
<div class="px-8 py-8 space-y-6"> <div class="px-8 py-8 space-y-6">
<div> <div>
<label <label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Model Name') }}</label>
__('Model Name') }}</label>
<input type="text" name="name" x-model="currentModel.name" required <input type="text" name="name" x-model="currentModel.name" required
class="luxury-input w-full" placeholder="{{ __('Enter model name') }}"> class="luxury-input w-full" placeholder="{{ __('Enter model name') }}">
</div> </div>
</div> </div>
<div <div
class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800"> class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showEditModelModal = false" class="btn-luxury-ghost">{{ <button type="button" @click="showEditModelModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
__('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button> <button type="submit" class="btn-luxury-primary px-8">{{ __('Save') }}</button>
</div> </div>
</form> </form>
@@ -686,8 +933,7 @@
</div> </div>
<p <p
class="text-xs font-bold text-amber-700 dark:text-amber-300 leading-relaxed text-left flex-1"> class="text-xs font-bold text-amber-700 dark:text-amber-300 leading-relaxed text-left flex-1">
{{ __('Optimized for display. Supported formats: JPG, PNG, WebP.') {{ __('PNG, JPG, WEBP up to 10MB') }}
}}
</p> </p>
</div> </div>
</div> </div>
@@ -739,7 +985,6 @@
</div> </div>
<!-- Helper text --> <!-- Helper text -->
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 text-white/40 text-[10px] font-bold uppercase tracking-[0.3em] pointer-events-none">
{{ __('Click anywhere to close') }} {{ __('Click anywhere to close') }}
</div> </div>
</div> </div>
@@ -772,7 +1017,7 @@
<div class="p-10 flex flex-col items-center gap-6"> <div class="p-10 flex flex-col items-center gap-6">
<div class="p-4 bg-white rounded-3xl shadow-xl border border-slate-100"> <div class="p-4 bg-white rounded-3xl shadow-xl border border-slate-100">
<img :src="'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(maintenanceQrUrl)" <img :src="'{{ route('admin.basic-settings.qr-code') }}?data=' + encodeURIComponent(maintenanceQrUrl)"
class="w-48 h-48" class="w-48 h-48"
alt="{{ __('Maintenance QR Code') }}"> alt="{{ __('Maintenance QR Code') }}">
</div> </div>
@@ -812,10 +1057,10 @@
<div <div
class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800"> class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
<div <div
class="px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between"> class="px-6 py-3 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-black text-slate-800 dark:text-white">{{ __('Parameters') }}</h2> <h2 class="text-xl font-black text-slate-800 dark:text-white">{{ __('Parameters') }}</h2>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mt-1" <p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em] mt-1"
x-text="currentMachine?.name"></p> x-text="currentMachine?.name"></p>
</div> </div>
<button @click="showDetailDrawer = false" <button @click="showDetailDrawer = false"
@@ -826,10 +1071,10 @@
</svg> </svg>
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 custom-scrollbar"> <div class="flex-1 overflow-y-auto px-6 pt-1 pb-6 space-y-6 custom-scrollbar">
<template x-if="currentMachine?.image_urls && currentMachine.image_urls.length > 0"> <template x-if="currentMachine?.image_urls && currentMachine.image_urls.length > 0">
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-[11px] font-black text-indigo-500 uppercase tracking-[0.3em]">{{ <h3 class="text-xs font-black text-indigo-500 uppercase tracking-[0.3em]">{{
__('Machine Images') }}</h3> __('Machine Images') }}</h3>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<template x-for="(url, index) in currentMachine.image_urls" :key="index"> <template x-for="(url, index) in currentMachine.image_urls" :key="index">
@@ -848,28 +1093,27 @@
</section> </section>
</template> </template>
<section class="space-y-6"> <section class="space-y-6">
<h3 class="text-[11px] font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware <h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Hardware & Network') }}</h3>
& Network') }}</h3>
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<div <div
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80"> class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<span <span
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{
__('Serial & Version') }}</span> __('Serial & Version') }}</span>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-xs font-mono font-bold text-slate-700 dark:text-slate-300" <div class="text-sm font-mono font-bold text-slate-700 dark:text-slate-300"
x-text="currentMachine?.serial_no"></div> x-text="currentMachine?.serial_no"></div>
<span <span
class="px-2 py-0.5 rounded-md bg-white dark:bg-slate-900 text-[9px] font-black text-slate-500 border border-slate-100 dark:border-slate-800" class="px-2 py-0.5 rounded-md bg-white dark:bg-slate-900 text-[10px] font-black text-slate-500 border border-slate-100 dark:border-slate-800"
x-text="'v' + (currentMachine?.firmware_version || '1.0')"></span> x-text="'v' + (currentMachine?.firmware_version || '1.0')"></span>
</div> </div>
</div> </div>
<div <div
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80"> class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<span <span
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{
__('Heartbeat') }}</span> __('Heartbeat') }}</span>
<div class="text-xs font-bold text-slate-700 dark:text-slate-300" <div class="text-sm font-bold text-slate-700 dark:text-slate-300"
x-text="currentMachine?.last_heartbeat_at ? new Date(currentMachine.last_heartbeat_at).toLocaleString() : '--'"> x-text="currentMachine?.last_heartbeat_at ? new Date(currentMachine.last_heartbeat_at).toLocaleString() : '--'">
</div> </div>
</div> </div>
@@ -878,37 +1122,64 @@
<!-- Operational Settings --> <!-- Operational Settings -->
<section class="space-y-6"> <section class="space-y-6">
<h3 class="text-[11px] font-black text-amber-500 uppercase tracking-[0.3em]">{{ <h3 class="text-xs font-black text-amber-500 uppercase tracking-[0.3em]">{{
__('Operations') }}</h3> __('Operations') }}</h3>
<div class="space-y-4"> <div class="space-y-4">
<div <div
class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5"> class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5">
<span class="text-xs font-bold text-slate-500">{{ __('Heating Range') }}</span> <span class="text-sm font-bold text-slate-500">{{ __('Heating Range') }}</span>
<span class="text-xs font-black text-slate-700 dark:text-slate-300" <span class="text-sm font-black text-slate-700 dark:text-slate-300"
x-text="(currentMachine?.heating_start_time ? currentMachine.heating_start_time.substring(0, 5) : '00:00') + ' ~ ' + (currentMachine?.heating_end_time ? currentMachine.heating_end_time.substring(0, 5) : '00:00')"></span> x-text="(currentMachine?.heating_start_time ? currentMachine.heating_start_time.substring(0, 5) : '00:00') + ' ~ ' + (currentMachine?.heating_end_time ? currentMachine.heating_end_time.substring(0, 5) : '00:00')"></span>
</div> </div>
<div <div
class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5"> class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5">
<span class="text-xs font-bold text-slate-500">{{ __('Card Reader No') }}</span> <span class="text-sm font-bold text-slate-500">{{ __('Card Reader No') }}</span>
<span class="text-xs font-black text-slate-700 dark:text-slate-300" <span class="text-sm font-black text-slate-700 dark:text-slate-300"
x-text="currentMachine?.card_reader_no || '--'"></span> x-text="currentMachine?.card_reader_no || '--'"></span>
</div> </div>
<div <div class="flex flex-col gap-3 p-3 mt-1 bg-slate-50 dark:bg-slate-800/40 rounded-xl border border-slate-100 dark:border-slate-700/50 relative">
class="flex items-center justify-between p-2 border-b border-slate-50 dark:border-white/5"> <div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-500">{{ __('API Token') }}</span> <span class="text-xs font-black text-slate-500 uppercase tracking-widest">{{ __('API Token') }}</span>
<span class="text-[10px] font-mono text-slate-400 truncate max-w-[150px]" <div class="flex items-center gap-1">
x-text="currentMachine?.api_token || '--'"></span> <template x-if="currentMachine?.api_token">
<div class="flex items-center gap-1">
<button @click="showApiToken = !showApiToken"
class="p-1.5 rounded-lg text-slate-400 hover:text-cyan-500 hover:bg-cyan-50 dark:hover:bg-cyan-900/40 transition-all font-bold"
:title="showApiToken ? '{{ __('Hide') }}' : '{{ __('Show') }}'">
<svg x-show="!showApiToken" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
<svg x-show="showApiToken" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/></svg>
</button>
<button @click="copyToken(currentMachine)"
class="p-1.5 rounded-lg text-slate-400 hover:text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 transition-all font-bold"
title="{{ __('Copy') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</template>
<button @click="regenerateToken()" :disabled="loadingRegenerate"
class="ml-2 px-2.5 py-1.5 rounded-lg bg-rose-50 dark:bg-rose-500/10 text-rose-500 hover:bg-rose-100 dark:hover:bg-rose-500/20 text-xs font-black uppercase tracking-widest transition-all disabled:opacity-50 flex items-center gap-1.5 border border-rose-100 dark:border-rose-500/20"
title="{{ __('Regenerate') }}">
<svg x-show="loadingRegenerate" class="animate-spin w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<svg x-show="!loadingRegenerate" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span>{{ __('Regenerate') }}</span>
</button>
</div>
</div>
<div class="bg-white dark:bg-slate-900/50 rounded-lg border border-slate-200 dark:border-slate-700/50 p-2.5 overflow-x-auto custom-scrollbar">
<span class="text-sm font-mono font-bold tracking-[0.1em] text-cyan-600 dark:text-cyan-400 select-all block whitespace-nowrap min-w-full"
x-text="currentMachine?.api_token ? (showApiToken ? currentMachine.api_token : '•'.repeat(16)) : '{{ __('None') }}'"></span>
</div>
</div> </div>
</div> </div>
</section> </section>
<!-- Location --> <!-- Location -->
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-[11px] font-black text-emerald-500 uppercase tracking-[0.3em]">{{ <h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{
__('Location') }}</h3> __('Location') }}</h3>
<div <div
class="p-4 bg-emerald-50/30 dark:bg-emerald-500/5 rounded-2xl border border-emerald-100/50 dark:border-emerald-500/10"> class="p-4 bg-emerald-50/30 dark:bg-emerald-500/5 rounded-2xl border border-emerald-100/50 dark:border-emerald-500/10">
<p class="text-xs text-emerald-700 dark:text-emerald-400 leading-relaxed font-bold" <p class="text-sm text-emerald-700 dark:text-emerald-400 leading-relaxed font-bold"
x-text="currentMachine?.location || '{{ __('No location set') }}'"></p> x-text="currentMachine?.location || '{{ __('No location set') }}'"></p>
</div> </div>
</section> </section>
@@ -926,6 +1197,175 @@
<!-- Global Delete Confirm Modal --> <!-- Global Delete Confirm Modal -->
<x-delete-confirm-modal /> <x-delete-confirm-modal />
<x-confirm-modal
alpine-var="isRegenerateConfirmOpen"
confirm-action="isRegenerateConfirmOpen = false; window.dispatchEvent(new CustomEvent('execute-regenerate', { detail: window.activeMachineSerial || window.activeMachineId }))"
icon-type="warning"
confirm-color="sky"
:title="__('Are you sure?')"
:message="__('Regenerating the token will disconnect the physical machine until it is updated. Continue?')"
:confirm-text="__('Yes, regenerate')"
/>
<!-- Machine Permissions Modal -->
<template x-teleport='body'>
<div x-show='showPermissionModal' class='fixed inset-0 z-[160] overflow-y-auto' x-cloak>
<div class='flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0'>
<div x-show='showPermissionModal' @click='showPermissionModal = false'
x-transition:enter='ease-out duration-300' x-transition:enter-start='opacity-0'
x-transition:enter-end='opacity-100' x-transition:leave='ease-in duration-200'
x-transition:leave-start='opacity-100' x-transition:leave-end='opacity-0'
class='fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity'></div>
<span class='hidden sm:inline-block sm:align-middle sm:h-screen'>&#8203;</span>
<div x-show='showPermissionModal' x-transition:enter='ease-out duration-300'
x-transition:enter-start='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
x-transition:enter-end='opacity-100 translate-y-0 sm:scale-100'
x-transition:leave='ease-in duration-200'
x-transition:leave-start='opacity-100 translate-y-0 sm:scale-100'
x-transition:leave-end='opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
class='inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full overflow-hidden animate-luxury-in'>
<div class='flex justify-between items-center mb-8'>
<div>
<h3 class='text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight'>
{{ __('Authorized Machines Management') }}</h3>
<div class='flex items-center gap-2 mt-1 drop-shadow-sm'>
<span class='text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]'>{{
__('Account') }}:</span>
<span class='text-xs font-bold text-cyan-500 uppercase tracking-widest'
x-text='targetUserName'></span>
</div>
</div>
<button @click='showPermissionModal = false'
class='text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors bg-slate-50 dark:bg-slate-800 p-2 rounded-xl'>
<svg class='size-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2.5'
d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div class='relative min-h-[400px]'>
<div class='mb-6 flex flex-col md:flex-row gap-4 items-center'>
<div class='flex-1 relative group w-full text-left'>
<span class='absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10'>
<svg class='size-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors'
viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5'
stroke-linecap='round' stroke-linejoin='round'>
<circle cx='11' cy='11' r='8'></circle>
<line x1='21' y1='21' x2='16.65' y2='16.65'></line>
</svg>
</span>
<input type='text' x-model='permissionSearchQuery'
placeholder='{{ __("Search machines...") }}'
class='luxury-input py-3 pl-12 pr-6 block w-full text-sm font-extrabold' @click.stop>
</div>
<button @click="toggleSelectAll()"
class="shrink-0 flex items-center gap-2 px-6 py-3 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200 dark:border-slate-700 font-black text-xs uppercase tracking-widest shadow-sm">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span
x-text="allMachines.filter(m => !permissionSearchQuery || m.name.toLowerCase().includes(permissionSearchQuery.toLowerCase()) || m.serial_no.toLowerCase().includes(permissionSearchQuery.toLowerCase())).every(m => permissions[m.id]) ? '{{ __('Deselect All') }}' : '{{ __('Select All') }}'"></span>
</button>
</div>
<template x-if='isPermissionsLoading'>
<div
class='absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm z-[170] rounded-2xl'>
<div class='flex flex-col items-center gap-3'>
<div
class='w-10 h-10 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin'>
</div>
<span class='text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] animate-pulse'>{{ __('Syncing Permissions...') }}</span>
</div>
</div>
</template>
<div
class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-[450px] overflow-y-auto pr-2 custom-scrollbar p-1'>
<template
x-for='machine in allMachines.filter(m => !permissionSearchQuery || m.name.toLowerCase().includes(permissionSearchQuery.toLowerCase()) || m.serial_no.toLowerCase().includes(permissionSearchQuery.toLowerCase()))'
:key='machine.id'>
<div @click='togglePermission(machine.id)'
:class='permissions[machine.id] ? "border-cyan-500 bg-cyan-500/5 dark:bg-cyan-500/10 ring-1 ring-cyan-500/20 shadow-md shadow-cyan-500/10" : "border-slate-100 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-600 shadow-sm"'
class='p-4 rounded-2xl border-2 cursor-pointer transition-all duration-300 group relative overflow-hidden'>
<div class='flex flex-col relative z-10 text-left'>
<div class='flex items-center gap-2'>
<div class='size-2 rounded-full'
:class='permissions[machine.id] ? "bg-cyan-500 animate-pulse" : "bg-slate-300 dark:bg-slate-700"'>
</div>
<span class='text-sm font-extrabold truncate drop-shadow-sm'
:class='permissions[machine.id] ? "text-cyan-600 dark:text-cyan-400" : "text-slate-700 dark:text-slate-300"'
x-text='machine.name'></span>
</div>
<span
class='text-[10px] font-mono font-bold text-slate-400 mt-2 tracking-widest uppercase opacity-70'
x-text='machine.serial_no'></span>
</div>
<div
class='absolute -right-2 -bottom-2 opacity-[0.03] text-slate-900 dark:text-white pointer-events-none group-hover:scale-110 transition-transform duration-700'>
<svg class='size-20' fill='currentColor' viewBox='0 0 24 24'>
<path
d='M5 2h14c1.1 0 2 .9 2 2v16c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2zm0 2v16h14V4H5zm3 3h8v6H8V7zm0 8h3v2H8v-2zm5 0h3v2h-3v-2z' />
</svg>
</div>
<div class='absolute top-4 right-4 animate-luxury-in'
x-show='permissions[machine.id]'>
<div
class='size-5 rounded-full bg-cyan-500 flex items-center justify-center shadow-lg shadow-cyan-500/30'>
<svg class='size-3 text-white' fill='none' stroke='currentColor'
viewBox='0 0 24 24'>
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='3'
d='M5 13l4 4L19 7' />
</svg>
</div>
</div>
</div>
</template>
</div>
</div>
<div
class='flex flex-col sm:flex-row justify-between items-center mt-10 pt-8 border-t border-slate-100 dark:border-slate-800 gap-6'>
<div class='flex items-center gap-3'>
<div class='flex -space-x-2'>
<template x-for='i in Math.min(3, Object.values(permissions).filter(v => v).length)'
:key='i'>
<div
class='size-6 rounded-full border-2 border-white dark:border-slate-900 bg-cyan-500 flex items-center justify-center shadow-sm'>
<svg class='size-3 text-white' fill='currentColor' viewBox='0 0 24 24'>
<path
d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14.5v-9l6 4.5-6 4.5z' />
</svg>
</div>
</template>
</div>
<p class='text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]'>
{{ __('Selection') }}: <span class='text-cyan-500 text-xs font-extrabold'
x-text='Object.values(permissions).filter(v => v).length'></span> / <span
class="font-extrabold" x-text='allMachines?.length || 0'></span> {{ __('Devices') }}
</p>
</div>
<div class='flex gap-4 w-full sm:w-auto'>
<button @click='showPermissionModal = false'
class='flex-1 sm:flex-none btn-luxury-ghost px-8'>{{ __('Cancel') }}</button>
<button @click='savePermissions()' class='flex-1 sm:flex-none btn-luxury-primary px-12 transition-all duration-300 shadow-lg shadow-cyan-500/20'
:disabled='isPermissionsLoading'>
<span>{{ __('Update Authorization') }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div> </div>
@endsection @endsection

View File

@@ -2,32 +2,125 @@
@section('content') @section('content')
<div class="space-y-6" x-data="{ <div class="space-y-6" x-data="{
showModal: false, showModal: false,
showHistoryModal: false,
editing: false, editing: false,
sidebarView: 'detail',
currentCompany: { currentCompany: {
id: '', id: '',
name: '', name: '',
code: '', code: '',
original_type: 'lease',
current_type: 'lease',
tax_id: '', tax_id: '',
contact_name: '', contact_name: '',
contact_phone: '', contact_phone: '',
contact_email: '', contact_email: '',
valid_until: '', start_date: '',
end_date: '',
warranty_start_date: '',
warranty_end_date: '',
software_start_date: '',
software_end_date: '',
status: 1, status: 1,
note: '' note: '',
settings: {
enable_material_code: false,
enable_points: false
}
}, },
openCreateModal() { openCreateModal() {
this.editing = false; this.editing = false;
this.currentCompany = { id: '', name: '', code: '', tax_id: '', contact_name: '', contact_phone: '', contact_email: '', valid_until: '', status: 1, note: '' }; this.currentCompany = {
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
tax_id: '', contact_name: '', contact_phone: '',
contact_email: '', start_date: '', end_date: '',
warranty_start_date: '', warranty_end_date: '',
software_start_date: '', software_end_date: '',
status: 1, note: '',
settings: { enable_material_code: false, enable_points: false }
};
this.showModal = true; this.showModal = true;
}, },
openEditModal(company) { openEditModal(company) {
this.editing = true; this.editing = true;
this.currentCompany = { ...company }; this.currentCompany = {
...company,
start_date: company.start_date ? company.start_date.substring(0, 10) : '',
end_date: company.end_date ? company.end_date.substring(0, 10) : '',
warranty_start_date: company.warranty_start_date ? company.warranty_start_date.substring(0, 10) : '',
warranty_end_date: company.warranty_end_date ? company.warranty_end_date.substring(0, 10) : '',
software_start_date: company.software_start_date ? company.software_start_date.substring(0, 10) : '',
software_end_date: company.software_end_date ? company.software_end_date.substring(0, 10) : '',
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.originalStatus = company.status;
this.showModal = true; this.showModal = true;
}, },
isDeleteConfirmOpen: false, isDeleteConfirmOpen: false,
deleteFormAction: '' deleteFormAction: '',
isStatusConfirmOpen: false,
originalStatus: 1,
toggleFormAction: '',
statusToggleSource: 'edit',
showDetail: false,
detailCompany: {
id: '', name: '', code: '', original_type: 'lease', current_type: 'lease',
tax_id: '', contact_name: '', contact_phone: '', contact_email: '',
start_date: '', end_date: '',
warranty_start_date: '', warranty_end_date: '',
software_start_date: '', software_end_date: '',
status: 1, note: '',
settings: { enable_material_code: false, enable_points: false },
users_count: 0, machines_count: 0,
contracts: []
},
openDetailSidebar(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.sidebarView = 'detail';
this.showDetail = true;
},
openHistorySidebar(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.sidebarView = 'history';
this.showDetail = true;
},
openFullHistory() {
this.showHistoryModal = true;
},
openHistory(company) {
this.detailCompany = {
...company,
settings: {
enable_material_code: company.settings?.enable_material_code || false,
enable_points: company.settings?.enable_points || false
}
};
this.showHistoryModal = true;
},
submitConfirmedForm() {
if (this.statusToggleSource === 'list') {
this.$refs.statusToggleForm.submit();
} else {
this.$refs.companyForm.submit();
}
},
}"> }">
<!-- Header --> <!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
@@ -103,6 +196,9 @@
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
{{ __('Customer Info') }}</th> {{ __('Customer Info') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Business Type') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Status') }}</th> {{ __('Status') }}</th>
@@ -111,7 +207,7 @@
{{ __('Accounts / Machines') }}</th> {{ __('Accounts / Machines') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Valid Until') }}</th> {{ __('Service Terms') }}</th>
<th <th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right"> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Actions') }}</th> {{ __('Actions') }}</th>
@@ -131,9 +227,11 @@
</svg> </svg>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span <div class="flex items-center gap-2">
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">{{ <span
$company->name }}</span> class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{
$company->name }}</span>
</div>
<span <span
class="text-xs font-mono font-bold text-slate-500 dark:text-slate-400 mt-0.5 tracking-widest uppercase">{{ class="text-xs font-mono font-bold text-slate-500 dark:text-slate-400 mt-0.5 tracking-widest uppercase">{{
$company->code }}</span> $company->code }}</span>
@@ -141,14 +239,28 @@
</div> </div>
</td> </td>
<td class="px-6 py-6 text-center"> <td class="px-6 py-6 text-center">
<div class="flex flex-col items-center gap-2">
<div class="flex items-center gap-2 min-w-[100px] justify-center">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest w-8 text-right">{{ __('Original:') }}</span>
<span class="px-2 py-0.5 rounded text-xs font-bold {{ $company->original_type === 'buyout' ? 'bg-amber-500/10 text-amber-600' : 'bg-blue-500/10 text-blue-600' }} uppercase tracking-wider">
{{ __($company->original_type === 'buyout' ? 'Buyout' : 'Lease') }}
</span>
</div>
<div class="flex items-center gap-2 min-w-[100px] justify-center">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest w-8 text-right">{{ __('Current:') }}</span>
<span class="px-2 py-0.5 rounded text-xs font-bold {{ $company->current_type === 'buyout' ? 'bg-amber-500/10 text-amber-600 border border-amber-500/20' : 'bg-blue-500/10 text-blue-600 border border-blue-500/20' }} uppercase tracking-wider">
{{ __($company->current_type === 'buyout' ? 'Buyout' : 'Lease') }}
</span>
</div>
</div>
</td>
<td class="px-6 py-6 text-center py-6">
@if($company->status) @if($company->status)
<span <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">
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') }} {{ __('Active') }}
</span> </span>
@else @else
<span <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">
class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700 tracking-widest uppercase">
{{ __('Disabled') }} {{ __('Disabled') }}
</span> </span>
@endif @endif
@@ -166,15 +278,77 @@
</div> </div>
</div> </div>
</td> </td>
<td class="px-6 py-6 text-center"> <td class="px-6 py-6 border-b border-slate-50 dark:border-slate-800/50">
<span <div class="flex flex-col gap-2.5 min-w-[200px] mx-auto w-fit">
class="text-[13px] font-bold font-display tracking-widest {{ $company->valid_until && $company->valid_until->isPast() ? 'text-rose-500' : 'text-slate-600 dark:text-slate-300' }}"> <!-- Contract Period (Only for Lease) -->
{{ $company->valid_until ? $company->valid_until->format('Y/m/d') : __('Permanent') }} @if($company->current_type === 'lease')
</span> <div class="flex items-center gap-3 group/term">
<span class="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-600 text-[10px] font-black border border-blue-500/20 group-hover/term:bg-blue-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Contract') }}
</span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->start_date ? $company->start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->end_date && $company->end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-600 dark:text-slate-300' }}">
{{ $company->end_date ? $company->end_date->format('Y-m-d') : __('Permanent') }}
</span>
</div>
</div>
@endif
@if($company->current_type === 'buyout')
<!-- Warranty Period -->
<div class="flex items-center gap-3 group/term">
<span class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-600 text-[10px] font-black border border-amber-500/20 group-hover/term:bg-amber-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Warranty') }}
</span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->warranty_start_date ? $company->warranty_start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->warranty_end_date && $company->warranty_end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-500 dark:text-slate-400' }}">
{{ $company->warranty_end_date ? $company->warranty_end_date->format('Y-m-d') : '--' }}
</span>
</div>
</div>
<!-- Software Period -->
<div class="flex items-center gap-3 group/term">
<span class="px-2 py-0.5 rounded-md bg-purple-500/10 text-purple-600 text-[10px] font-black border border-purple-500/20 group-hover/term:bg-purple-500 group-hover/term:text-white transition-all tracking-wider whitespace-nowrap min-w-[42px] text-center">
{{ __('Software') }}
</span>
<div class="flex items-center gap-1.5 font-mono text-[11px] font-bold">
<span class="text-slate-400">{{ $company->software_start_date ? $company->software_start_date->format('Y-m-d') : '--' }}</span>
<span class="text-slate-300">~</span>
<span class="{{ $company->software_end_date && $company->software_end_date->isPast() ? 'text-rose-500 shadow-sm shadow-rose-500/10' : 'text-slate-500 dark:text-slate-400' }}">
{{ $company->software_end_date ? $company->software_end_date->format('Y-m-d') : '--' }}
</span>
</div>
</div>
@endif
</div>
</td> </td>
<td class="px-6 py-6 text-right"> <td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-x-2"> <div class="flex items-center justify-end gap-x-2">
<button @click="openEditModal({{ json_encode($company) }})" @if($company->status)
<button type="button"
@click="toggleFormAction = '{{ route('admin.permission.companies.status.toggle', $company->id) }}'; statusToggleSource = 'list'; isStatusConfirmOpen = true"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 transition-all border border-transparent hover:border-amber-500/20"
title="{{ __('Disable') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
</button>
@else
<button type="button"
@click="toggleFormAction = '{{ route('admin.permission.companies.status.toggle', $company->id) }}'; $nextTick(() => $refs.statusToggleForm.submit())"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20"
title="{{ __('Enable') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 0 1 0 1.971l-11.54 6.347c-.75.412-1.667-.13-1.667-.986V5.653z" />
</svg>
</button>
@endif
<button @click="statusToggleSource = 'edit'; openEditModal({{ json_encode($company) }})"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20"> class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" <svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5"> stroke-width="2.5">
@@ -191,6 +365,14 @@
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg> </svg>
</button> </button>
<button type="button" @click="openDetailSidebar({{ json_encode($company) }})"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-indigo-500 hover:bg-indigo-500/5 transition-all border border-transparent hover:border-indigo-500/20"
title="{{ __('View Details') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -251,8 +433,16 @@
</div> </div>
<form <form
x-ref="companyForm"
:action="editing ? '{{ url('admin/permission/companies') }}/' + currentCompany.id : '{{ route('admin.permission.companies.store') }}'" :action="editing ? '{{ url('admin/permission/companies') }}/' + currentCompany.id : '{{ route('admin.permission.companies.store') }}'"
method="POST" class="space-y-6"> method="POST" class="space-y-6"
@submit.prevent="
if (editing && currentCompany.status == '0' && originalStatus == '1') {
isStatusConfirmOpen = true;
} else {
$el.submit();
}
">
@csrf @csrf
<input type="hidden" name="_method" :value="editing ? 'PUT' : 'POST'"> <input type="hidden" name="_method" :value="editing ? 'PUT' : 'POST'">
@@ -264,16 +454,63 @@
__('Company Information') }}</h4> __('Company Information') }}</h4>
</div> </div>
<!-- Business Type Selector -->
<div class="p-4 rounded-2xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 space-y-4">
<label class="text-[11px] font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Business Type') }}
<span class="text-rose-500 ml-0.5">*</span>
</label>
<!-- 新增模式:顯示切換按鈕 -->
<div x-show="!editing" class="flex p-1.5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 w-fit">
<button type="button" @click="currentCompany.original_type = 'lease'"
:class="currentCompany.original_type === 'lease' ? 'bg-blue-500 text-white shadow-lg shadow-blue-500/20' : 'text-slate-400 hover:text-slate-600'"
class="px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
{{ __('Lease') }}
</button>
<button type="button" @click="currentCompany.original_type = 'buyout'"
:class="currentCompany.original_type === 'buyout' ? 'bg-amber-500 text-white shadow-lg shadow-amber-500/20' : 'text-slate-400 hover:text-slate-600'"
class="px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
{{ __('Buyout') }}
</button>
<input type="hidden" name="original_type" :value="currentCompany.original_type">
</div>
<!-- 編輯模式:顯示原始類型固定值 + 當前類型切換 -->
<div x-show="editing" class="flex flex-col gap-5">
<div class="flex items-center gap-4">
<span class="text-[11px] font-black text-slate-400 uppercase tracking-widest">{{ __('Original Type') }}: <span class="text-rose-500 ml-0.5">*</span></span>
<span class="px-4 py-1.5 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs font-bold uppercase tracking-widest text-slate-600 dark:text-slate-400" x-text="currentCompany.original_type === 'buyout' ? '{{ __('Buyout') }}' : '{{ __('Lease') }}'"></span>
</div>
<div class="flex items-center gap-4">
<span class="text-[11px] font-black text-slate-400 uppercase tracking-widest">{{ __('Current Type') }}: <span class="text-rose-500 ml-0.5">*</span></span>
<div class="flex p-1.5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 w-fit">
<button type="button" @click="currentCompany.current_type = 'lease'"
:class="currentCompany.current_type === 'lease' ? 'bg-blue-500 text-white shadow-lg shadow-blue-500/20' : 'text-slate-400 hover:text-slate-600'"
class="px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
{{ __('Lease') }}
</button>
<button type="button" @click="currentCompany.current_type = 'buyout'"
:class="currentCompany.current_type === 'buyout' ? 'bg-amber-500 text-white shadow-lg shadow-amber-500/20' : 'text-slate-400 hover:text-slate-600'"
class="px-4 py-1.5 rounded-lg text-xs font-bold uppercase tracking-widest transition-all">
{{ __('Buyout') }}
</button>
<input type="hidden" name="current_type" :value="currentCompany.current_type">
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Company Name') }}</label> __('Company Name') }} <span class="text-rose-500 ml-0.5">*</span></label>
<input type="text" name="name" x-model="currentCompany.name" required <input type="text" name="name" x-model="currentCompany.name" required
class="luxury-input w-full" placeholder="{{ __('e.g. Taiwan Star') }}"> class="luxury-input w-full" placeholder="{{ __('e.g. Taiwan Star') }}">
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Company Code') }}</label> __('Company Code') }} <span class="text-rose-500 ml-0.5">*</span></label>
<input type="text" name="code" x-model="currentCompany.code" required <input type="text" name="code" x-model="currentCompany.code" required
class="luxury-input w-full" placeholder="{{ __('e.g. TWSTAR') }}"> class="luxury-input w-full" placeholder="{{ __('e.g. TWSTAR') }}">
</div> </div>
@@ -286,13 +523,53 @@
<input type="text" name="tax_id" x-model="currentCompany.tax_id" <input type="text" name="tax_id" x-model="currentCompany.tax_id"
class="luxury-input w-full"> class="luxury-input w-full">
</div> </div>
<div class="space-y-2"> <div class="grid grid-cols-2 gap-3" x-show="currentCompany.current_type === 'lease'">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <div class="space-y-2">
__('Contract Until (Optional)') }}</label> <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
<input type="date" name="valid_until" x-model="currentCompany.valid_until" __('Start Date') }} <span class="text-rose-500 ml-0.5">*</span></label>
class="luxury-input w-full"> <input type="date" name="start_date" x-model="currentCompany.start_date" :required="currentCompany.current_type === 'lease'"
class="luxury-input w-full px-2">
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('End Date') }}</label>
<input type="date" name="end_date" x-model="currentCompany.end_date"
class="luxury-input w-full px-2">
</div>
</div> </div>
</div> </div>
<!-- Buyout Specific Dates -->
<div x-show="currentCompany.current_type === 'buyout'" x-transition class="space-y-4 pt-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-[10px] font-black text-amber-600 uppercase tracking-widest pl-1">{{ __('Warranty Start') }}</label>
<input type="date" name="warranty_start_date" x-model="currentCompany.warranty_start_date"
class="luxury-input w-full px-2 border-amber-100 dark:border-amber-900/30">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-amber-600 uppercase tracking-widest pl-1">{{ __('Warranty End') }}</label>
<input type="date" name="warranty_end_date" x-model="currentCompany.warranty_end_date"
class="luxury-input w-full px-2 border-amber-100 dark:border-amber-900/30">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest pl-1">{{ __('Software Start') }}</label>
<input type="date" name="software_start_date" x-model="currentCompany.software_start_date"
class="luxury-input w-full px-2 border-indigo-100 dark:border-indigo-900/30">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest pl-1">{{ __('Software End') }}</label>
<input type="date" name="software_end_date" x-model="currentCompany.software_end_date"
class="luxury-input w-full px-2 border-indigo-100 dark:border-indigo-900/30">
</div>
</div>
</div>
<!-- Keep hidden start_date for buyout as well if needed by backend -->
<input type="hidden" name="start_date" :value="currentCompany.start_date" x-if="currentCompany.current_type === 'buyout' && !currentCompany.start_date">
</div>
</div> </div>
<!-- Admin Account Section (Account Creation) - Only show when creating --> <!-- Admin Account Section (Account Creation) - Only show when creating -->
@@ -315,8 +592,20 @@
<div class="space-y-2"> <div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Password') }}</label> __('Password') }}</label>
<input type="password" name="admin_password" class="luxury-input w-full" <div x-data="{ show: false }" class="relative items-center">
placeholder="{{ __('Min 8 characters') }}"> <input :type="show ? 'text' : 'password'" name="admin_password" class="luxury-input w-full pr-12"
placeholder="{{ __('Min 8 characters') }}">
<button type="button" @click="show = !show"
class="absolute inset-y-0 end-0 flex items-center z-20 px-4 cursor-pointer text-slate-400 hover:text-cyan-500 transition-colors">
<svg x-show="!show" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<svg x-show="show" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
</button>
</div>
</div> </div>
</div> </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">
@@ -341,7 +630,7 @@
</div> </div>
<!-- Contact Section --> <!-- Contact Section -->
<div class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800 relative z-10"> <div class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800 relative z-20">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="h-6 w-1 bg-amber-500 rounded-full"></div> <div class="h-6 w-1 bg-amber-500 rounded-full"></div>
<h4 class="text-xs font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ <h4 class="text-xs font-black text-slate-800 dark:text-white uppercase tracking-widest">{{
@@ -368,17 +657,10 @@
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ <label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Status') }}</label> __('Status') }}</label>
<x-searchable-select <x-searchable-select
id="company-status"
name="status" name="status"
x-model="currentCompany.status" x-model="currentCompany.status"
:hasSearch="false" :hasSearch="false"
x-init="$watch('currentCompany.status', (value) => {
$nextTick(() => {
const inst = HSSelect.getInstance($el);
if (inst) {
inst.setValue(String(value));
}
});
})"
> >
<option value="1">{{ __('Active') }}</option> <option value="1">{{ __('Active') }}</option>
<option value="0">{{ __('Disabled') }}</option> <option value="0">{{ __('Disabled') }}</option>
@@ -392,6 +674,43 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Feature Toggles Section -->
<div class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800 relative z-[5]">
<div class="flex items-center gap-3">
<div class="h-6 w-1 bg-cyan-500 rounded-full"></div>
<h4 class="text-xs font-black text-slate-800 dark:text-white uppercase tracking-widest">{{ __('Feature Settings') }}</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Material Code Toggle -->
<div class="flex items-center justify-between p-4 rounded-2xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 transition-all hover:bg-slate-50 dark:hover:bg-slate-800">
<div class="space-y-0.5">
<label class="text-sm font-black text-slate-700 dark:text-slate-200 uppercase tracking-wide">{{ __('Enable Material Code') }}</label>
<p class="text-xs text-slate-400 font-medium">{{ __('Show material code field in products') }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="hidden" name="settings[enable_material_code]" value="0">
<input type="checkbox" name="settings[enable_material_code]" value="1" x-model="currentCompany.settings.enable_material_code" class="peer sr-only">
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500"></div>
</label>
</div>
<!-- Points Toggle -->
<div class="flex items-center justify-between p-4 rounded-2xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 transition-all hover:bg-slate-50 dark:hover:bg-slate-800">
<div class="space-y-0.5">
<label class="text-sm font-black text-slate-700 dark:text-slate-200 uppercase tracking-wide">{{ __('Enable Points') }}</label>
<p class="text-xs text-slate-400 font-medium">{{ __('Show points rules in products') }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="hidden" name="settings[enable_points]" value="0">
<input type="checkbox" name="settings[enable_points]" value="1" x-model="currentCompany.settings.enable_points" class="peer sr-only">
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500"></div>
</label>
</div>
</div>
</div>
<div class="flex justify-end gap-x-4 pt-8"> <div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button> <button type="button" @click="showModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
@@ -405,6 +724,328 @@
</div> </div>
<x-delete-confirm-modal :message="__('Are you sure to delete this customer?')" /> <x-delete-confirm-modal :message="__('Are you sure to delete this customer?')" />
<x-status-confirm-modal :title="__('停用客戶確認')" :message="__('停用此客戶將會連帶停用該客戶下的所有帳號,確定要繼續嗎?')" />
<form x-ref="statusToggleForm" :action="toggleFormAction" method="POST" class="hidden">
@csrf
@method('PATCH')
</form>
<!-- Details Sidebar -->
<template x-teleport="body">
<div x-show="showDetail" class="fixed inset-0 z-[150]" x-cloak>
<!-- Overlay -->
<div x-show="showDetail"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showDetail = false"
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity"></div>
<!-- Sidebar Content -->
<div class="fixed inset-y-0 right-0 max-w-full flex">
<div class="w-screen max-w-md"
x-show="showDetail"
x-transition:enter="transform transition ease-in-out duration-500"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-500"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full">
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
<!-- Header -->
<div class="px-8 py-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-900 sticky top-0 z-10">
<div>
<h2 class="text-xl font-black text-slate-800 dark:text-white tracking-tight" x-text="sidebarView === 'history' ? '{{ __('Contract History Detail') }}' : '{{ __('Customer Details') }}'"></h2>
<div class="flex items-center gap-2 mt-1">
<p class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]" x-text="detailCompany.name"></p>
<span class="text-xs font-mono font-black text-cyan-500 px-1.5 py-0.5 bg-cyan-500/10 rounded" x-text="detailCompany.code"></span>
</div>
</div>
<button @click="showDetail = false" class="p-2 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-slate-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-8 pt-4 pb-8 space-y-5 custom-scrollbar">
<!-- Tab Switcher -->
<div class="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800/60 rounded-xl">
<button @click="sidebarView = 'detail'"
:class="sidebarView === 'detail' ? 'bg-white dark:bg-slate-700 text-slate-800 dark:text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'"
class="flex-1 py-2.5 px-3 text-xs font-black uppercase tracking-[0.15em] rounded-lg transition-all">{{ __('Customer Details') }}</button>
<button @click="sidebarView = 'history'"
:class="sidebarView === 'history' ? 'bg-white dark:bg-slate-700 text-slate-800 dark:text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'"
class="flex-1 py-2.5 px-3 text-xs font-black uppercase tracking-[0.15em] rounded-lg transition-all">{{ __('Contract History Detail') }}</button>
</div>
<!-- Detail View -->
<div x-show="sidebarView === 'detail'" class="space-y-8">
<!-- Validity & Status Section -->
<section class="space-y-4">
<h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Account Status') }}</h3>
<div class="grid grid-cols-1 gap-4">
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 flex items-center justify-between">
<div>
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Current Status') }}</span>
<template x-if="detailCompany.status">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]"></span>
<span class="text-sm font-black text-emerald-500 uppercase tracking-widest">{{ __('Active') }}</span>
</div>
</template>
<template x-if="!detailCompany.status">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]"></span>
<span class="text-sm font-black text-rose-500 uppercase tracking-widest">{{ __('Disabled') }}</span>
</div>
</template>
</div>
</div>
<!-- Business Type -->
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 space-y-4">
<div class="flex justify-between items-start pb-3 border-b border-slate-100/50 dark:border-slate-800/50">
<div>
<h4 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.2em]">{{ __('Contract Model') }}</h4>
</div>
<span :class="detailCompany.current_type === 'lease' ? 'text-blue-500 bg-blue-500/10' : 'text-amber-500 bg-amber-500/10'"
class="text-[10px] font-black uppercase tracking-wider px-2.5 py-1 rounded-lg">
<span x-text="detailCompany.current_type === 'lease' ? '{{ __('Lease') }}' : '{{ __('Buyout') }}'"></span>
</span>
</div>
<!-- Lease Info Display -->
<template x-if="detailCompany.current_type === 'lease'">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ __('Start Date') }}</p>
<p class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.start_date?.substring(0, 10) || '-'"></p>
</div>
<div class="space-y-1">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">{{ __('End Date') }}</p>
<p class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.end_date?.substring(0, 10) || '{{ __('Unlimited') }}'"></p>
</div>
</div>
</template>
<!-- Buyout Info Display -->
<template x-if="detailCompany.current_type === 'buyout'">
<div class="space-y-4">
<div class="grid grid-cols-1 gap-3">
<div class="p-3 bg-amber-50/30 dark:bg-amber-500/5 rounded-xl border border-amber-100/50 dark:border-amber-500/10">
<p class="text-[9px] font-black text-amber-600 uppercase tracking-widest">{{ __('Warranty Service') }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.warranty_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400">~</span>
<span class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.warranty_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
<div class="p-3 bg-indigo-50/30 dark:bg-indigo-500/5 rounded-xl border border-indigo-100/50 dark:border-indigo-500/10">
<p class="text-[9px] font-black text-indigo-600 uppercase tracking-widest">{{ __('Software Service') }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-black text-slate-700 dark:text-slate-200" x-text="detailCompany.software_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400">~</span>
<span class="text-xs font-black text-slate-800 dark:text-white" x-text="detailCompany.software_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</section>
<!-- Feature Settings Section -->
<section class="space-y-4">
<h3 class="text-xs font-black text-indigo-500 uppercase tracking-[0.3em]">{{ __('Feature Settings') }}</h3>
<div class="space-y-3">
<div class="bg-white dark:bg-slate-800/20 p-4 rounded-2xl border border-slate-100 dark:border-slate-800/50 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-indigo-50 dark:bg-indigo-500/10 text-indigo-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 11h.01M7 15h.01M13 7h.01M13 11h.01M13 15h.01M17 7h.01M17 11h.01M17 15h.01" /></svg>
</div>
<span class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ __('Material Code') }}</span>
</div>
<div class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-black uppercase tracking-widest"
:class="detailCompany.settings?.enable_material_code ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-100 dark:bg-slate-800 text-slate-400'">
<span x-text="detailCompany.settings?.enable_material_code ? '{{ __('Enabled') }}' : '{{ __('Disabled') }}'"></span>
</div>
</div>
<div class="bg-white dark:bg-slate-800/20 p-4 rounded-2xl border border-slate-100 dark:border-slate-800/50 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-50 dark:bg-amber-500/10 text-amber-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<span class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ __('Points Rule') }}</span>
</div>
<div class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-black uppercase tracking-widest"
:class="detailCompany.settings?.enable_points ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-100 dark:bg-slate-800 text-slate-400'">
<span x-text="detailCompany.settings?.enable_points ? '{{ __('Enabled') }}' : '{{ __('Disabled') }}'"></span>
</div>
</div>
</div>
</section>
<!-- Basic Info Section -->
<section class="space-y-4">
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Basic Information') }}</h3>
<div class="grid grid-cols-1 gap-4">
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Tax ID') }}</span>
<div class="text-sm font-mono font-bold text-slate-700 dark:text-slate-300" x-text="detailCompany.tax_id || '--'"></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Contact Name') }}</span>
<div class="text-sm font-bold text-slate-700 dark:text-slate-300" x-text="detailCompany.contact_name || '--'"></div>
</div>
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Phone') }}</span>
<div class="text-sm font-mono font-bold text-slate-700 dark:text-slate-300" x-text="detailCompany.contact_phone || '--'"></div>
</div>
</div>
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Email') }}</span>
<div class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate" x-text="detailCompany.contact_email || '--'"></div>
</div>
</div>
</section>
<!-- Statistics Section -->
<section class="space-y-4">
<h3 class="text-xs font-black text-amber-500 uppercase tracking-[0.3em]">{{ __('Statistics') }}</h3>
<div class="grid grid-cols-2 gap-4">
<div class="bg-amber-50 dark:bg-amber-500/5 p-6 rounded-2xl border border-amber-100 dark:border-amber-500/10 text-center">
<p class="text-2xl font-black text-slate-800 dark:text-white" x-text="detailCompany.users_count || 0"></p>
<p class="text-xs font-black text-slate-400 uppercase tracking-widest mt-1">{{ __('Users') }}</p>
</div>
<div class="bg-cyan-50 dark:bg-cyan-500/5 p-6 rounded-2xl border border-cyan-100 dark:border-cyan-500/10 text-center">
<p class="text-2xl font-black text-slate-800 dark:text-white" x-text="detailCompany.machines_count || 0"></p>
<p class="text-xs font-black text-slate-400 uppercase tracking-widest mt-1">{{ __('Machines') }}</p>
</div>
</div>
</section>
<!-- Notes Section -->
<section class="space-y-4 pb-8" x-show="detailCompany.note">
<h3 class="text-xs font-black text-slate-400 uppercase tracking-[0.3em]">{{ __('Notes') }}</h3>
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<p class="text-sm font-bold text-slate-600 dark:text-slate-400 leading-relaxed italic" x-text="detailCompany.note"></p>
</div>
</section>
</div>
<!-- History View -->
<div x-show="sidebarView === 'history'" class="space-y-6">
<template x-for="(contract, index) in (detailCompany.contracts || [])" :key="contract.id">
<div class="bg-slate-50/50 dark:bg-slate-800/30 rounded-2xl border border-slate-100 dark:border-slate-800/80 overflow-hidden">
<!-- Card Header -->
<div class="px-5 py-3 bg-white dark:bg-slate-800/50 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<div class="flex items-center gap-2.5">
<div class="size-7 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-[9px] font-black text-slate-500" x-text="'#' + (detailCompany.contracts.length - index)"></div>
<div>
<p class="text-[10px] font-black text-slate-700 dark:text-slate-200" x-text="new Date(contract.created_at).toLocaleString()"></p>
<div class="flex items-center gap-1.5 mt-0.5">
<span :class="contract.type === 'lease' ? 'text-blue-500 bg-blue-500/10' : 'text-amber-500 bg-amber-500/10'"
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded"
x-text="contract.type === 'lease' ? '{{ __('Lease') }}' : '{{ __('Buyout') }}'"></span>
<span class="text-[9px] text-slate-400">{{ __('by') }} <span class="text-slate-600 dark:text-slate-300" x-text="contract.creator?.name || 'System'"></span></span>
</div>
</div>
</div>
<template x-if="index === 0">
<span class="px-2 py-0.5 bg-emerald-500 text-white text-[8px] font-black uppercase tracking-widest rounded-full">{{ __('Current') }}</span>
</template>
</div>
<!-- Card Body -->
<div class="p-5 space-y-4">
<!-- Lease dates -->
<template x-if="contract.type === 'lease'">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Contract Start') }}</p>
<p class="text-sm font-black text-slate-700 dark:text-white font-mono" x-text="contract.start_date?.substring(0, 10) || '-'"></p>
</div>
<div class="space-y-1.5">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Contract End') }}</p>
<p class="text-sm font-black text-slate-700 dark:text-white font-mono" x-text="contract.end_date?.substring(0, 10) || '{{ __('Unlimited') }}'"></p>
</div>
</div>
</template>
<!-- Buyout dates -->
<template x-if="contract.type === 'buyout'">
<div class="grid grid-cols-1 gap-4">
<div class="p-4 bg-amber-50/40 dark:bg-amber-500/5 rounded-2xl border border-amber-100/50 dark:border-amber-500/10">
<p class="text-[10px] font-black text-amber-600 uppercase tracking-widest mb-2">{{ __('Warranty Service') }}</p>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono" x-text="contract.warranty_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400 font-bold">~</span>
<span class="text-sm font-black text-slate-800 dark:text-white font-mono" x-text="contract.warranty_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
<div class="p-4 bg-indigo-50/40 dark:bg-indigo-500/5 rounded-2xl border border-indigo-100/50 dark:border-indigo-500/10">
<p class="text-[10px] font-black text-indigo-600 uppercase tracking-widest mb-2">{{ __('Software Service') }}</p>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono" x-text="contract.software_start_date?.substring(0, 10) || '-'"></span>
<span class="text-slate-400 font-bold">~</span>
<span class="text-sm font-black text-slate-800 dark:text-white font-mono" x-text="contract.software_end_date?.substring(0, 10) || '-'"></span>
</div>
</div>
</div>
</template>
<!-- Note -->
<div class="pt-2 border-t border-slate-100/50 dark:border-slate-800/50" x-show="contract.note">
<p class="text-[9px] font-bold text-slate-400 italic" x-text="contract.note"></p>
</div>
</div>
</div>
</template>
<div x-show="!(detailCompany.contracts || []).length" class="py-12 text-center">
<div class="size-14 rounded-2xl bg-slate-50 dark:bg-slate-800/50 flex items-center justify-center mx-auto mb-3 border border-slate-100 dark:border-slate-800">
<svg class="size-7 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18c-2.305 0-4.408.867-6 2.292m0-14.25v14.25" /></svg>
</div>
<p class="text-xs font-black text-slate-400 uppercase tracking-[0.2em]">{{ __('No history records') }}</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-8 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<button @click="showDetail = false" class="w-full btn-luxury-ghost py-4 rounded-xl font-black uppercase tracking-[0.2em] text-xs">
{{ __('Close Panel') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #475569;
}
</style>
@endsection @endsection

View File

@@ -11,15 +11,17 @@
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Connectivity Status') }}</h3> <h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Connectivity Status') }}</h3>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time status monitoring') }}</p> <p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time status monitoring') }}</p>
</div> </div>
<div class="flex items-center gap-x-1.5 px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 border border-cyan-500/20"> <div
class="flex items-center gap-x-1.5 px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-500 border border-cyan-500/20">
<span class="relative flex h-2 w-2"> <span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span> <span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span> <span class="relative inline-flex rounded-full h-2 w-2 bg-cyan-500"></span>
</span> </span>
<span class="text-[10px] font-black uppercase tracking-wider">{{ __('LIVE') }}</span> <span class="text-[10px] font-black uppercase tracking-wider">{{ __('LIVE') }}</span>
</div> </div>
</div> </div>
<div class="flex-1 flex items-center"> <div class="flex-1 flex items-center">
<!-- Left: Stats List --> <!-- Left: Stats List -->
<div class="flex-1 space-y-6"> <div class="flex-1 space-y-6">
@@ -35,24 +37,27 @@
<div class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.6)]"></div> <div class="w-2 h-2 rounded-full bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.6)]"></div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Offline Machines') }}</span> <span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Offline Machines') }}</span>
</div> </div>
<span class="text-2xl font-black text-rose-500">{{ $alertsPending }}</span> <span class="text-2xl font-black text-rose-500">{{ $offlineMachines }}</span>
</div> </div>
<div class="flex items-center justify-between pr-10"> <div class="flex items-center justify-between pr-10">
<div class="flex items-center gap-x-4"> <div class="flex items-center gap-x-4">
<div class="w-2 h-2 rounded-full bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.6)]"></div> <div class="w-2 h-2 rounded-full bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.6)]"></div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Alerts Pending') }}</span> <span class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ __('Alerts Pending') }}</span>
</div> </div>
<span class="text-2xl font-black text-slate-900 dark:text-white">0</span> <span class="text-2xl font-black text-slate-900 dark:text-white">{{ $alertsPending }}</span>
</div> </div>
</div> </div>
<!-- Divider --> <!-- Divider -->
<div class="w-px h-32 bg-slate-100 dark:bg-slate-800 mx-2"></div> <div class="w-px h-32 bg-slate-100 dark:bg-slate-800 mx-2"></div>
<!-- Right: Big Total --> <!-- Right: Big Total -->
<div class="w-40 text-center"> <div class="w-40 text-center">
<p class="text-7xl font-black text-cyan-500 drop-shadow-[0_0_20px_rgba(6,182,212,0.3)] leading-none">{{ $activeMachines }}</p> <p
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] mt-4">{{ __('Total Connected') }}</p> class="text-7xl font-black text-cyan-500 drop-shadow-[0_0_20px_rgba(6,182,212,0.3)] leading-none">
{{ $activeMachines }}</p>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.2em] mt-4">
{{ __('Total Connected') }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -64,25 +69,38 @@
<h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Monthly Transactions') }}</h3> <h3 class="text-xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Monthly Transactions') }}</h3>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Monthly cumulative revenue overview') }}</p> <p class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Monthly cumulative revenue overview') }}</p>
</div> </div>
<div class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800/80 text-slate-400 dark:text-slate-500 border border-transparent dark:border-slate-700/50"> <div
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800/80 text-slate-400 dark:text-slate-500 border border-transparent dark:border-slate-700/50">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div> </div>
</div> </div>
<div class="flex-1 flex flex-col space-y-4 justify-center"> <div class="flex-1 flex flex-col space-y-4 justify-center">
<!-- Today Stat Card --> <!-- Today Stat Card -->
<div class="group flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:shadow-none border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/30"> <div
class="group flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-slate-900 shadow-[0_2px_15px_-3px_rgba(0,0,0,0.07),0_10px_20px_-2px_rgba(0,0,0,0.04)] dark:shadow-none border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/30">
<div class="flex items-center gap-x-4"> <div class="flex items-center gap-x-4">
<div class="w-12 h-12 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 flex items-center justify-center text-cyan-600 dark:text-cyan-400 shadow-sm transition-transform group-hover:scale-110"> <div
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.5 4.5L21.75 7.5M21.75 7.5V12m0-4.5H17.25"/></svg> class="w-12 h-12 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 flex items-center justify-center text-cyan-600 dark:text-cyan-400 shadow-sm transition-transform group-hover:scale-110">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 18L9 11.25l4.5 4.5L21.75 7.5M21.75 7.5V12m0-4.5H17.25" />
</svg>
</div> </div>
<div> <div>
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Today's Transactions") }}</p> <p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Today's Transactions") }}</p>
<p class="text-4xl font-black text-slate-900 dark:text-white mt-1 tracking-tight drop-shadow-sm">${{ number_format($totalRevenue / 30, 0) }}</p> <p
class="text-4xl font-black text-slate-900 dark:text-white mt-1 tracking-tight drop-shadow-sm">
${{ number_format($totalRevenue / 30, 0) }}</p>
</div> </div>
</div> </div>
<div class="flex flex-col items-end gap-y-1"> <div class="flex flex-col items-end gap-y-1">
<span class="text-[10px] font-black text-emerald-500 bg-emerald-500/10 px-2.5 py-0.5 rounded-full">+12.5%</span> <span
class="text-[10px] font-black text-emerald-500 bg-emerald-500/10 px-2.5 py-0.5 rounded-full">+12.5%</span>
<p class="text-[9px] font-bold text-slate-300 dark:text-slate-500 uppercase tracking-tighter">{{ __('vs Yesterday') }}</p> <p class="text-[9px] font-bold text-slate-300 dark:text-slate-500 uppercase tracking-tighter">{{ __('vs Yesterday') }}</p>
</div> </div>
</div> </div>
@@ -90,25 +108,39 @@
<!-- Previous Days Stats Row --> <!-- Previous Days Stats Row -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<!-- Yesterday Card --> <!-- Yesterday Card -->
<div class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20"> <div
class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Yesterday") }}</p> <p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Yesterday") }}</p>
<div class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400"> <div
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div> </div>
</div> </div>
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue / 25, 0) }}</p> <p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue
/ 25, 0) }}</p>
</div> </div>
<!-- Before Yesterday Card --> <!-- Before Yesterday Card -->
<div class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20"> <div
class="group flex flex-col p-5 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 border border-slate-100 dark:border-slate-800 transition-all hover:border-cyan-500/20">
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Day Before") }}</p> <p class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ __("Day Before") }}</p>
<div class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400"> <div
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg> class="w-6 h-6 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</div> </div>
</div> </div>
<p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue / 40, 0) }}</p> <p class="text-xl font-black text-slate-800 dark:text-slate-200">${{ number_format($totalRevenue
/ 40, 0) }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -121,22 +153,27 @@
<div> <div>
<div class="flex items-center gap-x-3"> <div class="flex items-center gap-x-3">
<h2 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Status List') }}</h2> <h2 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Machine Status List') }}</h2>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-tighter"> <span
class="inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-tighter">
{{ __('Total items', ['count' => $machines->total()]) }} {{ __('Total items', ['count' => $machines->total()]) }}
</span> </span>
</div> </div>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time monitoring across all machines') }}</p> <p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1">{{ __('Real-time monitoring across all machines') }}</p>
</div> </div>
<form action="{{ route('admin.dashboard') }}" method="GET" class="flex flex-wrap items-center gap-4"> <form action="{{ route('admin.dashboard') }}" method="GET" class="flex flex-wrap items-center gap-4">
<div class="relative group"> <div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none"> <span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> <svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
</span> </span>
<input type="text" name="search" value="{{ request('search') }}" class="py-3 pl-12 pr-6 block w-64 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 rounded-2xl text-sm font-bold text-slate-700 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 transition-all outline-none" placeholder="{{ __('Quick search...') }}"> <input type="text" name="search" value="{{ request('search') }}"
class="py-3 pl-12 pr-6 block w-64 border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 rounded-2xl text-sm font-bold text-slate-700 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 transition-all outline-none"
placeholder="{{ __('Quick search...') }}">
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}"> <input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
</div> </div>
</form> </form>
@@ -146,63 +183,115 @@
<table class="w-full text-left border-separate border-spacing-y-0"> <table class="w-full text-left border-separate border-spacing-y-0">
<thead> <thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/30"> <tr class="bg-slate-50/50 dark:bg-slate-900/30">
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">{{ __('Machine Info') }}</th> <th
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Running Status') }}</th> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Today Cumulative Sales') }}</th> {{ __('Machine Info') }}</th>
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Current Stock') }}</th> <th
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Last Signal') }}</th> class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
<th class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Alert Summary') }}</th> {{ __('Running Status') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Today Cumulative Sales') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Current Stock') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
{{ __('Last Signal') }}</th>
<th
class="px-6 py-4 text-[12px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">
{{ __('Alert Summary') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/50"> <tbody class="divide-y divide-slate-50 dark:divide-slate-800/50">
@forelse($machines as $machine) @forelse($machines as $machine)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300"> <tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6"> <td class="px-6 py-6">
<div class="flex items-center gap-x-5"> <div class="flex items-center gap-x-5">
<div class="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-300 group-hover:bg-cyan-500 group-hover:text-white transition-all shadow-sm"> <div
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg> class="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-300 group-hover:bg-cyan-500 group-hover:text-white transition-all shadow-sm">
</div> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="flex flex-col"> stroke-width="2">
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $machine->name }}</span> <path
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN: {{ $machine->serial_no }})</span> d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</div> </svg>
</div> </div>
</td> <div class="flex flex-col">
<td class="px-6 py-6 text-center"> <span
@if($machine->status === 'online') 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">{{
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase"> $machine->name }}</span>
{{ __('Online') }} <span
</span> class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 uppercase tracking-[0.15em]">(SN:
@else {{ $machine->serial_no }})</span>
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">
{{ __('Offline') }}
</span>
@endif
</td>
<td class="px-6 py-6 text-center">
<span class="text-base font-extrabold text-slate-900 dark:text-slate-100">$ 0</span>
</td>
<td class="px-6 py-6">
<div class="flex flex-col items-center gap-y-2.5">
<div class="w-32 h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
<div class="h-full bg-rose-500 rounded-full shadow-[0_0_8px_rgba(244,63,94,0.4)]" style="width: 15.5%"></div>
</div>
<span class="text-[11px] font-black text-rose-500 uppercase tracking-[0.2em]">15.5% {{ __('Low Stock') }}</span>
</div> </div>
</td> </div>
<td class="px-6 py-6 text-center"> </td>
<div class="text-xs font-black text-slate-400 dark:text-slate-400/80 uppercase tracking-widest leading-none"> <td class="px-6 py-6 text-center">
{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->format('Y/m/d H:i') : '---' }} @php
$cStatus = $machine->calculated_status;
@endphp
@if($cStatus === 'online')
<div
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<div class="relative flex h-2 w-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</div> </div>
</td> <span
<td class="px-6 py-6 text-right"> class="text-[10px] font-black text-emerald-600 dark:text-emerald-400 tracking-widest uppercase">{{
<span class="text-[11px] font-bold text-slate-400/30 dark:text-slate-500 uppercase tracking-widest group-hover:text-slate-400 transition-colors">{{ __('No alert summary') }}</span> __('Online') }}</span>
</td> </div>
</tr> @elseif($cStatus === 'error')
<div
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-rose-500/10 border border-rose-500/20">
<div class="h-2 w-2 rounded-full bg-rose-500 animate-pulse"></div>
<span
class="text-[10px] font-black text-rose-600 dark:text-rose-400 tracking-widest uppercase">{{
__('Error') }}</span>
</div>
@else
<div
class="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-slate-500/10 border border-slate-500/20">
<div class="h-2 w-2 rounded-full bg-slate-400"></div>
<span
class="text-[10px] font-black text-slate-600 dark:text-slate-400 tracking-widest uppercase">{{
__('Offline') }}</span>
</div>
@endif
</td>
<td class="px-6 py-6 text-center">
<span class="text-base font-extrabold text-slate-900 dark:text-slate-100">$ 0</span>
</td>
<td class="px-6 py-6">
<div class="flex flex-col items-center gap-y-2.5">
<div
class="w-32 h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden shadow-inner">
<div class="h-full bg-rose-500 rounded-full shadow-[0_0_8px_rgba(244,63,94,0.4)]"
style="width: 15.5%"></div>
</div>
<span class="text-[11px] font-black text-rose-500 uppercase tracking-[0.2em]">15.5% {{
__('Low Stock') }}</span>
</div>
</td>
<td class="px-6 py-6 text-center">
<div
class="text-xs font-black text-slate-400 dark:text-slate-400/80 uppercase tracking-widest leading-none">
{{ $machine->last_heartbeat_at ? $machine->last_heartbeat_at->format('Y/m/d H:i') :
'---' }}
</div>
</td>
<td class="px-6 py-6 text-right">
<span
class="text-[11px] font-bold text-slate-400/30 dark:text-slate-500 uppercase tracking-widest group-hover:text-slate-400 transition-colors">{{
__('No alert summary') }}</span>
</td>
</tr>
@empty @empty
<tr> <tr>
<td colspan="6" class="px-6 py-32 text-center text-slate-400">{{ __('No data available') }}</td> <td colspan="6" class="px-6 py-32 text-center text-slate-400">{{ __('No data available') }}</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
@@ -213,4 +302,4 @@
</div> </div>
</div> </div>
</div> </div>
@endsection @endsection

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More