From deef3baacc9bff77dbdd025e7d39690d585c5ad6 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 25 Feb 2026 11:48:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=A7=8B=E6=A8=A1?= =?UTF-8?q?=E7=B5=84=E9=80=9A=E8=A8=8A=E8=88=87=E8=AA=BF=E6=95=B4=E5=84=80?= =?UTF-8?q?=E8=A1=A8=E6=9D=BF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 依循跨模組通訊規範,將 Sales 與 Production 模組中對 Inventory 的直接模型關聯改為透過 InventoryServiceInterface 取得 - 於 InventoryService 實作獲取最高庫存價值、即將過期商品等方法,供儀表板使用 - 確保所有跨模組調用皆採用手動水和(Manual Hydration)方式組合資料 - 移除本地已歸檔的 .agent 規範檔案 --- .agent/skills/ui-consistency/SKILL.md | 1260 ----------------- .../rules/activity-logging.md | 4 + .../rules/cross-module-communication.md | 19 +- {.agent => .agents}/rules/framework.md | 4 - .../rules/permission-management.md | 4 + .agents/rules/ui-consistency.md | 512 +++++++ .../Core/Controllers/DashboardController.php | 141 +- .../Controllers/AccountPayableController.php | 54 +- app/Modules/Finance/Models/AccountPayable.php | 9 +- .../Contracts/InventoryServiceInterface.php | 18 + .../Controllers/GoodsReceiptController.php | 3 +- .../Inventory/Services/InventoryService.php | 25 + .../Contracts/ProductionServiceInterface.php | 8 + app/Modules/Production/Models/RecipeItem.php | 10 +- .../Production/ProductionServiceProvider.php | 5 +- .../Production/Services/ProductionService.php | 14 + .../Sales/Contracts/SalesServiceInterface.php | 11 + .../Controllers/SalesImportController.php | 25 +- .../Sales/Imports/SalesImportSheet.php | 5 +- app/Modules/Sales/Models/SalesImportItem.php | 13 +- app/Modules/Sales/SalesServiceProvider.php | 15 + app/Modules/Sales/Services/SalesService.php | 63 + 22 files changed, 826 insertions(+), 1396 deletions(-) delete mode 100644 .agent/skills/ui-consistency/SKILL.md rename .agent/skills/activity-logging/SKILL.md => .agents/rules/activity-logging.md (99%) rename .agent/skills/cross-module-communication/SKILL.md => .agents/rules/cross-module-communication.md (84%) rename {.agent => .agents}/rules/framework.md (99%) rename .agent/skills/permission-management/SKILL.md => .agents/rules/permission-management.md (99%) create mode 100644 .agents/rules/ui-consistency.md create mode 100644 app/Modules/Production/Contracts/ProductionServiceInterface.php create mode 100644 app/Modules/Production/Services/ProductionService.php create mode 100644 app/Modules/Sales/Contracts/SalesServiceInterface.php create mode 100644 app/Modules/Sales/SalesServiceProvider.php create mode 100644 app/Modules/Sales/Services/SalesService.php diff --git a/.agent/skills/ui-consistency/SKILL.md b/.agent/skills/ui-consistency/SKILL.md deleted file mode 100644 index 3ddf1e2..0000000 --- a/.agent/skills/ui-consistency/SKILL.md +++ /dev/null @@ -1,1260 +0,0 @@ ---- -name: 客戶端後台 UI 統一規範 -description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為 ---- - -# 客戶端後台 UI 統一規範 - -## 概述 - -本技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。 - -> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。 - -## 核心原則 - -1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件 -2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別 -3. **統一的圖標系統**:全面使用 `lucide-react` 圖標 -4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構 -5. **權限控制**:所有操作按鈕必須使用 `` 元件包裹 - ---- - -## 1. 專案結構 - -### 1.1 關鍵目錄 - -``` -resources/ -├── css/ -│ └── app.css # 全域樣式與設計 Token -├── js/ -│ ├── Components/ -│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui) -│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等) -│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll) -│ ├── Layouts/ -│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用 -│ │ └── LandlordLayout.tsx # 中央管理後台佈局 -│ └── Pages/ # 頁面元件 -``` - -### 1.2 可用 UI 元件清單 - -``` -accordion, alert, alert-dialog, avatar, badge, breadcrumb, button, -calendar, card, carousel, chart, checkbox, collapsible, command, -context-menu, dialog, drawer, dropdown-menu, form, hover-card, -input, input-otp, label, menubar, navigation-menu, pagination, -popover, progress, radio-group, resizable, scroll-area, -searchable-select, select, separator, sheet, sidebar, skeleton, -slider, sonner, switch, table, tabs, textarea, toggle, toggle-group, -tooltip -``` - ---- - -## 2. 色彩系統 - -### 2.1 主題色 (Primary) - **動態租戶品牌色** - -> **注意**:主題色會根據租戶設定(Branding)動態改變,**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。 -> 請務必使用 Tailwind Utility Class 或 CSS 變數。 - -| Tailwind Class | CSS Variable | 說明 | -|----------------|--------------|------| -| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 | -| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 | -| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 | -| `*-primary-lightest` | `--primary-lightest` | **最淺色**:系統自動計算,用於背景底色、Active 狀態 | - -**運作機制**: -`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。 - -```tsx -// ✅ 正確:使用 Tailwind Class -
...
- -// ✅ 正確:使用 CSS 變數 (自定義樣式時) -
...
- -// ❌ 錯誤:寫死色碼 (會導致租戶無法換色) -
...
-``` - -### 2.2 灰階 (Grey Scale) - -```css ---grey-0: #1a1a1a; /* 深黑 - 標題文字 */ ---grey-1: #4a4a4a; /* 深灰 - 主要內文 */ ---grey-2: #6b6b6b; /* 中灰 - 次要內文、Placeholder */ ---grey-3: #9e9e9e; /* 淺灰 - 禁用文字、輔助說明 */ ---grey-4: #e0e0e0; /* 極淺灰 - 邊框、分隔線 */ ---grey-5: #fff; /* 白色 - 背景、按鈕文字 */ -``` - -### 2.3 狀態色 (State Colors) - -```css ---other-success: #01ab83; /* 成功 - 同主題色 */ ---other-error: #dc2626; /* 錯誤 - 刪除、警示 */ ---other-warning: #f59e0b; /* 警告 - 提醒、注意 */ ---other-info: #3b82f6; /* 資訊 - 說明、提示 */ -``` - ---- - -## 3. 按鈕規範 - -### 3.1 按鈕樣式類別 - -專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別: - -#### Filled 按鈕(實心按鈕)— 用於主要操作 - -```tsx -// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認 - - -// ✅ 成功操作 - - -// ✅ 資訊操作(用於系統提示、說明等非業務主流程) - - -// ✅ 警告操作 - - -// ✅ 錯誤/刪除操作(AlertDialog 內確認按鈕) - -``` - -#### Outlined 按鈕(邊框按鈕)— 用於次要操作 - -```tsx -// ✅ 編輯按鈕(表格操作列) - - -// ✅ 刪除按鈕(表格操作列) - -``` - -#### Text 按鈕(文字按鈕) - -```tsx - -``` - -### 3.2 按鈕大小 - -| Size | 高度 | 使用情境 | -|------|------|----------| -| `size="sm"` | h-8 | 表格操作列、緊湊佈局 | -| `size="default"` | h-9 | 一般操作、表單提交 | -| `size="lg"` | h-10 | 主要 CTA、頁面主操作 | -| `size="icon"` | 9×9 | 純圖標按鈕 | - -### 3.3 常見操作按鈕模式 - -#### 頁面頂部新增按鈕 - -```tsx - - - - - -``` - -#### 表格操作列檢視按鈕 - -```tsx - - - - - -``` - -#### 表格操作列編輯按鈕 - -```tsx - - - - - -``` - -#### 表格操作列刪除按鈕(帶確認對話框) - -```tsx - - - - - - - - 確認刪除 - - 確定要刪除「{item.name}」嗎?此操作無法復原。 - - - - 取消 - handleDelete(item.id)} - className="bg-red-600 hover:bg-red-700" - > - 刪除 - - - - - -``` - -### 3.4 返回按鈕規範 - -詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。 - -**樣式規格**: -- **位置**:標題區域上方 (`mb-6`),獨立於標題列 -- **樣式**:`variant="outline"` + `className="gap-2 button-outlined-primary"` -- **圖標**:`` -- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」 - -```tsx -
- - - -
-``` - ---- - -## 3.5 頁面佈局規範(新增/編輯頁面) - -### 標準結構 - -新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構: - -```tsx - - - -
- {/* Header */} -
- {/* 返回按鈕 */} - - - - - {/* 頁面標題區塊 */} -
-

- - 頁面標題 -

-

- 頁面說明文字 -

-
-
- - {/* 表單或內容區塊 */} - -
-
-``` - -### 關鍵規範 - -1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致 -2. **Header 包裹**:使用 `
` 包裹返回按鈕與標題區塊 -3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔 -4. **標題區塊**:使用 `
` 包裹 h1 和 p 標籤 -5. **標題樣式**:`text-2xl font-bold text-grey-0 flex items-center gap-2` -6. **說明文字**:`text-gray-500 mt-1` - -### 範例頁面 - -- ✅ `/resources/js/Pages/PurchaseOrder/Create.tsx`(建立採購單) -- ✅ `/resources/js/Pages/Product/Create.tsx`(新增商品) -- ✅ `/resources/js/Pages/Product/Edit.tsx`(編輯商品) - ---- - -## 4. 圖標規範 - -### 4.1 統一使用 lucide-react - -**統一使用 `lucide-react`**,禁止使用其他圖標庫(如 FontAwesome、Material Icons、react-icons 等)。 - -### 4.2 圖標尺寸標準 - -| 尺寸 | 類別 | 使用情境 | -|------|------|----------| -| 小型 | `h-3 w-3` | Badge 內、小文字旁 | -| 標準 | `h-4 w-4` | 按鈕內、表格操作 | -| 標題 | `h-5 w-5` | 側邊欄選單 | -| 大型 | `h-6 w-6` | 頁面標題 | - -### 4.3 常用操作圖標映射 - -| 操作 | 圖標組件 | 使用情境 | -|------|----------|----------| -| 新增 | `` | 新增按鈕 | -| 編輯 | `` | 編輯按鈕 | -| 刪除 | `` | 刪除按鈕 | -| 查看 | `` | 查看詳情 | -| 搜尋 | `` | 搜尋欄位 | -| 篩選 | `` | 篩選功能 | -| 下載 | `` | 下載/匯出 | -| 上傳 | `` | 上傳/匯入 | -| 設定 | `` | 設定功能 | -| 複製 | `` | 複製內容 | -| 郵件 | `` | Email 顯示 | -| 使用者 | ``, `` | 使用者管理 | -| 權限 | `` | 角色/權限 | -| 排序 | ``, ``, `` | 表格排序 | -| 儀表板 | `` | 首頁/總覽 | -| 商品 | `` | 商品管理 | -| 倉庫 | `` | 倉庫管理 | -| 廠商 | ``, `` | 廠商管理 | -| 採購 | `` | 採購管理 | - -### 4.4 圖標使用範例 - -```tsx -import { Plus, Pencil, Trash2, Users } from 'lucide-react'; - -// 頁面標題 -

- - 使用者管理 -

- -// 按鈕內圖標(圖標在左,帶文字) - - -// 純圖標按鈕(表格操作列) - -``` - ---- - -## 5. 表格規範 - -### 5.1 表格容器 - -```tsx -
- - {/* 表格內容 */} -
-
-``` - -### 5.2 表格標題列 - -```tsx - - - # - 名稱 - 操作 - - -``` - -**關鍵要點**: -- 使用 `bg-gray-50` 背景色 -- 序號欄位固定寬度 `w-[50px]` 並置中 -- 操作欄位置中顯示 - -### 5.3 表格主體 - -```tsx - - {items.length === 0 ? ( - - - 無符合條件的資料 - - - ) : ( - items.map((item, index) => ( - - - {startIndex + index} - - {/* 其他欄位 */} - -
- {/* 操作按鈕 */} -
-
-
- )) - )} -
-``` - -**關鍵要點**: -- 空狀態訊息使用置中、灰色文字 -- 序號欄使用 `text-gray-500 font-medium text-center` -- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕 - -### 5.4 欄位排序規範 - -當表格需要支援排序時,請遵循以下模式: - -1. **圖標邏輯**: - * 未排序:`ArrowUpDown` (class: `text-muted-foreground`) - * 升冪 (asc):`ArrowUp` (class: `text-primary`) - * 降冪 (desc):`ArrowDown` (class: `text-primary`) -2. **結構**:在 `TableHead` 內使用 `button` 元素。 -3. **後端配合**:後端 Controller **必須** 處理 `sort_by` 與 `sort_order` 參數。 - -```tsx -// 1. 定義 Helper Component (在元件內部) -const SortIcon = ({ field }: { field: string }) => { - if (filters.sort_by !== field) { - return ; - } - if (filters.sort_order === "asc") { - return ; - } - return ; -}; - -// 2. 表格標題應用 - - - - -// 3. 排序處理函式 (三態切換:未排序 -> 升冪 -> 降冪 -> 未排序) -const handleSort = (field: string) => { - let newSortBy: string | undefined = field; - let newSortOrder: 'asc' | 'desc' | undefined = 'asc'; - - if (filters.sort_by === field) { - if (filters.sort_order === 'asc') { - newSortOrder = 'desc'; - } else { - // desc -> reset (回到預設排序) - newSortBy = undefined; - newSortOrder = undefined; - } - } - - router.get( - route(route().current()!), - { ...filters, sort_by: newSortBy, sort_order: newSortOrder }, - { preserveState: true, replace: true } - ); -}; -``` - ---- - -## 6. 分頁規範 - -### 6.1 統一分頁元件 - -使用 `@/Components/shared/Pagination` 元件: - -```tsx -import Pagination from "@/Components/shared/Pagination"; -import { SearchableSelect } from "@/Components/ui/searchable-select"; - -// 在表格下方(底部工具列) -
-
-
- 每頁顯示 - - -
- {/* 總筆數顯示:統一放在每頁顯示右側,使用 text-gray-500 */} - 共 {data.total} 筆紀錄 -
- -
-``` - -### 6.2 每頁筆數狀態管理 - -```tsx -const [perPage, setPerPage] = useState(filters.per_page || "10"); - -const handlePerPageChange = (value: string) => { - setPerPage(value); - router.get( - route('resource.index'), - { per_page: value }, - { preserveState: false, replace: true, preserveScroll: true } - ); -}; -``` - ---- - - -## 7. Badge 與狀態顯示 - -### 7.1 基本 Badge - -```tsx -import { Badge } from "@/Components/ui/badge"; - -// Outline 樣式(最常用) -{item.category?.name || '-'} - -// 預設樣式(主題色背景) -啟用中 - -// 錯誤樣式 -停用 -``` - -### 7.2 角色顯示(特殊樣式) - -```tsx -
- {user.roles.map(role => ( -
-
- {role.name === 'super-admin' && } - - {role.display_name} - -
-
- ))} -
-``` - -### 7.3 統一狀態標籤 (StatusBadge) - -系統提供統一的 `StatusBadge` 元件來顯示各種業務狀態,確保顏色與樣式的一致性。 - -**引入方式**: - -```tsx -import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; -``` - -**支援的變體 (Variant)**: - -| Variant | 顏色 | 適用情境 | -|---|---|---| -| `neutral` | 灰色 | 草稿、取消、關閉、缺貨 | -| `info` | 藍色 | 處理中、啟用中 | -| `warning` | 黃色 | 待審核、庫存預警、週轉慢 | -| `success` | 綠色 | 已完成、已核准、正常 | -| `destructive` | 紅色 | 作廢、駁回、滯銷、異常 | - -**實作模式**: - -建議定義一個 `getStatusVariant` 函式將業務狀態對應到 UI 變體,保持程式碼整潔。 - -```tsx -// 1. 定義狀態映射函式 -const getStatusVariant = (status: string): StatusVariant => { - switch (status) { - case 'normal': return 'success'; // 正常 -> 綠色 - case 'slow': return 'warning'; // 週轉慢 -> 黃色 - case 'dead': return 'destructive'; // 滯銷 -> 紅色 - case 'out_of_stock': return 'neutral';// 缺貨 -> 灰色 - default: return 'neutral'; - } -}; - -// 2. 在表格中使用 - - {item.status_label} - -``` - ---- - -## 8. 頁面佈局規範 - -### 8.1 頁面結構 - -```tsx -export default function ResourceIndex() { - return ( - - - -
- {/* 頁面頭部 */} - {/* 主要內容 */} - {/* 分頁元件 */} -
-
- ); -} -``` - -### 8.2 標準頁面頭部 - -```tsx -
-
-

- - 頁面標題 -

-

- 頁面說明文字 -

-
- - - - - -
-``` - ---- - -## 9. 權限控制規範 - -### 9.1 使用 Can 元件 - -**所有**涉及權限的 UI 元素都必須使用 `` 元件包裹: - -```tsx -import { Can } from "@/Components/Permission/Can"; - - - {/* 新增按鈕 */} - - - - {/* 編輯按鈕 */} - - - - {/* 刪除按鈕 */} - -``` - -### 9.2 權限命名規範 - -遵循 `resource.action` 格式: - -- `resource.view`:查看列表/詳情 -- `resource.create`:新增 -- `resource.edit`:編輯 -- `resource.delete`:刪除 - -### 9.3 多權限判斷 - -```tsx -// 滿足任一權限即可 - -
管理操作
-
- -// 必須滿足所有權限 -import { CanAll } from "@/Components/Permission/Can"; - - - - -``` - ---- - -## 10. 通知訊息規範 - -### 10.1 使用 Toast 通知 - -使用 `sonner` 的 `toast` 進行通知: - -```tsx -import { toast } from 'sonner'; - -// 成功訊息 -toast.success('操作成功'); - -// 錯誤訊息 -toast.error('操作失敗'); - -// 資訊訊息 -toast.info('提示訊息'); - -// 警告訊息 -toast.warning('警告訊息'); -``` - -### 10.2 常見操作的 Toast 訊息 - -```tsx -// 新增成功 -router.post(route('resource.store'), data, { - onSuccess: () => toast.success('新增成功'), - onError: () => toast.error('新增失敗,請檢查輸入內容'), -}); - -// 更新成功 -router.put(route('resource.update', id), data, { - onSuccess: () => toast.success('更新成功'), - onError: () => toast.error('更新失敗'), -}); - -// 刪除成功 -router.delete(route('resource.destroy', id), { - onSuccess: () => toast.success('已刪除'), - onError: () => toast.error('刪除失敗,請檢查權限'), -}); -``` - ---- - -## 11. 表單規範 - -### 11.1 表單容器 - -```tsx -
-
- {/* 表單欄位 */} -
-
-``` - -### 11.2 表單欄位 - -```tsx -
- - setData("field", e.target.value)} - placeholder="請輸入..." - className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" - /> - {errors.field &&

{errors.field}

} -
-``` - -### 11.3 下拉選單 - -使用 `SearchableSelect` 元件: - -```tsx -import { SearchableSelect } from "@/Components/ui/searchable-select"; - - setData("category_id", value)} - options={categories.map(cat => ({ label: cat.name, value: String(cat.id) }))} - placeholder="請選擇分類" - searchThreshold={10} // 超過 10 個選項才顯示搜尋框 -/> -``` - ---- - -## 11.4 對話框 (Dialog) 滾動與佈局 - -當對話框內容可能超出螢幕高度時(如長表單或詳細資料),**請勿使用 `ScrollArea`**,應直接在 `DialogContent` 使用原生的 `overflow-y-auto`。 - -**原因**:`ScrollArea` 在 Flex 佈局計算高度時容易失效或導致雙重滾動條。以及與原生捲動行為不一致。 - -```tsx -// ❌ 錯誤:使用 ScrollArea 或固定高度計算 - - - {/* 內容 */} - - - -// ✅ 正確:直接使用 overflow-y-auto 與 max-h - - ... -
- {/* 內容會自動滾動 */} -
- ... -
-``` - ---- - -## 11.5 輸入框尺寸 (Input Sizes) - -為確保介面整齊與統一,所有表單輸入元件標準高度應為 **`h-9`** (36px),與標準按鈕尺寸對齊。 - -- **Input**: 預設即為 `h-9` (由 `py-1` 與 `text-sm` 組合而成) -- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9` -- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。 - -## 11.6 日期輸入框樣式 (Date Input Style) - -日期輸入框應採用「**左側裝飾圖示 + 右側原生操作**」的配置,以保持視覺一致性並保留瀏覽器原生便利性。 - -**樣式規格**: -1. **容器**: 使用 `relative` 定位。 -2. **圖標**: 使用 `Calendar` 圖標,放置於絕對位置 `absolute left-2.5 top-2.5`,顏色 `text-gray-400`,並設定 `pointer-events-none` 避免干擾點擊。 -3. **輸入框**: 設定 `pl-9` (左內距) 以避開圖示,並使用原生 `type="date"` 或 `type="datetime-local"`。 - -```tsx -import { Calendar } from "lucide-react"; - -## 11.7 金額與數字輸入規範 - -所有涉及金額(單價、成本、總價)的輸入框,應遵循以下規範以確保操作體驗一致: - -1. **HTML 屬性**: - * `type="number"` - * `min="0"` (除非業務邏輯允許負數) - * `step="any"` (設置為 `any` 可允許任意小數,且瀏覽器預設按上下鍵時會增減 **1** 並保留小數部分,例如 37.2 -> 38.2) - * **步進值 (Step)**: 金額與數量輸入框均應設定 `step="any"`,以支援小數點輸入(除非業務邏輯強制整數)。 - * `placeholder="0"` -2. **樣式類別**: - * 預設靠左對齊 (不需要 `text-right`),亦可依版面需求調整。 - -### 9.2 對齊方式 (Alignment) - -依據欄位所在的情境區分對齊方式: - -- **明細列表/表格 (Details/Table)**:金額與數量欄位一律 **靠右對齊 (text-right)**。 - - 包含:採購單明細、庫存盤點表、調撥單明細等 Table 內的輸入框。 -- **一般表單/新增欄位 (Form/Input)**:金額與數量欄位一律 **靠左對齊 (text-left)**。 - - 包含:商品資料設定、新增表單中的獨立欄位。亦可依版面需求調整。 -3. **行為邏輯**: - * 輸入時允許輸入小數點。 - * 鍵盤上下鍵調整時,瀏覽器會預設增減 1 (搭配 `step="any"`)。 - -```tsx - setPrice(parseFloat(e.target.value) || 0)} - placeholder="0" -/> -``` - -
- - setDate(e.target.value)} - /> -
-``` - -## 11.7 搜尋選單樣式 (SearchableSelect Style) - -`SearchableSelect` 元件在表單或篩選列中使用時,高度必須設定為 `h-9` 以與輸入框對齊。 - -```tsx - -``` - -## 11.8 篩選列規範 (Filter Bar Norms) - -列表頁面的篩選區域(Filter Bar)應遵循以下規範以節省空間並保持層級清晰: - -1. **標籤文字 (Labels)**: 使用 **`text-xs`** (`12px`) 大小,顏色建議使用 `text-gray-500` 或 `text-grey-2`。這與一般表單 (`text-sm`) 不同,目的是降低篩選列的視覺權重。 -2. **輸入元件高度**: 統一使用 **`h-9`** (`36px`)。 -3. **佈局**: - - **容器內距**: 統一使用 **`p-5`** (`20px`)。 - - **Grid 間距**: 建議使用 **`gap-4`** (`16px`) 或 `gap-6` (`24px`),但同一專案內需統一。本專案推薦 **`gap-4`**。 - - **垂直間距**: Label 與 Input 之間使用 **`space-y-1`** (`4px`)。 - - **排版**: 建議使用 Grid 系統 (`grid-cols-12`) 進行排版。 - -```tsx -
- - -
-``` - -4. **操作按鈕區 (Action Bar)**: - - **位置**: 位於篩選列最下方。 - - **樣式**: 統一使用 `flex items-center justify-end border-t border-grey-4 pt-5 gap-3`。 - - **說明**: `border-grey-4` 為標準通用邊框色,`pt-5` 與容器 padding (`p-5`) 呼應,維持視覺平衡。 - -5. **收合模式 (Collapsible Mode)**: - - **目的**: 節省垂直空間,預設隱藏較佔空間與低頻使用的篩選器(如日期區間)。 - - **實作**: - - 預設狀態:若無相關篩選值,則預設為 **收合 (Collapsed)**。 - - 切換按鈕:位於 Action Bar 左側 (`mr-auto`)。 - - 樣式:Ghost Button + `ChevronDown`/`ChevronUp` Icon + 提示圓點 (Indicator)。 - - **邏輯**: 若載入頁面時已有被隱藏的篩選值 (e.g. `date_start`),則強制 **展開 (Expanded)** 或顯示提示。 - ---- - -## 12. 檢查清單 - -在開發或審查頁面時,請確認以下項目: - -### ✅ 按鈕 -- [ ] 使用 `button-filled-*` 或 `button-outlined-*` 類別 -- [ ] 主要操作使用 `button-filled-primary` -- [ ] 編輯操作使用 `button-outlined-primary` -- [ ] 刪除操作使用 `button-outlined-error` -- [ ] 按鈕尺寸正確(sm/default/lg) -- [ ] 包含適當的圖標 - -### ✅ 圖標 -- [ ] 全部使用 `lucide-react` -- [ ] 尺寸正確(h-3/h-4/h-5/h-6) -- [ ] 顏色與上下文一致 - -### ✅ 表格 -- [ ] 使用 `@/Components/ui/table` 元件 -- [ ] 有 `bg-white rounded-xl border` 容器 -- [ ] 標題列有 `bg-gray-50` 背景 -- [ ] 序號欄固定寬度並置中 -- [ ] 操作欄使用 `flex justify-center gap-2` -- [ ] 空狀態訊息置中顯示 - -### ✅ 分頁 -- [ ] 使用 `@/Components/shared/Pagination` -- [ ] 有每頁筆數選擇器(10/20/50/100) - -### ✅ 權限 -- [ ] 所有操作按鈕都用 `` 包裹 -- [ ] 權限命名符合 `resource.action` 格式 - -### ✅ 通知 -- [ ] 使用 `toast` 提供操作反饋 -- [ ] 成功/錯誤訊息明確 - -### ✅ 整體 -- [ ] 頁面有標準頭部(標題 + 圖標 + 說明 + 新增按鈕) -- [ ] 容器寬度使用 `max-w-7xl` -- [ ] 使用正確的佈局(`AuthenticatedLayout`) - ---- - -## 13. 常見錯誤與修正 - -### ❌ 錯誤:自定義按鈕樣式 - -```tsx -// ❌ 錯誤 - - -// ✅ 正確 - -``` - -### ❌ 錯誤:混用圖標庫 - -```tsx -// ❌ 錯誤 -import { FaEdit } from 'react-icons/fa'; - - -// ✅ 正確 -import { Pencil } from 'lucide-react'; - -``` - -### ❌ 錯誤:操作欄未置中 - -```tsx -// ❌ 錯誤 - - - - - -// ✅ 正確 - -
- - -
-
-``` - -### ❌ 錯誤:缺少權限控制 - -```tsx -// ❌ 錯誤 - - -// ✅ 正確 - - - -``` - ---- - -## 14. 參考範例 - -以下頁面展示了完整的 UI 統一性實踐: - -- **使用者管理**:`resources/js/Pages/Admin/User/Index.tsx` -- **角色管理**:`resources/js/Pages/Admin/Role/Index.tsx` -- **產品管理**:`resources/js/Pages/Product/Index.tsx` -- **倉庫管理**:`resources/js/Pages/Warehouse/Index.tsx` - ---- - -## 總結 - -遵循本規範可確保: - -1. ✅ **視覺一致性**:所有頁面看起來像同一個系統 -2. ✅ **維護效率**:使用統一組件,修改一處即可影響全局 -3. ✅ **開發速度**:有明確的模式可循,減少決策時間 -4. ✅ **使用者體驗**:一致的互動模式降低學習成本 -5. ✅ **安全性**:統一的權限控制確保資料安全 - -當你在開發或審查 Star ERP 客戶端後台的 UI 時,請務必參考此規範! - ---- - -## 15. 批次匯入彈窗規範 (Batch Import Dialog) - -為了確保系統中所有批次匯入功能(如:商品、庫存、客戶)的體驗一致,必須遵循以下 UI 結構與樣式。 - -### 15.1 標題結構 - -- **樣式**:保持簡潔,僅使用文字標題,不帶額外圖示。 -- **文字**:統一為「匯入XXXX資料」。 - -```tsx - - 匯入商品資料 - - 請先下載範本,填寫完畢後上傳檔案進行批次處理。 - - -``` - -### 15.2 分步引導區塊 (Step-by-Step Guide) - -匯入流程必須分為三個清晰的步驟區塊: - -#### 步驟 1:取得匯入範本 -- **容器樣式**:`bg-gray-50 rounded-lg border border-gray-100 p-4 space-y-2` -- **標題圖示**:`` -- **下載按鈕**:`variant="outline" size="sm" className="w-full sm:w-auto button-outlined-primary"`,並明確標註 `.xlsx`。 - -#### 步驟 2:設定資訊 (選甜) -- **容器樣式**:`space-y-2` -- **標題圖示**:`` -- **欄位樣式**:使用標準 `Input`,標籤文字使用 `text-sm text-gray-700`。 -- **預設值**:若有備註欄位,應提供合適的預設值(例如:「Excel 匯入」)。 - -#### 步驟 3:上傳填寫後的檔案 -- **容器樣式**:`space-y-2` -- **標題圖示**:`` -- **Input 樣式**:`type="file"`,並開啟 `cursor-pointer`。 - -### 15.3 規則說明面板 (Accordion Rules) - -詳細的填寫說明必須收納於 `Accordion` 中,避免干擾主流程: - -- **樣式**:標準灰色邊框,不使用特殊背景色 (如琥珀色)。 -- **容器**:`className="w-full border rounded-lg px-2"` -- **觸發文字**:`text-sm text-gray-500` - -```tsx - - - -
- - 匯入規則與提示 -
-
- -
-
    -
  • 使用加粗文字標註關鍵欄位:關鍵字
  • -
  • 說明文字簡潔明瞭。
  • -
-
-
-
-
-``` - -### 15.4 底部操作 (Footer) - -- **取消按鈕**:`variant="outline"`,且為 `button-outlined-primary`。 -- **提交按鈕**:`button-filled-primary`,且在處理中時顯示 `Loader2`。 - ---- - -## 16. 詳情頁面項目清單規範 (Detail Page Item List Standards) - -為了確保詳情頁面(如:採購單詳情、進貨單詳情、銷售匯入詳情)的資訊層級清晰且視覺統一,所有項目清單必須遵循以下規範。 - -### 16.1 容器結構 (Container Structure) - -項目清單應封裝在一個帶有內距的卡片容器中,而不是讓表格直接緊貼外層卡片邊緣。 - -1. **外層卡片**:`bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden` -2. **標題區塊**:`p-6 border-b border-gray-100 bg-gray-50/30` -3. **內容內距**:標題下方的內容區塊應加上 `p-6`。 -4. **表格包裹層**:表格應再包裹一層 `border rounded-lg overflow-hidden`,以確保表格內部的邊角與隔線視覺完整。 - -```tsx -
- {/* 標題 */} -
-

項目清單標題

-
- - {/* 內容區塊 */} -
-
- - - - {/* 標頭欄位 */} - - - - {/* 表格內容 */} - -
-
- - {/* 若有分頁,直接放在 p-6 容器內,並加 mt-6 分隔 */} -
- -
-
-
-``` - -### 16.2 表格樣式細節 (Table Styling) - -1. **標頭背景**:`TableHeader` 的第一個 `TableRow` 應使用 `bg-gray-50 hover:bg-gray-50` 強化視覺區隔。 -2. **文字顏色**:主體文字使用 `text-gray-900`(標題/重要數據)或 `text-gray-500`(輔助/序號)。 -3. **數據對齊**: - * **數量/序號**:文字置中 (`text-center`) 或依據數據類型對齊。 - * **金額**:金額欄位必須使用 `text-right` 並視情況加粗 (`font-bold`) 或加上 `text-primary-main` 顏色。 -4. **表格隔線**:確保表格具有清晰但不過於突出的水平隔線,提升長列表的可讀性。 - diff --git a/.agent/skills/activity-logging/SKILL.md b/.agents/rules/activity-logging.md similarity index 99% rename from .agent/skills/activity-logging/SKILL.md rename to .agents/rules/activity-logging.md index 3ea89fe..0ea846f 100644 --- a/.agent/skills/activity-logging/SKILL.md +++ b/.agents/rules/activity-logging.md @@ -1,3 +1,7 @@ +--- +trigger: always_on +--- + --- name: 操作紀錄實作規範 description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。 diff --git a/.agent/skills/cross-module-communication/SKILL.md b/.agents/rules/cross-module-communication.md similarity index 84% rename from .agent/skills/cross-module-communication/SKILL.md rename to .agents/rules/cross-module-communication.md index 8e26c9f..9d2ae8a 100644 --- a/.agent/skills/cross-module-communication/SKILL.md +++ b/.agents/rules/cross-module-communication.md @@ -1,3 +1,7 @@ +--- +trigger: always_on +--- + --- name: 跨模組調用與通訊規範 (Cross-Module Communication) description: 規範 Laravel Modular Monolith 架構下,不同業務模組中如何彼此調用資料與邏輯,包含禁止項目、Interface 實作、與 Service 綁定規則。 @@ -9,7 +13,7 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中 ## 🚫 絕對禁止的行為 (Strict Prohibitions) -* **禁止跨模組 Eloquent 關聯** +* **禁止跨模組 Eloquent 關聯(例外除外)** * **錯誤**:在 `app/Modules/Sales/Models/Order.php` 中撰寫 `public function product() { return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); }`。 * **原因**:這會造成資料庫查詢的強耦合。如果 `Inventory` 模組修改了 Schema,`Sales` 模組會無預警崩壞。 * **禁止跨模組直接引入 (use) Model** @@ -19,6 +23,19 @@ description: 規範 Laravel Modular Monolith 架構下,不同業務模組中 --- +## 🌟 允許的全域例外 (Global Exceptions) + +雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。 + +其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model: +1. **`App\Modules\Core\Models\User`**:因為幾乎所有表都有 `created_by` / `updated_by`,直接關聯可保留 `with('creator')` 等便利性。 +2. **`App\Modules\Core\Models\Role`**:權限判定已深度整合至系統底層。 +3. **`App\Modules\Core\Models\Tenant`**:多租戶架構 (Tenancy) 的核心基石,底層查詢會頻繁依賴。 + +> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`)**絕對不能**反過來直接 `use` 外部業務模組的 Model,仍必須透過外部模組的 Service Interface 來索取資料。 + +--- + ## ✅ 正確的跨模組調用流程:合約與依賴反轉 所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。 diff --git a/.agent/rules/framework.md b/.agents/rules/framework.md similarity index 99% rename from .agent/rules/framework.md rename to .agents/rules/framework.md index a6dfa4c..421698d 100644 --- a/.agent/rules/framework.md +++ b/.agents/rules/framework.md @@ -2,10 +2,6 @@ trigger: always_on --- ---- -trigger: always_on ---- - # 開發框架規範說明書:ERP 系統 (star-erp) ## 1. 專案概述 diff --git a/.agent/skills/permission-management/SKILL.md b/.agents/rules/permission-management.md similarity index 99% rename from .agent/skills/permission-management/SKILL.md rename to .agents/rules/permission-management.md index 78c311d..d4de0b4 100644 --- a/.agent/skills/permission-management/SKILL.md +++ b/.agents/rules/permission-management.md @@ -1,3 +1,7 @@ +--- +trigger: always_on +--- + --- name: 權限管理與實作規範 description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。 diff --git a/.agents/rules/ui-consistency.md b/.agents/rules/ui-consistency.md new file mode 100644 index 0000000..ef6ad15 --- /dev/null +++ b/.agents/rules/ui-consistency.md @@ -0,0 +1,512 @@ +--- +trigger: always_on +--- + +--- +name: 客戶端後台 UI 統一規範 +description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為 +--- + +## 概述 + +本技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。 + +> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。 + +## 核心原則 + +1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件 +2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別 +3. **統一的圖標系統**:全面使用 `lucide-react` 圖標 +4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構 +5. **權限控制**:所有操作按鈕必須使用 `` 元件包裹 + +--- + +## 1. 專案結構 + +### 1.1 關鍵目錄 + +``` +resources/ +├── css/ +│ └── app.css # 全域樣式與設計 Token +├── js/ +│ ├── Components/ +│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui) +│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等) +│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll) +│ ├── Layouts/ +│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用 +│ │ └── LandlordLayout.tsx # 中央管理後台佈局 +│ └── Pages/ # 頁面元件 +``` + +### 1.2 可用 UI 元件清單 + +``` +accordion, alert, alert-dialog, avatar, badge, breadcrumb, button, +calendar, card, carousel, chart, checkbox, collapsible, command, +context-menu, dialog, drawer, dropdown-menu, form, hover-card, +input, input-otp, label, menubar, navigation-menu, pagination, +popover, progress, radio-group, resizable, scroll-area, +searchable-select, select, separator, sheet, sidebar, skeleton, +slider, sonner, switch, table, tabs, textarea, toggle, toggle-group, +tooltip +``` + +--- + +## 2. 色彩系統 + +### 2.1 主題色 (Primary) - **動態租戶品牌色** + +> **注意**:主題色會根據租戶設定(Branding)動態改變,**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。 +> 請務必使用 Tailwind Utility Class 或 CSS 變數。 + +| Tailwind Class | CSS Variable | 說明 | +|----------------|--------------|------| +| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 | +| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 | +| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 | +| `*-primary-lightest` | `--primary-lightest` | **最淺色**:系統自動計算,用於背景底色、Active 狀態 | + +**運作機制**: +`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。 + +```tsx +// ✅ 正確:使用 Tailwind Class +
...
+ +// ✅ 正確:使用 CSS 變數 (自定義樣式時) +
...
+ +// ❌ 錯誤:寫死色碼 (會導致租戶無法換色) +
...
+``` + +### 2.2 灰階 (Grey Scale) + +```css +--grey-0: #1a1a1a; /* 深黑 - 標題文字 */ +--grey-1: #4a4a4a; /* 深灰 - 主要內文 */ +--grey-2: #6b6b6b; /* 中灰 - 次要內文、Placeholder */ +--grey-3: #9e9e9e; /* 淺灰 - 禁用文字、輔助說明 */ +--grey-4: #e0e0e0; /* 極淺灰 - 邊框、分隔線 */ +--grey-5: #fff; /* 白色 - 背景、按鈕文字 */ +``` + +### 2.3 狀態色 (State Colors) + +```css +--other-success: #01ab83; /* 成功 - 同主題色 */ +--other-error: #dc2626; /* 錯誤 - 刪除、警示 */ +--other-warning: #f59e0b; /* 警告 - 提醒、注意 */ +--other-info: #3b82f6; /* 資訊 - 說明、提示 */ +``` + +--- + +## 3. 按鈕規範 + +### 3.1 按鈕樣式類別 + +專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別: + +#### Filled 按鈕(實心按鈕)— 用於主要操作 + +```tsx +// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認 + + +// ✅ 成功操作 + + +// ✅ 資訊操作(用於系統提示、說明等非業務主流程) + + +// ✅ 警告操作 + + +// ✅ 錯誤/刪除操作(AlertDialog 內確認按鈕) + +``` + +#### Outlined 按鈕(邊框按鈕)— 用於次要操作 + +```tsx +// ✅ 編輯按鈕(表格操作列) + + +// ✅ 刪除按鈕(表格操作列) + +``` + +#### Text 按鈕(文字按鈕) + +```tsx + +``` + +### 3.2 按鈕大小 + +| Size | 高度 | 使用情境 | +|------|------|----------| +| `size="sm"` | h-8 | 表格操作列、緊湊佈局 | +| `size="default"` | h-9 | 一般操作、表單提交 | +| `size="lg"` | h-10 | 主要 CTA、頁面主操作 | +| `size="icon"` | 9×9 | 純圖標按鈕 | + +### 3.3 常見操作按鈕模式 + +#### 頁面頂部新增按鈕 + +```tsx + + + + + +``` + +#### 表格操作列檢視按鈕 + +```tsx + + + + + +``` + +#### 表格操作列編輯按鈕 + +```tsx + + + + + +``` + +#### 表格操作列刪除按鈕(帶確認對話框) + +```tsx + + + + + + + + 確認刪除 + + 確定要刪除「{item.name}」嗎?此操作無法復原。 + + + + 取消 + handleDelete(item.id)} + className="bg-red-600 hover:bg-red-700" + > + 刪除 + + + + + +``` + +### 3.4 返回按鈕規範 + +詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。 + +**樣式規格**: +- **位置**:標題區域上方 (`mb-6`),獨立於標題列 +- **樣式**:`variant="outline"` + `className="gap-2 button-outlined-primary"` +- **圖標**:`` +- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」 + +```tsx +
+ + + +
+``` + +--- + +## 3.5 頁面佈局規範(新增/編輯頁面) + +### 標準結構 + +新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構: + +```tsx + + + +
+ {/* Header */} +
+ {/* 返回按鈕 */} + + + + + {/* 頁面標題區塊 */} +
+

+ + 頁面標題 +

+

+ 頁面說明文字 +

+
+
+ + {/* 表單或內容區塊 */} + +
+
+``` + +### 關鍵規範 + +1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致 +2. **Header 包裹**:使用 `
` 包裹返回按鈕與標題區塊 +3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔 +4. **標題區塊**:使用 `
` 包裹 h1 和 p 標籤 +5. **標題樣式**:`text-2xl font-bold text-grey-0 flex items-center gap-2` +6. **說明文字**:`text-gray-500 mt-1` + +### 範例頁面 + +- ✅ `/resources/js/Pages/PurchaseOrder/Create.tsx`(建立採購單) +- ✅ `/resources/js/Pages/Product/Create.tsx`(新增商品) +- ✅ `/resources/js/Pages/Product/Edit.tsx`(編輯商品) + +--- + +## 4. 圖標規範 + +### 4.1 統一使用 lucide-react + +**統一使用 `lucide-react`**,禁止使用其他圖標庫(如 FontAwesome、Material Icons、react-icons 等)。 + +### 4.2 圖標尺寸標準 + +| 尺寸 | 類別 | 使用情境 | +|------|------|----------| +| 小型 | `h-3 w-3` | Badge 內、小文字旁 | +| 標準 | `h-4 w-4` | 按鈕內、表格操作 | +| 標題 | `h-5 w-5` | 側邊欄選單 | +| 大型 | `h-6 w-6` | 頁面標題 | + +### 4.3 常用操作圖標映射 + +| 操作 | 圖標組件 | 使用情境 | +|------|----------|----------| +| 新增 | `` | 新增按鈕 | +| 編輯 | `` | 編輯按鈕 | +| 刪除 | `` | 刪除按鈕 | +| 查看 | `` | 查看詳情 | +| 搜尋 | `` | 搜尋欄位 | +| 篩選 | `` | 篩選功能 | +| 下載 | `` | 下載/匯出 | +| 上傳 | `` | 上傳/匯入 | +| 設定 | `` | 設定功能 | +| 複製 | `` | 複製內容 | +| 郵件 | `` | Email 顯示 | +| 使用者 | ``, `` | 使用者管理 | +| 權限 | `` | 角色/權限 | +| 排序 | ``, ``, `` | 表格排序 | +| 儀表板 | `` | 首頁/總覽 | +| 商品 | `` | 商品管理 | +| 倉庫 | `` | 倉庫管理 | +| 廠商 | ``, `` | 廠商管理 | +| 採購 | `` | 採購管理 | + +### 4.4 圖標使用範例 + +```tsx +import { Plus, Pencil, Trash2, Users } from 'lucide-react'; + +// 頁面標題 +

+ + 使用者管理 +

+ +// 按鈕內圖標(圖標在左,帶文字) + + +// 純圖標按鈕(表格操作列) + +``` + +--- + +## 5. 表格規範 + +### 5.1 表格容器 + +```tsx +
+ + {/* 表格內容 */} +
+
+``` + +### 5.2 表格標題列 + +```tsx + + + # + 名稱 + 操作 + + +``` + +**關鍵要點**: +- 使用 `bg-gray-50` 背景色 +- 序號欄位固定寬度 `w-[50px]` 並置中 +- 操作欄位置中顯示 + +### 5.3 表格主體 + +```tsx + + {items.length === 0 ? ( + + + 無符合條件的資料 + + + ) : ( + items.map((item, index) => ( + + + {startIndex + index} + + {/* 其他欄位 */} + +
+ {/* 操作按鈕 */} +
+
+
+ )) + )} +
+``` + +**關鍵要點**: +- 空狀態訊息使用置中、灰色文字 +- 序號欄使用 `text-gray-500 font-medium text-center` +- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕 + +### 5.4 欄位排序規範 + +當表格需要支援排序時,請遵循以下模式: + +1. **圖標邏輯**: + * 未排序:`ArrowUpDown` (class: `text-muted-foreground`) + * 升冪 (asc):`ArrowUp` (class: `text-primary`) + * 降冪 (desc):`ArrowDown` (class: `text-primary`) +2. **結構**:在 `TableHead` 內使用 `button` 元素。 +3. **後端配合**:後端 Controller **必須** 處理 `sort_by` 與 `sort_order` 參數。 + +```tsx +// 1. 定義 Helper Component (在元件內部) +const SortIcon = ({ field }: { field: string }) => { + if (filters.sort_by !== field) { + return ; + } + if (filters.sort_order === "asc") { + return ; + } + return ; +}; + +// 2. 表格標題應用 + + + + +// 3. 排序處理函式 (三態切換:未排序 -> 升冪 -> 降冪 -> 未排序) +const handleSort = (field: string) => { + let newSortBy: string | undefined = field; + let newSortOrder: 'asc' | 'desc' | undefined = 'asc'; + + if (filters.sort_by === field) { + if (filters.sort_order === 'asc') { + newSortOrder = 'desc'; + } else { + // desc -> reset (回到預設排序) + newSortBy = undefined; + newSortOrder = undefined; + } + } + + router.get( + route(route().curr \ No newline at end of file diff --git a/app/Modules/Core/Controllers/DashboardController.php b/app/Modules/Core/Controllers/DashboardController.php index 185e277..1e57bf3 100644 --- a/app/Modules/Core/Controllers/DashboardController.php +++ b/app/Modules/Core/Controllers/DashboardController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Controller; use App\Modules\Inventory\Contracts\InventoryServiceInterface; use App\Modules\Procurement\Contracts\ProcurementServiceInterface; +use App\Modules\Sales\Contracts\SalesServiceInterface; +use App\Modules\Production\Contracts\ProductionServiceInterface; use Inertia\Inertia; use Illuminate\Http\Request; @@ -13,13 +15,19 @@ class DashboardController extends Controller { protected $inventoryService; protected $procurementService; + protected $salesService; + protected $productionService; public function __construct( InventoryServiceInterface $inventoryService, - ProcurementServiceInterface $procurementService + ProcurementServiceInterface $procurementService, + SalesServiceInterface $salesService, + ProductionServiceInterface $productionService ) { $this->inventoryService = $inventoryService; $this->procurementService = $procurementService; + $this->salesService = $salesService; + $this->productionService = $productionService; } public function index() @@ -35,99 +43,70 @@ class DashboardController extends Controller $procStats = $this->procurementService->getDashboardStats(); // 銷售統計 (本月營收) - $thisMonthRevenue = \App\Modules\Sales\Models\SalesImportItem::whereMonth('transaction_at', now()->month) - ->whereYear('transaction_at', now()->year) - ->sum('amount'); + $thisMonthRevenue = $this->salesService->getThisMonthRevenue(); // 生產統計 (待核准工單) - $pendingProductionCount = \App\Modules\Production\Models\ProductionOrder::where('status', 'pending')->count(); + $pendingProductionCount = $this->productionService->getPendingProductionCount(); // 生產狀態分佈 // 近30日銷售趨勢 (Area Chart) - $startDate = now()->subDays(29)->startOfDay(); - $salesData = \App\Modules\Sales\Models\SalesImportItem::where('transaction_at', '>=', $startDate) - ->selectRaw('DATE(transaction_at) as date, SUM(amount) as total') - ->groupBy('date') - ->orderBy('date') - ->get() - ->mapWithKeys(function ($item) { - return [$item->date => (int)$item->total]; - }); - - $salesTrend = []; - for ($i = 0; $i < 30; $i++) { - $date = $startDate->copy()->addDays($i)->format('Y-m-d'); - $salesTrend[] = [ - 'date' => $startDate->copy()->addDays($i)->format('m/d'), - 'amount' => $salesData[$date] ?? 0, - ]; - } + $salesTrend = $this->salesService->getSalesTrend(); // 本月熱銷商品 Top 5 (Bar Chart) - $topSellingProducts = \App\Modules\Sales\Models\SalesImportItem::with('product') - ->whereMonth('transaction_at', now()->month) - ->whereYear('transaction_at', now()->year) - ->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(amount) as total_amount')) - ->groupBy('product_code', 'product_id') - ->orderByDesc('total_amount') - ->limit(5) - ->get() - ->map(function ($item) { - return [ - 'name' => $item->product ? $item->product->name : $item->product_code, - 'amount' => (int)$item->total_amount, - ]; - }); + $topSellingItems = $this->salesService->getTopSellingProducts(); + $productIds = $topSellingItems->pluck('product_id')->filter()->unique()->toArray(); + $productsMap = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + + $topSellingProducts = $topSellingItems->map(function ($item) use ($productsMap) { + $product = $productsMap->get($item->product_id); + return [ + 'name' => $product ? $product->name : $item->product_code, + 'amount' => (int)$item->total_amount, + ]; + }); // 庫存積壓排行 (Top Inventory Value) - $topInventoryValue = \App\Modules\Inventory\Models\Inventory::with('product') - ->select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value')) - ->where('quantity', '>', 0) - ->groupBy('product_id') - ->orderByDesc('total_value') - ->limit(5) - ->get() - ->map(function ($item) { - return [ - 'name' => $item->product ? $item->product->name : 'Unknown Product', - 'code' => $item->product ? $item->product->code : '', - 'value' => (int)$item->total_value, - ]; - }); + $topInventoryValueItems = $this->inventoryService->getTopInventoryValue(); + $invProductIds = $topInventoryValueItems->pluck('product_id')->filter()->unique()->toArray(); + $invProductsMap = $this->inventoryService->getProductsByIds($invProductIds)->keyBy('id'); + + $topInventoryValue = $topInventoryValueItems->map(function ($item) use ($invProductsMap) { + $product = $invProductsMap->get($item->product_id); + return [ + 'name' => $product ? $product->name : 'Unknown Product', + 'code' => $product ? $product->code : '', + 'value' => (int)$item->total_value, + ]; + }); // 熱銷數量排行 (Top Selling by Quantity) - $topSellingByQuantity = \App\Modules\Sales\Models\SalesImportItem::with('product') - ->whereMonth('transaction_at', now()->month) - ->whereYear('transaction_at', now()->year) - ->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_quantity')) - ->groupBy('product_code', 'product_id') - ->orderByDesc('total_quantity') - ->limit(5) - ->get() - ->map(function ($item) { - return [ - 'name' => $item->product ? $item->product->name : $item->product_code, - 'code' => $item->product_code, - 'value' => (int)$item->total_quantity, - ]; - }); + $topSellingQtyItems = $this->salesService->getTopSellingByQuantity(); + $qtyProductIds = $topSellingQtyItems->pluck('product_id')->filter()->unique()->toArray(); + $qtyProductsMap = $this->inventoryService->getProductsByIds($qtyProductIds)->keyBy('id'); + + $topSellingByQuantity = $topSellingQtyItems->map(function ($item) use ($qtyProductsMap) { + $product = $qtyProductsMap->get($item->product_id); + return [ + 'name' => $product ? $product->name : $item->product_code, + 'code' => $item->product_code, + 'value' => (int)$item->total_quantity, + ]; + }); // 即將過期商品 (Expiring Soon) - $expiringSoon = \App\Modules\Inventory\Models\Inventory::with('product') - ->where('quantity', '>', 0) - ->whereNotNull('expiry_date') - ->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的 - ->orderBy('expiry_date', 'asc') - ->limit(5) - ->get() - ->map(function ($item) { - return [ - 'name' => $item->product ? $item->product->name : 'Unknown Product', - 'batch_number' => $item->batch_number, - 'expiry_date' => $item->expiry_date->format('Y-m-d'), - 'quantity' => (int)$item->quantity, - ]; - }); + $expiringItems = $this->inventoryService->getExpiringSoon(); + $expiringProductIds = $expiringItems->pluck('product_id')->filter()->unique()->toArray(); + $expiringProductsMap = $this->inventoryService->getProductsByIds($expiringProductIds)->keyBy('id'); + + $expiringSoon = $expiringItems->map(function ($item) use ($expiringProductsMap) { + $product = $expiringProductsMap->get($item->product_id); + return [ + 'name' => $product ? $product->name : 'Unknown Product', + 'batch_number' => $item->batch_number, + 'expiry_date' => $item->expiry_date->format('Y-m-d'), + 'quantity' => (int)$item->quantity, + ]; + }); return Inertia::render('Dashboard', [ 'stats' => [ diff --git a/app/Modules/Finance/Controllers/AccountPayableController.php b/app/Modules/Finance/Controllers/AccountPayableController.php index 0e0588c..2ab5916 100644 --- a/app/Modules/Finance/Controllers/AccountPayableController.php +++ b/app/Modules/Finance/Controllers/AccountPayableController.php @@ -7,23 +7,41 @@ use App\Modules\Finance\Models\AccountPayable; use Illuminate\Http\Request; use Inertia\Inertia; +use App\Modules\Procurement\Contracts\ProcurementServiceInterface; +use App\Modules\Inventory\Contracts\InventoryServiceInterface; + class AccountPayableController extends Controller { + protected $procurementService; + protected $inventoryService; + + public function __construct( + ProcurementServiceInterface $procurementService, + InventoryServiceInterface $inventoryService + ) { + $this->procurementService = $procurementService; + $this->inventoryService = $inventoryService; + } /** * Display a listing of the resource. */ public function index(Request $request) { - $query = AccountPayable::with(['vendor', 'creator']); + $query = AccountPayable::with(['creator']); // 關鍵字搜尋 (單號、供應商名稱) if ($request->filled('search')) { $search = $request->search; - $query->where(function ($q) use ($search) { - $q->where('document_number', 'like', "%{$search}%") - ->orWhereHas('vendor', function ($q) use ($search) { - $q->where('name', 'like', "%{$search}%"); - }); + + // 透過 ProcurementService 查詢符合關鍵字的 Vendor IDs + $matchedVendors = $this->procurementService->searchVendors($search); + $vendorIds = $matchedVendors->pluck('id')->toArray(); + + $query->where(function ($q) use ($search, $vendorIds) { + $q->where('document_number', 'like', "%{$search}%"); + if (!empty($vendorIds)) { + $q->orWhereIn('vendor_id', $vendorIds); + } }); } @@ -48,7 +66,16 @@ class AccountPayableController extends Controller $perPage = $request->input('per_page', 10); $payables = $query->latest()->paginate($perPage)->withQueryString(); - $vendors = \App\Modules\Procurement\Models\Vendor::select('id', 'name')->get(); + // Manual Hydration for Vendors + $allVendorIds = collect($payables->items())->pluck('vendor_id')->unique()->filter()->toArray(); + $vendorsMap = $this->procurementService->getVendorsByIds($allVendorIds)->keyBy('id'); + + $payables->getCollection()->transform(function ($item) use ($vendorsMap) { + $item->vendor = $vendorsMap->get($item->vendor_id); + return $item; + }); + + $vendors = $this->procurementService->getAllVendors(); return Inertia::render('AccountPayable/Index', [ 'payables' => $payables, @@ -62,14 +89,19 @@ class AccountPayableController extends Controller */ public function show(AccountPayable $accountPayable) { - $accountPayable->load(['vendor', 'creator']); + $accountPayable->load(['creator']); + + if ($accountPayable->vendor_id) { + $accountPayable->vendor = $this->procurementService->getVendorsByIds([$accountPayable->vendor_id])->first(); + } // 嘗試加載來源單據資訊 (目前支援 goods_receipt) $sourceDocumentCode = null; if ($accountPayable->source_document_type === 'goods_receipt') { - $receipt = \App\Modules\Inventory\Models\GoodsReceipt::find($accountPayable->source_document_id); - if ($receipt) { - $sourceDocumentCode = $receipt->code; + $receiptData = app(\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class) + ->getGoodsReceiptData($accountPayable->source_document_id); + if ($receiptData) { + $sourceDocumentCode = $receiptData['code'] ?? null; } } diff --git a/app/Modules/Finance/Models/AccountPayable.php b/app/Modules/Finance/Models/AccountPayable.php index 0658845..ea29713 100644 --- a/app/Modules/Finance/Models/AccountPayable.php +++ b/app/Modules/Finance/Models/AccountPayable.php @@ -41,14 +41,7 @@ class AccountPayable extends Model 'paid_at' => 'datetime', ]; - /** - * 關聯:供應商 - * @return BelongsTo - */ - public function vendor(): BelongsTo - { - return $this->belongsTo(\App\Modules\Procurement\Models\Vendor::class, 'vendor_id'); - } + // vendor 關聯移至 service (跨模組) /** * 關聯:建立者 diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php index ee4590b..6f45a46 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -40,6 +40,14 @@ interface InventoryServiceInterface */ public function getProductsByIds(array $ids); + /** + * Get multiple warehouses by their codes. + * + * @param array $codes + * @return \Illuminate\Support\Collection + */ + public function getWarehousesByCodes(array $codes); + /** * Search products by name. * @@ -139,4 +147,14 @@ interface InventoryServiceInterface * @return object */ public function findOrCreateWarehouseByName(string $warehouseName); + + /** + * Get top inventory value for dashboard. + */ + public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection; + + /** + * Get items expiring soon for dashboard. + */ + public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection; } \ No newline at end of file diff --git a/app/Modules/Inventory/Controllers/GoodsReceiptController.php b/app/Modules/Inventory/Controllers/GoodsReceiptController.php index 49e8c38..22325cb 100644 --- a/app/Modules/Inventory/Controllers/GoodsReceiptController.php +++ b/app/Modules/Inventory/Controllers/GoodsReceiptController.php @@ -7,11 +7,10 @@ use App\Modules\Inventory\Services\GoodsReceiptService; use App\Modules\Inventory\Services\InventoryService; use App\Modules\Procurement\Contracts\ProcurementServiceInterface; use Illuminate\Http\Request; -use App\Modules\Procurement\Models\Vendor; -use App\Modules\Inventory\Services\DuplicateCheckService; use Inertia\Inertia; use App\Modules\Inventory\Models\GoodsReceipt; use Illuminate\Support\Facades\DB; +use App\Modules\Inventory\Services\DuplicateCheckService; class GoodsReceiptController extends Controller { diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 40420e2..bf8b1fa 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -16,6 +16,26 @@ class InventoryService implements InventoryServiceInterface return Warehouse::all(); } + public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection + { + return Inventory::select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value')) + ->where('quantity', '>', 0) + ->groupBy('product_id') + ->orderByDesc('total_value') + ->limit($limit) + ->get(); + } + + public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection + { + return Inventory::where('quantity', '>', 0) + ->whereNotNull('expiry_date') + ->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的 + ->orderBy('expiry_date', 'asc') + ->limit($limit) + ->get(); + } + public function getAllProducts() { return Product::with(['baseUnit', 'largeUnit'])->get(); @@ -41,6 +61,11 @@ class InventoryService implements InventoryServiceInterface return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get(); } + public function getWarehousesByCodes(array $codes) + { + return Warehouse::whereIn('code', $codes)->get(); + } + public function getProductsByName(string $name) { return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get(); diff --git a/app/Modules/Production/Contracts/ProductionServiceInterface.php b/app/Modules/Production/Contracts/ProductionServiceInterface.php new file mode 100644 index 0000000..4e21c25 --- /dev/null +++ b/app/Modules/Production/Contracts/ProductionServiceInterface.php @@ -0,0 +1,8 @@ +belongsTo(Recipe::class); } - public function product() - { - return $this->belongsTo(\App\Modules\Inventory\Models\Product::class); - } - - public function unit() - { - return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class); - } + // product 和 unit 關聯移至 service (跨模組) } diff --git a/app/Modules/Production/ProductionServiceProvider.php b/app/Modules/Production/ProductionServiceProvider.php index e8a854d..a604fb0 100644 --- a/app/Modules/Production/ProductionServiceProvider.php +++ b/app/Modules/Production/ProductionServiceProvider.php @@ -10,7 +10,10 @@ class ProductionServiceProvider extends ServiceProvider { public function register(): void { - // + $this->app->bind( + \App\Modules\Production\Contracts\ProductionServiceInterface::class, + \App\Modules\Production\Services\ProductionService::class + ); } public function boot(): void diff --git a/app/Modules/Production/Services/ProductionService.php b/app/Modules/Production/Services/ProductionService.php new file mode 100644 index 0000000..164338e --- /dev/null +++ b/app/Modules/Production/Services/ProductionService.php @@ -0,0 +1,14 @@ +count(); + } +} diff --git a/app/Modules/Sales/Contracts/SalesServiceInterface.php b/app/Modules/Sales/Contracts/SalesServiceInterface.php new file mode 100644 index 0000000..09424dd --- /dev/null +++ b/app/Modules/Sales/Contracts/SalesServiceInterface.php @@ -0,0 +1,11 @@ +load(['items', 'importer']); $perPage = $request->input('per_page', 10); + $paginatedItems = $import->items()->paginate($perPage)->withQueryString(); + // Manual Hydration for Products and Warehouses + $inventoryService = app(InventoryServiceInterface::class); + $productIds = collect($paginatedItems->items())->pluck('product_id')->filter()->unique()->toArray(); + $machineCodes = collect($paginatedItems->items())->pluck('machine_id')->filter()->unique()->toArray(); + + $products = $inventoryService->getProductsByIds($productIds)->keyBy('id'); + $warehouses = $inventoryService->getWarehousesByCodes($machineCodes)->keyBy('code'); + + $paginatedItems->getCollection()->transform(function ($item) use ($products, $warehouses) { + $item->product = $products->get($item->product_id); + $item->warehouse = $warehouses->get($item->machine_id); + return $item; + }); + return Inertia::render('Sales/Import/Show', [ 'import' => $import, - 'items' => $import->items()->with(['product', 'warehouse'])->paginate($perPage)->withQueryString(), + 'items' => $paginatedItems, 'filters' => [ 'per_page' => $perPage, ], ]); } - public function confirm(SalesImportBatch $import, InventoryService $inventoryService) + public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService) { if ($import->status !== 'pending') { return back()->with('error', '此批次無法確認。'); @@ -87,8 +102,8 @@ class SalesImportController extends Controller $aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot" // Pre-load necessary warehouses for matching - $machineIds = $import->items->pluck('machine_id')->filter()->unique(); - $warehouses = \App\Modules\Inventory\Models\Warehouse::whereIn('code', $machineIds)->get()->keyBy('code'); + $machineIds = $import->items->pluck('machine_id')->filter()->unique()->toArray(); + $warehouses = $inventoryService->getWarehousesByCodes($machineIds)->keyBy('code'); foreach ($import->items as $item) { // Only process shipped items with a valid product diff --git a/app/Modules/Sales/Imports/SalesImportSheet.php b/app/Modules/Sales/Imports/SalesImportSheet.php index 2102117..7484122 100644 --- a/app/Modules/Sales/Imports/SalesImportSheet.php +++ b/app/Modules/Sales/Imports/SalesImportSheet.php @@ -4,7 +4,7 @@ namespace App\Modules\Sales\Imports; use App\Modules\Sales\Models\SalesImportBatch; use App\Modules\Sales\Models\SalesImportItem; -use App\Modules\Inventory\Models\Product; +use App\Modules\Inventory\Contracts\InventoryServiceInterface; use Illuminate\Support\Collection; use Maatwebsite\Excel\Concerns\ToCollection; use Maatwebsite\Excel\Concerns\WithStartRow; @@ -19,7 +19,8 @@ class SalesImportSheet implements ToCollection, WithStartRow { $this->batch = $batch; // Pre-load all products to minimize queries (keyed by code) - $this->products = Product::pluck('id', 'code'); // assumes code is unique + $inventoryService = app(InventoryServiceInterface::class); + $this->products = $inventoryService->getAllProducts()->pluck('id', 'code')->toArray(); // assumes code is unique } public function startRow(): int diff --git a/app/Modules/Sales/Models/SalesImportItem.php b/app/Modules/Sales/Models/SalesImportItem.php index da08811..9c78e6a 100644 --- a/app/Modules/Sales/Models/SalesImportItem.php +++ b/app/Modules/Sales/Models/SalesImportItem.php @@ -2,8 +2,7 @@ namespace App\Modules\Sales\Models; -use App\Modules\Inventory\Models\Product; -use App\Modules\Inventory\Models\Warehouse; + use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -38,14 +37,4 @@ class SalesImportItem extends Model { return $this->belongsTo(SalesImportBatch::class, 'batch_id'); } - - public function product(): BelongsTo - { - return $this->belongsTo(Product::class, 'product_id'); - } - - public function warehouse(): BelongsTo - { - return $this->belongsTo(Warehouse::class, 'machine_id', 'code'); - } } diff --git a/app/Modules/Sales/SalesServiceProvider.php b/app/Modules/Sales/SalesServiceProvider.php new file mode 100644 index 0000000..9568b48 --- /dev/null +++ b/app/Modules/Sales/SalesServiceProvider.php @@ -0,0 +1,15 @@ +app->bind(SalesServiceInterface::class, SalesService::class); + } +} diff --git a/app/Modules/Sales/Services/SalesService.php b/app/Modules/Sales/Services/SalesService.php new file mode 100644 index 0000000..26a6565 --- /dev/null +++ b/app/Modules/Sales/Services/SalesService.php @@ -0,0 +1,63 @@ +month) + ->whereYear('transaction_at', now()->year) + ->sum('amount'); + } + + public function getSalesTrend(int $days = 30): array + { + $startDate = now()->subDays($days - 1)->startOfDay(); + $salesData = SalesImportItem::where('transaction_at', '>=', $startDate) + ->selectRaw('DATE(transaction_at) as date, SUM(amount) as total') + ->groupBy('date') + ->orderBy('date') + ->get() + ->mapWithKeys(function ($item) { + return [$item->date => (int)$item->total]; + }); + + $salesTrend = []; + for ($i = 0; $i < $days; $i++) { + $date = $startDate->copy()->addDays($i)->format('Y-m-d'); + $salesTrend[] = [ + 'date' => $startDate->copy()->addDays($i)->format('m/d'), + 'amount' => $salesData[$date] ?? 0, + ]; + } + + return $salesTrend; + } + + public function getTopSellingProducts(int $limit = 5): \Illuminate\Support\Collection + { + return SalesImportItem::whereMonth('transaction_at', now()->month) + ->whereYear('transaction_at', now()->year) + ->select('product_code', 'product_id', DB::raw('SUM(amount) as total_amount')) + ->groupBy('product_code', 'product_id') + ->orderByDesc('total_amount') + ->limit($limit) + ->get(); + } + + public function getTopSellingByQuantity(int $limit = 5): \Illuminate\Support\Collection + { + return SalesImportItem::whereMonth('transaction_at', now()->month) + ->whereYear('transaction_at', now()->year) + ->select('product_code', 'product_id', DB::raw('SUM(quantity) as total_quantity')) + ->groupBy('product_code', 'product_id') + ->orderByDesc('total_quantity') + ->limit($limit) + ->get(); + } +}