feat(Inventory): 實作批號溯源完整功能與 UI 呈現,包含文字敘述卡片與更完整的關聯屬性
This commit is contained in:
@@ -7,506 +7,93 @@ name: 客戶端後台 UI 統一規範
|
|||||||
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
|
description: 確保 Star ERP 客戶端(租戶端)後台所有頁面的 UI 元件保持統一的樣式與行為
|
||||||
---
|
---
|
||||||
|
|
||||||
## 概述
|
## 適用範圍
|
||||||
|
|
||||||
本技能提供 Star ERP 系統**客戶端(租戶端)後台**的 UI 統一性規範,確保所有頁面使用一致的元件、樣式類別、圖標和佈局模式。
|
本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
|
||||||
|
|
||||||
> **適用範圍**:本規範適用於租戶端後台(使用 `AuthenticatedLayout` 的頁面),**不適用於**中央管理後台(`LandlordLayout`)。
|
## 核心禁止事項
|
||||||
|
|
||||||
## 核心原則
|
- ❌ **禁止 Hardcode 色碼**(如 `text-[#01ab83]`),必須使用 `*-primary-main` 等 Tailwind Class 或 CSS 變數
|
||||||
|
- ❌ **禁止使用非 `lucide-react` 的圖標庫**(如 FontAwesome、Material Icons)
|
||||||
1. **使用統一的 UI 組件庫**:優先使用 `@/Components/ui/` 中的 47 個元件
|
- ❌ **禁止操作按鈕不包裹 `<Can>` 權限元件**
|
||||||
2. **遵循既定的樣式類別**:使用 `app.css` 中定義的自定義按鈕類別
|
|
||||||
3. **統一的圖標系統**:全面使用 `lucide-react` 圖標
|
|
||||||
4. **一致的佈局模式**:表格、分頁、操作按鈕等保持相同結構
|
|
||||||
5. **權限控制**:所有操作按鈕必須使用 `<Can>` 元件包裹
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 專案結構
|
## 1. 色彩系統
|
||||||
|
|
||||||
### 1.1 關鍵目錄
|
### 主題色(動態租戶品牌色,由 `AuthenticatedLayout` 自動注入)
|
||||||
|
|
||||||
```
|
| Tailwind Class | 用途 |
|
||||||
resources/
|
|---|---|
|
||||||
├── css/
|
| `*-primary-main` | 主色:按鈕、連結、強調 |
|
||||||
│ └── app.css # 全域樣式與設計 Token
|
| `*-primary-dark` | Hover 狀態 |
|
||||||
├── js/
|
| `*-primary-light` | 次要強調 |
|
||||||
│ ├── Components/
|
| `*-primary-lightest` | 背景底色、Active 狀態 |
|
||||||
│ │ ├── ui/ # 47 個基礎 UI 元件 (shadcn/ui)
|
|
||||||
│ │ ├── shared/ # 共用業務元件 (Pagination, BreadcrumbNav 等)
|
|
||||||
│ │ └── Permission/ # 權限控制元件 (Can, HasRole, CanAll)
|
|
||||||
│ ├── Layouts/
|
|
||||||
│ │ ├── AuthenticatedLayout.tsx # 客戶端後台佈局 ⬅️ 本規範適用
|
|
||||||
│ │ └── LandlordLayout.tsx # 中央管理後台佈局
|
|
||||||
│ └── Pages/ # 頁面元件
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 可用 UI 元件清單
|
### 灰階與狀態色
|
||||||
|
|
||||||
```
|
直接參考 `resources/css/app.css` 中定義的 `--grey-0` ~ `--grey-5` 與 `--other-success/error/warning/info` 變數。
|
||||||
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. 按鈕規範
|
||||||
|
|
||||||
### 2.1 主題色 (Primary) - **動態租戶品牌色**
|
樣式定義於 `resources/css/app.css`,按鈕必須使用以下類別:
|
||||||
|
|
||||||
> **注意**:主題色會根據租戶設定(Branding)動態改變,**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。
|
| 類型 | 類別 | 用途 |
|
||||||
> 請務必使用 Tailwind Utility Class 或 CSS 變數。
|
|---|---|---|
|
||||||
|
| Filled | `button-filled-primary` | 主要操作(新增、儲存) |
|
||||||
|
| Filled | `button-filled-success/info/warning/error` | 各狀態操作 |
|
||||||
|
| Outlined | `button-outlined-primary` | 次要操作(編輯、檢視) |
|
||||||
|
| Outlined | `button-outlined-error` | 刪除按鈕 |
|
||||||
|
| Text | `button-text-primary` | 文字連結式按鈕 |
|
||||||
|
|
||||||
| Tailwind Class | CSS Variable | 說明 |
|
**尺寸**:表格操作列用 `size="sm"`,一般操作用 `size="default"`,主要 CTA 用 `size="lg"`。
|
||||||
|----------------|--------------|------|
|
|
||||||
| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 |
|
|
||||||
| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 |
|
|
||||||
| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 |
|
|
||||||
| `*-primary-lightest` | `--primary-lightest` | **最淺色**:系統自動計算,用於背景底色、Active 狀態 |
|
|
||||||
|
|
||||||
**運作機制**:
|
**返回按鈕**:放置於標題上方,使用 `variant="outline"` + `className="gap-2 button-outlined-primary"`,搭配 `<ArrowLeft />` 圖標。
|
||||||
`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ✅ 正確:使用 Tailwind Class
|
|
||||||
<div className="text-primary-main">...</div>
|
|
||||||
|
|
||||||
// ✅ 正確:使用 CSS 變數 (自定義樣式時)
|
|
||||||
<div style={{ borderColor: 'var(--primary-main)' }}>...</div>
|
|
||||||
|
|
||||||
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
|
|
||||||
<div className="text-[#01ab83]">...</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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. 圖標規範
|
||||||
|
|
||||||
### 3.1 按鈕樣式類別
|
統一使用 `lucide-react`。
|
||||||
|
|
||||||
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別:
|
| 尺寸 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `h-4 w-4` | 按鈕內、表格操作 |
|
||||||
|
| `h-5 w-5` | 側邊欄選單 |
|
||||||
|
| `h-6 w-6` | 頁面標題 |
|
||||||
|
|
||||||
#### Filled 按鈕(實心按鈕)— 用於主要操作
|
常用映射:`Plus`(新增)、`Pencil`(編輯)、`Trash2`(刪除)、`Eye`(檢視)、`Search`(搜尋)、`ArrowLeft`(返回)。
|
||||||
|
其餘請參考 `AuthenticatedLayout.tsx` 中的 `allMenuItems` 定義。
|
||||||
```tsx
|
|
||||||
// ✅ 主要操作按鈕(綠色主題色)- 新增、儲存、確認
|
|
||||||
<Button className="button-filled-primary">
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
新增項目
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
// ✅ 成功操作
|
|
||||||
<Button className="button-filled-success">確認</Button>
|
|
||||||
|
|
||||||
// ✅ 資訊操作(用於系統提示、說明等非業務主流程)
|
|
||||||
<Button className="button-filled-info">系統資訊</Button>
|
|
||||||
|
|
||||||
// ✅ 警告操作
|
|
||||||
<Button className="button-filled-warning">警告</Button>
|
|
||||||
|
|
||||||
// ✅ 錯誤/刪除操作(AlertDialog 內確認按鈕)
|
|
||||||
<Button className="button-filled-error">刪除</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Outlined 按鈕(邊框按鈕)— 用於次要操作
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// ✅ 編輯按鈕(表格操作列)
|
|
||||||
<Button variant="outline" size="sm" className="button-outlined-primary">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
// ✅ 刪除按鈕(表格操作列)
|
|
||||||
<Button variant="outline" size="sm" className="button-outlined-error">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Text 按鈕(文字按鈕)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Button className="button-text-primary">查看更多</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 按鈕大小
|
|
||||||
|
|
||||||
| Size | 高度 | 使用情境 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `size="sm"` | h-8 | 表格操作列、緊湊佈局 |
|
|
||||||
| `size="default"` | h-9 | 一般操作、表單提交 |
|
|
||||||
| `size="lg"` | h-10 | 主要 CTA、頁面主操作 |
|
|
||||||
| `size="icon"` | 9×9 | 純圖標按鈕 |
|
|
||||||
|
|
||||||
### 3.3 常見操作按鈕模式
|
|
||||||
|
|
||||||
#### 頁面頂部新增按鈕
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Can permission="resource.create">
|
|
||||||
<Link href={route('resource.create')}>
|
|
||||||
<Button className="button-filled-primary">
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
新增XXX
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</Can>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 表格操作列檢視按鈕
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Can permission="resource.view">
|
|
||||||
<Link href={route('resource.show', item.id)}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="button-outlined-primary"
|
|
||||||
title="檢視"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</Can>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 表格操作列編輯按鈕
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Can permission="resource.edit">
|
|
||||||
<Link href={route('resource.edit', item.id)}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="button-outlined-primary"
|
|
||||||
title="編輯"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</Can>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 表格操作列刪除按鈕(帶確認對話框)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Can permission="resource.delete">
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="button-outlined-error"
|
|
||||||
title="刪除"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>確認刪除</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
確定要刪除「{item.name}」嗎?此操作無法復原。
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => handleDelete(item.id)}
|
|
||||||
className="bg-red-600 hover:bg-red-700"
|
|
||||||
>
|
|
||||||
刪除
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</Can>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 返回按鈕規範
|
|
||||||
|
|
||||||
詳情頁面(如:查看庫存、進貨單詳情)的返回按鈕應統一放置於 **頁面標題上方**,並採用「**圖標 + 文字**」的 Outlined 樣式。
|
|
||||||
|
|
||||||
**樣式規格**:
|
|
||||||
- **位置**:標題區域上方 (`mb-6`),獨立於標題列
|
|
||||||
- **樣式**:`variant="outline"` + `className="gap-2 button-outlined-primary"`
|
|
||||||
- **圖標**:`<ArrowLeft className="h-4 w-4" />`
|
|
||||||
- **文字**:清楚說明返回目的地,例如「返回倉庫管理」、「返回列表」
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="mb-6">
|
|
||||||
<Link href={route('resource.index')}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2 button-outlined-primary"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
返回列表
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3.5 頁面佈局規範(新增/編輯頁面)
|
## 4. 頁面佈局規範
|
||||||
|
|
||||||
### 標準結構
|
所有頁面遵循以下結構,參考範例:`Pages/Product/Create.tsx`、`Pages/PurchaseOrder/Create.tsx`。
|
||||||
|
|
||||||
新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構:
|
**關鍵規則**:
|
||||||
|
- **外層容器**:`className="container mx-auto p-6 max-w-7xl"`
|
||||||
```tsx
|
- **標題樣式**:`text-2xl font-bold text-grey-0 flex items-center gap-2`
|
||||||
<AuthenticatedLayout breadcrumbs={...}>
|
- **說明文字**:`text-gray-500 mt-1`
|
||||||
<Head title="..." />
|
- **麵包屑**:使用 `BreadcrumbItemType`(屬性為 `label`, `href`, `isPage`),不需要包含「首頁」
|
||||||
|
- **Input 元件**:不額外設定 focus 樣式,直接使用 `@/Components/ui/input` 的內建樣式
|
||||||
<div className="container mx-auto p-6 max-w-7xl">
|
- **日期顯示**:使用 `resources/js/lib/date.ts` 的 `formatDate` 工具
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
{/* 返回按鈕 */}
|
|
||||||
<Link href={route('resource.index')}>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2 button-outlined-primary mb-4"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
返回列表
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* 頁面標題區塊 */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
|
||||||
<Icon className="h-6 w-6 text-primary-main" />
|
|
||||||
頁面標題
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
頁面說明文字
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 表單或內容區塊 */}
|
|
||||||
<FormComponent ... />
|
|
||||||
</div>
|
|
||||||
</AuthenticatedLayout>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 關鍵規範
|
|
||||||
|
|
||||||
1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致
|
|
||||||
2. **Header 包裹**:使用 `<div className="mb-6">` 包裹返回按鈕與標題區塊
|
|
||||||
3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔
|
|
||||||
4. **標題區塊**:使用 `<div className="mb-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 常用操作圖標映射
|
|
||||||
|
|
||||||
| 操作 | 圖標組件 | 使用情境 |
|
|
||||||
|------|----------|----------|
|
|
||||||
| 新增 | `<Plus />` | 新增按鈕 |
|
|
||||||
| 編輯 | `<Pencil />` | 編輯按鈕 |
|
|
||||||
| 刪除 | `<Trash2 />` | 刪除按鈕 |
|
|
||||||
| 查看 | `<Eye />` | 查看詳情 |
|
|
||||||
| 搜尋 | `<Search />` | 搜尋欄位 |
|
|
||||||
| 篩選 | `<Filter />` | 篩選功能 |
|
|
||||||
| 下載 | `<Download />` | 下載/匯出 |
|
|
||||||
| 上傳 | `<Upload />` | 上傳/匯入 |
|
|
||||||
| 設定 | `<Settings />` | 設定功能 |
|
|
||||||
| 複製 | `<Copy />` | 複製內容 |
|
|
||||||
| 郵件 | `<Mail />` | Email 顯示 |
|
|
||||||
| 使用者 | `<Users />`, `<User />` | 使用者管理 |
|
|
||||||
| 權限 | `<Shield />` | 角色/權限 |
|
|
||||||
| 排序 | `<ArrowUpDown />`, `<ArrowUp />`, `<ArrowDown />` | 表格排序 |
|
|
||||||
| 儀表板 | `<LayoutDashboard />` | 首頁/總覽 |
|
|
||||||
| 商品 | `<Package />` | 商品管理 |
|
|
||||||
| 倉庫 | `<Warehouse />` | 倉庫管理 |
|
|
||||||
| 廠商 | `<Truck />`, `<Contact2 />` | 廠商管理 |
|
|
||||||
| 採購 | `<ShoppingCart />` | 採購管理 |
|
|
||||||
|
|
||||||
### 4.4 圖標使用範例
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Plus, Pencil, Trash2, Users } from 'lucide-react';
|
|
||||||
|
|
||||||
// 頁面標題
|
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
|
||||||
<Users className="h-6 w-6 text-[#01ab83]" />
|
|
||||||
使用者管理
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
// 按鈕內圖標(圖標在左,帶文字)
|
|
||||||
<Button className="button-filled-primary">
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
新增使用者
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
// 純圖標按鈕(表格操作列)
|
|
||||||
<Button variant="outline" size="sm" className="button-outlined-primary">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 表格規範
|
## 5. 表格規範
|
||||||
|
|
||||||
### 5.1 表格容器
|
**容器**:`bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden`
|
||||||
|
**標題列**:`bg-gray-50`,序號欄 `w-[50px] text-center`,操作欄置中
|
||||||
|
**空狀態**:`text-center py-8 text-gray-500`,顯示「無符合條件的資料」
|
||||||
|
**操作欄**:`flex items-center justify-center gap-2`
|
||||||
|
|
||||||
```tsx
|
### 排序(三態切換)
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
{/* 表格內容 */}
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 表格標題列
|
- 未排序:`ArrowUpDown`(`text-muted-foreground`)
|
||||||
|
- 升冪:`ArrowUp`(`text-primary`)
|
||||||
```tsx
|
- 降冪:`ArrowDown`(`text-primary`)
|
||||||
<TableHeader className="bg-gray-50">
|
- 後端必須處理 `sort_by` 與 `sort_order` 參數
|
||||||
<TableRow>
|
- 參考實作:`Pages/Product/Index.tsx` 的 `handleSort`
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
|
||||||
<TableHead>名稱</TableHead>
|
|
||||||
<TableHead className="text-center">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
```
|
|
||||||
|
|
||||||
**關鍵要點**:
|
|
||||||
- 使用 `bg-gray-50` 背景色
|
|
||||||
- 序號欄位固定寬度 `w-[50px]` 並置中
|
|
||||||
- 操作欄位置中顯示
|
|
||||||
|
|
||||||
### 5.3 表格主體
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<TableBody>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="text-center py-8 text-gray-500">
|
|
||||||
無符合條件的資料
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
items.map((item, index) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="text-gray-500 font-medium text-center">
|
|
||||||
{startIndex + index}
|
|
||||||
</TableCell>
|
|
||||||
{/* 其他欄位 */}
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
{/* 操作按鈕 */}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
```
|
|
||||||
|
|
||||||
**關鍵要點**:
|
|
||||||
- 空狀態訊息使用置中、灰色文字
|
|
||||||
- 序號欄使用 `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 <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
|
|
||||||
}
|
|
||||||
if (filters.sort_order === "asc") {
|
|
||||||
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
|
|
||||||
}
|
|
||||||
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. 表格標題應用
|
|
||||||
<TableHead>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSort('created_at')}
|
|
||||||
className="flex items-center hover:text-gray-900"
|
|
||||||
>
|
|
||||||
建立時間 <SortIcon field="created_at" />
|
|
||||||
</button>
|
|
||||||
</TableHead>
|
|
||||||
|
|
||||||
// 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
|
|
||||||
@@ -185,6 +185,7 @@ class RoleController extends Controller
|
|||||||
'inventory_adjust' => '庫存盤調管理',
|
'inventory_adjust' => '庫存盤調管理',
|
||||||
'inventory_transfer' => '庫存調撥管理',
|
'inventory_transfer' => '庫存調撥管理',
|
||||||
'inventory_report' => '庫存報表',
|
'inventory_report' => '庫存報表',
|
||||||
|
'inventory_traceability' => '批號溯源',
|
||||||
'vendors' => '廠商資料管理',
|
'vendors' => '廠商資料管理',
|
||||||
'purchase_orders' => '採購單管理',
|
'purchase_orders' => '採購單管理',
|
||||||
'purchase_returns' => '採購退回管理',
|
'purchase_returns' => '採購退回管理',
|
||||||
|
|||||||
42
app/Modules/Inventory/Controllers/TraceabilityController.php
Normal file
42
app/Modules/Inventory/Controllers/TraceabilityController.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use App\Modules\Inventory\Services\TraceabilityService;
|
||||||
|
|
||||||
|
class TraceabilityController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected TraceabilityService $traceabilityService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示批號溯源查詢的主頁面
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$batchNumber = $request->input('batch_number');
|
||||||
|
$direction = $request->input('direction', 'backward'); // backward 或 forward
|
||||||
|
|
||||||
|
$result = null;
|
||||||
|
|
||||||
|
if ($batchNumber) {
|
||||||
|
if ($direction === 'backward') {
|
||||||
|
$result = $this->traceabilityService->traceBackward($batchNumber);
|
||||||
|
} else {
|
||||||
|
$result = $this->traceabilityService->traceForward($batchNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Inventory/Traceability/Index', [
|
||||||
|
'search' => [
|
||||||
|
'batch_number' => $batchNumber,
|
||||||
|
'direction' => $direction,
|
||||||
|
],
|
||||||
|
'result' => $result
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index');
|
Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 批號溯源 (Lot Traceability)
|
||||||
|
Route::middleware('permission:inventory_traceability.view')->group(function () {
|
||||||
|
Route::get('/inventory/traceability', [\App\Modules\Inventory\Controllers\TraceabilityController::class, 'index'])->name('inventory.traceability.index');
|
||||||
|
});
|
||||||
|
|
||||||
// 類別管理 (用於商品對話框) - 需要商品權限
|
// 類別管理 (用於商品對話框) - 需要商品權限
|
||||||
Route::middleware('permission:products.view')->group(function () {
|
Route::middleware('permission:products.view')->group(function () {
|
||||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||||
|
|||||||
@@ -122,8 +122,13 @@ class StoreRequisitionService
|
|||||||
$processedItems = []; // 暫存處理後的明細,用於轉入調撥單
|
$processedItems = []; // 暫存處理後的明細,用於轉入調撥單
|
||||||
|
|
||||||
if (isset($data['items'])) {
|
if (isset($data['items'])) {
|
||||||
|
$requisition->load('items.product');
|
||||||
|
$reqItemMap = $requisition->items->keyBy('id');
|
||||||
|
|
||||||
foreach ($data['items'] as $itemData) {
|
foreach ($data['items'] as $itemData) {
|
||||||
$reqItemId = $itemData['id'];
|
$reqItemId = $itemData['id'];
|
||||||
|
$reqItem = $reqItemMap->get($reqItemId);
|
||||||
|
$productName = $reqItem?->product?->name ?? '未知商品';
|
||||||
$totalApprovedQty = 0;
|
$totalApprovedQty = 0;
|
||||||
$batches = $itemData['batches'] ?? [];
|
$batches = $itemData['batches'] ?? [];
|
||||||
|
|
||||||
@@ -133,7 +138,22 @@ class StoreRequisitionService
|
|||||||
foreach ($batches as $batch) {
|
foreach ($batches as $batch) {
|
||||||
$qty = (float)($batch['qty'] ?? 0);
|
$qty = (float)($batch['qty'] ?? 0);
|
||||||
$bNum = $batch['batch_number'] ?? null;
|
$bNum = $batch['batch_number'] ?? null;
|
||||||
|
$invId = $batch['inventory_id'] ?? null;
|
||||||
|
|
||||||
if ($qty > 0) {
|
if ($qty > 0) {
|
||||||
|
if ($invId) {
|
||||||
|
$inventory = \App\Modules\Inventory\Models\Inventory::lockForUpdate()->find($invId);
|
||||||
|
if ($inventory) {
|
||||||
|
$available = max(0, $inventory->quantity - $inventory->reserved_quantity);
|
||||||
|
if ($qty > $available) {
|
||||||
|
$batchStr = $bNum ? "批號 {$bNum}" : "無批號";
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => "「{$productName}」的 {$batchStr} 數量({$qty})不可大於可用庫存({$available})",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$totalApprovedQty += $qty;
|
$totalApprovedQty += $qty;
|
||||||
$batchKey = $bNum ?? '';
|
$batchKey = $bNum ?? '';
|
||||||
$batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty;
|
$batchGroups[$batchKey] = ($batchGroups[$batchKey] ?? 0) + $qty;
|
||||||
@@ -151,6 +171,18 @@ class StoreRequisitionService
|
|||||||
// 無批號,傳統輸入
|
// 無批號,傳統輸入
|
||||||
$qty = (float)($itemData['approved_qty'] ?? 0);
|
$qty = (float)($itemData['approved_qty'] ?? 0);
|
||||||
if ($qty > 0) {
|
if ($qty > 0) {
|
||||||
|
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||||||
|
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
||||||
|
->where('product_id', $reqItem->product_id)
|
||||||
|
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
||||||
|
->value('available') ?? 0;
|
||||||
|
|
||||||
|
if ($qty > $totalAvailable) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => "「{$productName}」的數量({$qty})不可大於供貨倉可用總庫存({$totalAvailable})",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$totalApprovedQty += $qty;
|
$totalApprovedQty += $qty;
|
||||||
$processedItems[] = [
|
$processedItems[] = [
|
||||||
'req_item_id' => $reqItemId,
|
'req_item_id' => $reqItemId,
|
||||||
|
|||||||
326
app/Modules/Inventory/Services/TraceabilityService.php
Normal file
326
app/Modules/Inventory/Services/TraceabilityService.php
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Services;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Models\Inventory;
|
||||||
|
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||||
|
use App\Modules\Inventory\Models\GoodsReceiptItem;
|
||||||
|
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||||
|
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||||
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class TraceabilityService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected ProductionServiceInterface $productionService,
|
||||||
|
protected ProcurementServiceInterface $procurementService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逆向溯源:從成品批號往前追溯用到的所有原料與廠商
|
||||||
|
*
|
||||||
|
* @param string $batchNumber 成品批號
|
||||||
|
* @return array 樹狀結構資料
|
||||||
|
*/
|
||||||
|
public function traceBackward(string $batchNumber): array
|
||||||
|
{
|
||||||
|
// 取得基本庫存資訊以作為根節點參考
|
||||||
|
$baseInventory = Inventory::with(['product', 'warehouse'])
|
||||||
|
->where('batch_number', $batchNumber)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// 定義根節點
|
||||||
|
$rootNode = [
|
||||||
|
'id' => 'batch_' . $batchNumber,
|
||||||
|
'type' => 'target_batch',
|
||||||
|
'label' => '查詢批號: ' . $batchNumber,
|
||||||
|
'batch_number' => $batchNumber,
|
||||||
|
'product_name' => $baseInventory?->product?->name,
|
||||||
|
'spec' => $baseInventory?->product?->spec,
|
||||||
|
'warehouse_name' => $baseInventory?->warehouse?->name,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1. 尋找這個批號是不是生產出來的成品 (Production Order Output)
|
||||||
|
// 透過 ProductionService 獲取,以落實模組解耦
|
||||||
|
$productionOrders = $this->productionService->getProductionOrdersByOutputBatch($batchNumber);
|
||||||
|
|
||||||
|
foreach ($productionOrders as $po) {
|
||||||
|
$poNode = [
|
||||||
|
'id' => 'po_' . $po->id,
|
||||||
|
'type' => 'production_order',
|
||||||
|
'label' => '生產工單: ' . $po->code,
|
||||||
|
'date' => $po->production_date instanceof \DateTimeInterface
|
||||||
|
? $po->production_date->format('Y-m-d')
|
||||||
|
: $po->production_date,
|
||||||
|
'quantity' => $po->output_quantity,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// 針對每一張工單,尋找它投料的原料批號
|
||||||
|
foreach ($po->items as $item) {
|
||||||
|
if (isset($item->inventory)) {
|
||||||
|
$materialNode = $this->buildMaterialBackwardNode($item->inventory, $item);
|
||||||
|
$poNode['children'][] = $materialNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rootNode['children'][] = $poNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果這批號是直接採購進來的 (Goods Receipt)
|
||||||
|
// 或者是為了補足直接查詢原料批號的場景
|
||||||
|
$inventories = Inventory::with(['product', 'warehouse'])
|
||||||
|
->where('batch_number', $batchNumber)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($inventories as $inv) {
|
||||||
|
// 尋找進貨單
|
||||||
|
$grItems = GoodsReceiptItem::with(['goodsReceipt', 'product'])
|
||||||
|
->where('batch_number', $batchNumber)
|
||||||
|
->where('product_id', $inv->product_id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($grItems as $grItem) {
|
||||||
|
$gr = $grItem->goodsReceipt;
|
||||||
|
if ($gr) {
|
||||||
|
$grNode = [
|
||||||
|
'id' => 'gr_' . $gr->id . '_' . $inv->id,
|
||||||
|
'type' => 'goods_receipt',
|
||||||
|
'label' => '進貨單: ' . $gr->code,
|
||||||
|
'date' => $gr->received_date instanceof \DateTimeInterface
|
||||||
|
? $gr->received_date->format('Y-m-d')
|
||||||
|
: $gr->received_date,
|
||||||
|
'vendor_id' => $gr->vendor_id,
|
||||||
|
'quantity' => $grItem->quantity,
|
||||||
|
'product_name' => $grItem->product?->name,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// 避免重複加入
|
||||||
|
$isDuplicate = false;
|
||||||
|
foreach ($rootNode['children'] as $child) {
|
||||||
|
if ($child['id'] === $grNode['id']) {
|
||||||
|
$isDuplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$isDuplicate) {
|
||||||
|
$rootNode['children'][] = $grNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 補充廠商名稱 (跨模組)
|
||||||
|
$this->hydrateVendorNames($rootNode);
|
||||||
|
|
||||||
|
return $rootNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立原料的逆向溯源節點
|
||||||
|
*/
|
||||||
|
private function buildMaterialBackwardNode(Inventory $inventory, $poItem = null): array
|
||||||
|
{
|
||||||
|
$node = [
|
||||||
|
'id' => 'inv_' . $inventory->id,
|
||||||
|
'type' => 'material_batch',
|
||||||
|
'label' => '原料批號: ' . $inventory->batch_number,
|
||||||
|
'product_name' => $inventory->product?->name,
|
||||||
|
'spec' => $inventory->product?->spec,
|
||||||
|
'batch_number' => $inventory->batch_number,
|
||||||
|
'quantity' => $poItem ? $poItem->quantity_used : null,
|
||||||
|
'warehouse_name' => $inventory->warehouse?->name,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// 繼續往下追溯該原料是怎麼來的 (進貨單)
|
||||||
|
if ($inventory->batch_number) {
|
||||||
|
$grItems = GoodsReceiptItem::with(['goodsReceipt', 'product'])
|
||||||
|
->where('batch_number', $inventory->batch_number)
|
||||||
|
->where('product_id', $inventory->product_id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($grItems as $grItem) {
|
||||||
|
$gr = $grItem->goodsReceipt;
|
||||||
|
if ($gr) {
|
||||||
|
$node['children'][] = [
|
||||||
|
'id' => 'gr_' . $gr->id,
|
||||||
|
'type' => 'goods_receipt',
|
||||||
|
'label' => '進貨單: ' . $gr->code,
|
||||||
|
'date' => $gr->received_date instanceof \DateTimeInterface
|
||||||
|
? $gr->received_date->format('Y-m-d')
|
||||||
|
: $gr->received_date,
|
||||||
|
'vendor_id' => $gr->vendor_id,
|
||||||
|
'quantity' => $grItem->quantity,
|
||||||
|
'product_name' => $grItem->product?->name,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 順向追蹤:從原料批號往後追查被用在哪些成品及去向
|
||||||
|
*
|
||||||
|
* @param string $batchNumber 原料批號
|
||||||
|
* @return array 樹狀結構資料
|
||||||
|
*/
|
||||||
|
public function traceForward(string $batchNumber): array
|
||||||
|
{
|
||||||
|
$baseInventory = Inventory::with(['product', 'warehouse'])
|
||||||
|
->where('batch_number', $batchNumber)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$rootNode = [
|
||||||
|
'id' => 'batch_' . $batchNumber,
|
||||||
|
'type' => 'source_batch',
|
||||||
|
'label' => '查詢批號: ' . $batchNumber,
|
||||||
|
'batch_number' => $batchNumber,
|
||||||
|
'product_name' => $baseInventory?->product?->name,
|
||||||
|
'spec' => $baseInventory?->product?->spec,
|
||||||
|
'warehouse_name' => $baseInventory?->warehouse?->name,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1. 尋找這個批號被哪些工單使用了
|
||||||
|
$inventories = Inventory::with(['product', 'warehouse'])->where('batch_number', $batchNumber)->get();
|
||||||
|
|
||||||
|
foreach ($inventories as $inv) {
|
||||||
|
// 透過 ProductionService 獲取,以落實模組解耦
|
||||||
|
$poItems = $this->productionService->getProductionOrderItemsByInventoryId($inv->id, ['productionOrder']);
|
||||||
|
|
||||||
|
foreach ($poItems as $item) {
|
||||||
|
$po = $item->productionOrder;
|
||||||
|
if ($po) {
|
||||||
|
$poNode = [
|
||||||
|
'id' => 'po_' . $po->id,
|
||||||
|
'type' => 'production_order',
|
||||||
|
'label' => '投入工單: ' . $po->code,
|
||||||
|
'date' => $po->production_date instanceof \DateTimeInterface
|
||||||
|
? $po->production_date->format('Y-m-d')
|
||||||
|
: $po->production_date,
|
||||||
|
'quantity' => $item->quantity_used,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// 該工單產出的成品批號
|
||||||
|
if ($po->output_batch_number) {
|
||||||
|
$outputInventory = Inventory::with(['product', 'warehouse'])
|
||||||
|
->where('batch_number', $po->output_batch_number)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$outputNode = [
|
||||||
|
'id' => 'output_batch_' . $po->output_batch_number,
|
||||||
|
'type' => 'target_batch',
|
||||||
|
'label' => '產出成品: ' . $po->output_batch_number,
|
||||||
|
'batch_number' => $po->output_batch_number,
|
||||||
|
'quantity' => $po->output_quantity,
|
||||||
|
'product_name' => $outputInventory?->product?->name,
|
||||||
|
'spec' => $outputInventory?->product?->spec,
|
||||||
|
'warehouse_name' => $outputInventory?->warehouse?->name,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// 追蹤成品的出庫紀錄 (銷貨、領料等)
|
||||||
|
$outTransactions = InventoryTransaction::with(['reference', 'inventory.product'])
|
||||||
|
->whereHas('inventory', function ($q) use ($po) {
|
||||||
|
$q->where('batch_number', $po->output_batch_number);
|
||||||
|
})
|
||||||
|
->where('quantity', '<', 0) // 出庫
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($outTransactions as $txn) {
|
||||||
|
$refType = class_basename($txn->reference_type);
|
||||||
|
$outputNode['children'][] = [
|
||||||
|
'id' => 'txn_' . $txn->id,
|
||||||
|
'type' => 'outbound_transaction',
|
||||||
|
'label' => '出庫單據: ' . $refType . ' #' . $txn->reference_id,
|
||||||
|
'date' => $txn->actual_time,
|
||||||
|
'quantity' => abs($txn->quantity),
|
||||||
|
'product_name' => $txn->inventory?->product?->name,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$poNode['children'][] = $outputNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootNode['children'][] = $poNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果這個批號自己本身就有出庫紀錄 (不是被生產掉,而是直接被領走或賣掉)
|
||||||
|
foreach ($inventories as $inv) {
|
||||||
|
$outTransactions = InventoryTransaction::with(['reference', 'inventory.product'])
|
||||||
|
->where('inventory_id', $inv->id)
|
||||||
|
->where('quantity', '<', 0)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($outTransactions as $txn) {
|
||||||
|
// 如果是生產工單領料,上面已經處理過,這裡濾掉
|
||||||
|
if ($txn->reference_type && str_contains($txn->reference_type, 'ProductionOrder')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$refType = $txn->reference_type ? class_basename($txn->reference_type) : '未知';
|
||||||
|
$rootNode['children'][] = [
|
||||||
|
'id' => 'txn_direct_' . $txn->id,
|
||||||
|
'type' => 'outbound_transaction',
|
||||||
|
'label' => '直接出庫: ' . $refType . ' #' . $txn->reference_id,
|
||||||
|
'date' => $txn->actual_time,
|
||||||
|
'quantity' => abs($txn->quantity),
|
||||||
|
'product_name' => $txn->inventory?->product?->name,
|
||||||
|
'children' => []
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rootNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 水和廠商名稱 (跨模組)
|
||||||
|
*/
|
||||||
|
private function hydrateVendorNames(array &$node): void
|
||||||
|
{
|
||||||
|
$vendorIds = [];
|
||||||
|
$this->collectVendorIds($node, $vendorIds);
|
||||||
|
|
||||||
|
if (empty($vendorIds)) return;
|
||||||
|
|
||||||
|
$vendors = $this->procurementService->getVendorsByIds(array_unique($vendorIds))->keyBy('id');
|
||||||
|
|
||||||
|
$this->applyVendorNames($node, $vendors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectVendorIds(array $node, array &$ids): void
|
||||||
|
{
|
||||||
|
if (isset($node['vendor_id'])) {
|
||||||
|
$ids[] = $node['vendor_id'];
|
||||||
|
}
|
||||||
|
if (!empty($node['children'])) {
|
||||||
|
foreach ($node['children'] as $child) {
|
||||||
|
$this->collectVendorIds($child, $ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyVendorNames(array &$node, Collection $vendors): void
|
||||||
|
{
|
||||||
|
if (isset($node['vendor_id']) && $vendors->has($node['vendor_id'])) {
|
||||||
|
$vendor = $vendors->get($node['vendor_id']);
|
||||||
|
$node['label'] .= ' (廠商: ' . $vendor->name . ')';
|
||||||
|
}
|
||||||
|
if (!empty($node['children'])) {
|
||||||
|
foreach ($node['children'] as &$child) {
|
||||||
|
$this->applyVendorNames($child, $vendors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,4 +5,21 @@ namespace App\Modules\Production\Contracts;
|
|||||||
interface ProductionServiceInterface
|
interface ProductionServiceInterface
|
||||||
{
|
{
|
||||||
public function getPendingProductionCount(): int;
|
public function getPendingProductionCount(): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尋找產出特定批號的生產工單
|
||||||
|
*
|
||||||
|
* @param string $batchNumber
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getProductionOrdersByOutputBatch(string $batchNumber): \Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尋找使用了特定庫存批號的生產工單項目
|
||||||
|
*
|
||||||
|
* @param int $inventoryId
|
||||||
|
* @param array $with
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function getProductionOrderItemsByInventoryId(int $inventoryId, array $with = []): \Illuminate\Support\Collection;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,9 @@ class ProductionOrderItem extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(ProductionOrder::class);
|
return $this->belongsTo(ProductionOrder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Modules\Production\Services;
|
|||||||
|
|
||||||
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
use App\Modules\Production\Contracts\ProductionServiceInterface;
|
||||||
use App\Modules\Production\Models\ProductionOrder;
|
use App\Modules\Production\Models\ProductionOrder;
|
||||||
|
use App\Modules\Production\Models\ProductionOrderItem;
|
||||||
|
|
||||||
class ProductionService implements ProductionServiceInterface
|
class ProductionService implements ProductionServiceInterface
|
||||||
{
|
{
|
||||||
@@ -11,4 +12,18 @@ class ProductionService implements ProductionServiceInterface
|
|||||||
{
|
{
|
||||||
return ProductionOrder::where('status', 'pending')->count();
|
return ProductionOrder::where('status', 'pending')->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getProductionOrdersByOutputBatch(string $batchNumber): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
return ProductionOrder::with(['items.inventory.product', 'items.inventory'])
|
||||||
|
->where('output_batch_number', $batchNumber)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductionOrderItemsByInventoryId(int $inventoryId, array $with = []): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
return ProductionOrderItem::with($with)
|
||||||
|
->where('inventory_id', $inventoryId)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class PermissionSeeder extends Seeder
|
|||||||
'inventory_report.view' => '檢視',
|
'inventory_report.view' => '檢視',
|
||||||
'inventory_report.export' => '匯出',
|
'inventory_report.export' => '匯出',
|
||||||
|
|
||||||
|
// 批號溯源
|
||||||
|
'inventory_traceability.view' => '檢視',
|
||||||
|
|
||||||
// 進貨單管理
|
// 進貨單管理
|
||||||
'goods_receipts.view' => '檢視',
|
'goods_receipts.view' => '檢視',
|
||||||
'goods_receipts.create' => '建立',
|
'goods_receipts.create' => '建立',
|
||||||
@@ -191,7 +194,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
|
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
|
||||||
'inventory_report.view', 'inventory_report.export',
|
'inventory_report.view', 'inventory_report.export', 'inventory_traceability.view',
|
||||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||||
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete',
|
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete',
|
||||||
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete',
|
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete',
|
||||||
@@ -216,7 +219,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
|
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
|
||||||
'inventory_report.view', 'inventory_report.export',
|
'inventory_report.view', 'inventory_report.export', 'inventory_traceability.view',
|
||||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||||
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
||||||
@@ -246,6 +249,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'warehouses.view',
|
'warehouses.view',
|
||||||
'utility_fees.view',
|
'utility_fees.view',
|
||||||
'inventory_report.view',
|
'inventory_report.view',
|
||||||
|
'inventory_traceability.view',
|
||||||
'accounting.view',
|
'accounting.view',
|
||||||
'account_payables.view',
|
'account_payables.view',
|
||||||
'sales_orders.view',
|
'sales_orders.view',
|
||||||
|
|||||||
@@ -278,6 +278,13 @@ export default function AuthenticatedLayout({
|
|||||||
route: "/inventory/analysis",
|
route: "/inventory/analysis",
|
||||||
permission: "inventory_report.view",
|
permission: "inventory_report.view",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "inventory-traceability",
|
||||||
|
label: "批號溯源",
|
||||||
|
icon: <TrendingUp className="h-4 w-4" />,
|
||||||
|
route: "/inventory/traceability",
|
||||||
|
permission: "inventory_traceability.view",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Info } from "lucide-react";
|
||||||
|
import { TraceabilityNode } from "./TreeView";
|
||||||
|
|
||||||
|
interface TraceabilitySummaryProps {
|
||||||
|
data: TraceabilityNode;
|
||||||
|
direction: 'forward' | 'backward';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraceabilitySummary({ data, direction }: TraceabilitySummaryProps) {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
// --- Helper to extract unqiue names/codes from children ---
|
||||||
|
const getUniqueLabels = (nodes: TraceabilityNode[], prefixToStrip: string = '') => {
|
||||||
|
const labels = nodes
|
||||||
|
.map(n => n.label.replace(prefixToStrip, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return Array.from(new Set(labels));
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Logic for Backward Tracing ---
|
||||||
|
if (direction === 'backward') {
|
||||||
|
const poNodes = data.children?.filter(c => c.type === 'production_order') || [];
|
||||||
|
const poNames = getUniqueLabels(poNodes, '生產工單:');
|
||||||
|
|
||||||
|
let materialNodes: TraceabilityNode[] = [];
|
||||||
|
let grNodes: TraceabilityNode[] = [];
|
||||||
|
|
||||||
|
poNodes.forEach(po => {
|
||||||
|
const materials = po.children?.filter(c => c.type === 'material_batch') || [];
|
||||||
|
materialNodes = [...materialNodes, ...materials];
|
||||||
|
|
||||||
|
materials.forEach(mat => {
|
||||||
|
const grs = mat.children?.filter(c => c.type === 'goods_receipt') || [];
|
||||||
|
grNodes = [...grNodes, ...grs];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const materialNames = getUniqueLabels(materialNodes, '原料批號:');
|
||||||
|
const grNames = getUniqueLabels(grNodes, '進貨單:');
|
||||||
|
|
||||||
|
// Handle case where batch is directly from Goods Receipt (no PO)
|
||||||
|
const directGrNodes = data.children?.filter(c => c.type === 'goods_receipt') || [];
|
||||||
|
if (directGrNodes.length > 0) {
|
||||||
|
grNodes = [...grNodes, ...directGrNodes];
|
||||||
|
const directGrNames = getUniqueLabels(directGrNodes, '進貨單:');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50/50 border border-blue-100 rounded-xl p-4 mb-6 flex gap-3 text-gray-700 leading-relaxed shadow-sm">
|
||||||
|
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
查詢的批號 <strong className="text-gray-900">{data.batch_number}</strong>
|
||||||
|
{data.product_name && <span> ({data.product_name})</span>}
|
||||||
|
主要是由進貨單 {directGrNames.map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)} 採購進貨。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50/50 border border-blue-100 rounded-xl p-4 mb-6 flex gap-3 text-gray-700 leading-relaxed shadow-sm">
|
||||||
|
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p>
|
||||||
|
查詢的成品批號 <strong className="text-gray-900">{data.batch_number}</strong>
|
||||||
|
{data.product_name && <span> ({data.product_name})</span>} 是由
|
||||||
|
{poNames.length > 0 ? (
|
||||||
|
<>
|
||||||
|
生產工單 {poNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
|
||||||
|
{poNames.length > 3 && ` 等 ${poNames.length} 張工單`} 產出。
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'未知的生產工單產出。'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{materialNodes.length > 0 && (
|
||||||
|
<p>
|
||||||
|
這些工單總共使用了 <strong className="text-gray-900">{materialNodes.length}</strong> 批原料
|
||||||
|
(包含原料批號 {materialNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
|
||||||
|
{materialNames.length > 3 && ' 等'})。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{grNodes.length > 0 && (
|
||||||
|
<p>
|
||||||
|
而這些原料主要來自於進貨單 {grNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
|
||||||
|
{grNames.length > 3 && ` 等 ${grNames.length} 張單據`}。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logic for Forward Tracing ---
|
||||||
|
if (direction === 'forward') {
|
||||||
|
const poNodes = data.children?.filter(c => c.type === 'production_order') || [];
|
||||||
|
const poNames = getUniqueLabels(poNodes, '投入工單:');
|
||||||
|
|
||||||
|
let targetNodes: TraceabilityNode[] = [];
|
||||||
|
let outNodes: TraceabilityNode[] = [];
|
||||||
|
|
||||||
|
poNodes.forEach(po => {
|
||||||
|
const targets = po.children?.filter(c => c.type === 'target_batch') || [];
|
||||||
|
targetNodes = [...targetNodes, ...targets];
|
||||||
|
|
||||||
|
targets.forEach(tgt => {
|
||||||
|
const outs = tgt.children?.filter(c => c.type === 'outbound_transaction') || [];
|
||||||
|
outNodes = [...outNodes, ...outs];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetNames = getUniqueLabels(targetNodes, '產出成品:');
|
||||||
|
const outNames = getUniqueLabels(outNodes, '出庫單據:');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50/50 border border-blue-100 rounded-xl p-4 mb-6 flex gap-3 text-gray-700 leading-relaxed shadow-sm">
|
||||||
|
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p>
|
||||||
|
查詢的原料批號 <strong className="text-gray-900">{data.batch_number}</strong>
|
||||||
|
{data.product_name && <span> ({data.product_name})</span>} 被投入了
|
||||||
|
{poNames.length > 0 ? (
|
||||||
|
<>
|
||||||
|
生產工單 {poNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
|
||||||
|
{poNames.length > 3 && ` 等 ${poNames.length} 張工單`}。
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
' 未知的生產工單。'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{targetNodes.length > 0 && (
|
||||||
|
<p>
|
||||||
|
這些工單隨後產出了成品批號 {targetNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
|
||||||
|
{targetNames.length > 3 && ` 等 ${targetNodes.length} 批成品`}。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{outNodes.length > 0 && (
|
||||||
|
<p>
|
||||||
|
最終,這些成品透過 {outNames.slice(0, 3).map(n => <strong key={n} className="text-gray-900 mx-1">{n}</strong>).reduce((prev, curr) => [prev, '、', curr] as any)}
|
||||||
|
{outNames.length > 3 && ` 等 ${outNodes.length} 張單據`} 離開了倉庫。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Package, Truck, Factory, CornerDownRight, Box, ShoppingCart } from 'lucide-react';
|
||||||
|
import { formatDate } from '@/lib/date';
|
||||||
|
|
||||||
|
export interface TraceabilityNode {
|
||||||
|
id: string;
|
||||||
|
type: 'target_batch' | 'source_batch' | 'production_order' | 'material_batch' | 'goods_receipt' | 'outbound_transaction';
|
||||||
|
label: string;
|
||||||
|
batch_number?: string;
|
||||||
|
date?: string;
|
||||||
|
vendor_id?: number | string;
|
||||||
|
product_name?: string;
|
||||||
|
spec?: string;
|
||||||
|
quantity?: number | string;
|
||||||
|
unit?: string;
|
||||||
|
warehouse_name?: string;
|
||||||
|
children?: TraceabilityNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeViewProps {
|
||||||
|
data: TraceabilityNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNodeIcon = (type: TraceabilityNode['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'target_batch':
|
||||||
|
case 'source_batch':
|
||||||
|
return <Package className="h-5 w-5 text-primary-main" />;
|
||||||
|
case 'production_order':
|
||||||
|
return <Factory className="h-5 w-5 text-other-warning" />;
|
||||||
|
case 'material_batch':
|
||||||
|
return <Box className="h-5 w-5 text-primary-main" />;
|
||||||
|
case 'goods_receipt':
|
||||||
|
return <Truck className="h-5 w-5 text-other-info" />;
|
||||||
|
case 'outbound_transaction':
|
||||||
|
return <ShoppingCart className="h-5 w-5 text-other-success" />;
|
||||||
|
default:
|
||||||
|
return <Box className="h-5 w-5 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeColor = (type: TraceabilityNode['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'target_batch':
|
||||||
|
case 'source_batch':
|
||||||
|
return 'border-primary-light bg-primary-lightest';
|
||||||
|
case 'production_order':
|
||||||
|
return 'border-orange-200 bg-orange-50';
|
||||||
|
case 'material_batch':
|
||||||
|
return 'border-primary-light bg-primary-lightest';
|
||||||
|
case 'goods_receipt':
|
||||||
|
return 'border-blue-200 bg-blue-50';
|
||||||
|
case 'outbound_transaction':
|
||||||
|
return 'border-emerald-200 bg-emerald-50';
|
||||||
|
default:
|
||||||
|
return 'border-gray-200 bg-gray-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TreeNode = ({ node, isLast, level = 0 }: { node: TraceabilityNode; isLast: boolean; level?: number }) => {
|
||||||
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* 連接線 (上層到此節點) */}
|
||||||
|
{level > 0 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute border-l-2 border-gray-200",
|
||||||
|
isLast ? "h-6 top-0" : "h-full top-0"
|
||||||
|
)}
|
||||||
|
style={{ left: '-1.5rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 橫向連接線 */}
|
||||||
|
{level > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute border-t-2 border-gray-200 w-6 top-6"
|
||||||
|
style={{ left: '-1.5rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start mb-6">
|
||||||
|
{level > 0 && (
|
||||||
|
<div className="w-6 h-12 shrink-0 flex items-center justify-end pr-2 text-gray-400">
|
||||||
|
<CornerDownRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"relative flex flex-col p-4 rounded-xl border shadow-sm min-w-[320px] max-w-md",
|
||||||
|
getNodeColor(node.type)
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white rounded-lg shadow-sm shrink-0">
|
||||||
|
{getNodeIcon(node.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-semibold text-gray-900 truncate">{node.label}</h4>
|
||||||
|
{node.date && (
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<span className="text-xs text-gray-500">{formatDate(node.date)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(node.product_name || node.warehouse_name || (node.quantity !== undefined && node.quantity !== null)) && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200/60 flex flex-wrap gap-4">
|
||||||
|
{node.product_name && (
|
||||||
|
<div className="flex-1 min-w-[120px]">
|
||||||
|
<span className="text-[11px] text-gray-500 font-medium block mb-0.5">品名 / 規格</span>
|
||||||
|
<span className="text-sm font-medium text-gray-800 leading-tight block truncate" title={`${node.product_name} ${node.spec ? `(${node.spec})` : ''}`}>
|
||||||
|
{node.product_name}
|
||||||
|
{node.spec && <span className="text-gray-400 text-xs ml-1">({node.spec})</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.warehouse_name && (
|
||||||
|
<div className="shrink-0 min-w-[80px]">
|
||||||
|
<span className="text-[11px] text-gray-500 font-medium block mb-0.5">倉庫</span>
|
||||||
|
<span className="text-sm font-medium text-gray-800 leading-tight block truncate" title={node.warehouse_name}>
|
||||||
|
{node.warehouse_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.quantity !== undefined && node.quantity !== null && (
|
||||||
|
<div className="shrink-0 text-right min-w-[60px]">
|
||||||
|
<span className="text-[11px] text-gray-500 font-medium block mb-0.5">數量</span>
|
||||||
|
<span className="text-sm font-bold text-gray-900 block truncate">
|
||||||
|
{Number(node.quantity).toLocaleString()}
|
||||||
|
<span className="text-[10px] ml-0.5 text-gray-500 font-normal">{node.unit || 'PCS'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="pl-12 relative">
|
||||||
|
{node.children!.map((child, index) => (
|
||||||
|
<TreeNode
|
||||||
|
key={`${child.id} -${index} `}
|
||||||
|
node={child}
|
||||||
|
isLast={index === node.children!.length - 1}
|
||||||
|
level={level + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TreeView({ data }: TreeViewProps) {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 bg-gray-50/50 rounded-2xl border border-gray-100 overflow-x-auto">
|
||||||
|
<TreeNode node={data} isLast={true} level={0} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
resources/js/Pages/Inventory/Traceability/Index.tsx
Normal file
154
resources/js/Pages/Inventory/Traceability/Index.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Head, router } from '@inertiajs/react';
|
||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
|
import { PageProps } from '@/types/global';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||||
|
import { Button } from '@/Components/ui/button';
|
||||||
|
import { Input } from '@/Components/ui/input';
|
||||||
|
import { Label } from '@/Components/ui/label';
|
||||||
|
import { TrendingUp, Search, RotateCcw } from 'lucide-react';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/Components/ui/radio-group';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import TreeView, { TraceabilityNode } from './Components/TreeView';
|
||||||
|
import { TraceabilitySummary } from './Components/TraceabilitySummary';
|
||||||
|
import { Can } from '@/Components/Permission/Can';
|
||||||
|
|
||||||
|
interface Props extends PageProps {
|
||||||
|
search: {
|
||||||
|
batch_number: string | null;
|
||||||
|
direction: 'backward' | 'forward';
|
||||||
|
};
|
||||||
|
result: TraceabilityNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TraceabilityIndex({ search, result }: Props) {
|
||||||
|
const [batchNumber, setBatchNumber] = useState(search.batch_number || '');
|
||||||
|
const [direction, setDirection] = useState<'backward' | 'forward'>(search.direction || 'backward');
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!batchNumber.trim()) return;
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
router.get(
|
||||||
|
route('inventory.traceability.index'),
|
||||||
|
{ batch_number: batchNumber.trim(), direction },
|
||||||
|
{
|
||||||
|
preserveState: true,
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => setIsSearching(false)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '報表管理', href: '#' },
|
||||||
|
{ label: '批號溯源', href: route('inventory.traceability.index'), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="批號溯源 - Star ERP" />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-6 w-6 text-primary-main" />
|
||||||
|
批號溯源
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
透過批號追蹤產品的生產履歷,支援從成品追溯原料供應商(逆向),或從原料追查銷售去向(順向)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Can permission="inventory.traceability.view">
|
||||||
|
<Card className="mb-6 bg-white shadow-sm border-gray-200">
|
||||||
|
<CardHeader className="pb-3 border-b border-gray-100 bg-gray-50/50">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2 text-gray-800">
|
||||||
|
<Search className="h-5 w-5 text-primary-main" />
|
||||||
|
查詢條件
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-6 items-start md:items-end">
|
||||||
|
<div className="flex-1 w-full space-y-1.5">
|
||||||
|
<Label htmlFor="batchNumber" className="text-sm font-medium text-grey-1">查詢批號</Label>
|
||||||
|
<Input
|
||||||
|
id="batchNumber"
|
||||||
|
type="text"
|
||||||
|
placeholder="請輸入欲查詢的批號 (例如:PROD-TW-20240101-01)"
|
||||||
|
value={batchNumber}
|
||||||
|
onChange={(e) => setBatchNumber(e.target.value)}
|
||||||
|
className="max-w-md w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-gray-700 font-medium">追蹤方向</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={direction}
|
||||||
|
onValueChange={(val: 'backward' | 'forward') => setDirection(val)}
|
||||||
|
className="flex space-x-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center space-x-2 px-4 py-2 rounded-lg border cursor-pointer transition-colors",
|
||||||
|
direction === 'backward' ? "bg-primary-lightest border-primary-light" : "bg-gray-50 border-gray-200 hover:bg-gray-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem value="backward" id="backward" />
|
||||||
|
<Label htmlFor="backward" className="cursor-pointer flex items-center gap-1.5 font-medium">
|
||||||
|
<RotateCcw className="h-4 w-4 text-primary-main" />
|
||||||
|
逆向溯源 (成品 ➔ 原料)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center space-x-2 px-4 py-2 rounded-lg border cursor-pointer transition-colors",
|
||||||
|
direction === 'forward' ? "bg-primary-lightest border-primary-light" : "bg-gray-50 border-gray-200 hover:bg-gray-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem value="forward" id="forward" />
|
||||||
|
<Label htmlFor="forward" className="cursor-pointer flex items-center gap-1.5 font-medium">
|
||||||
|
<TrendingUp className="h-4 w-4 text-primary-main" />
|
||||||
|
順向追蹤 (原料 ➔ 去向)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSearching || !batchNumber.trim()}
|
||||||
|
className="button-filled-primary min-w-[120px]"
|
||||||
|
>
|
||||||
|
{isSearching ? '查詢中...' : '開始查詢'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{search.batch_number && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden p-6 md:p-8">
|
||||||
|
{result ? (
|
||||||
|
<>
|
||||||
|
<TraceabilitySummary data={result} direction={search.direction || 'backward'} />
|
||||||
|
<TreeView data={result} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-16 flex flex-col items-center justify-center text-gray-500">
|
||||||
|
<Search className="h-12 w-12 text-gray-300 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-1">找不到符合的批號資料</h3>
|
||||||
|
<p>請確認您輸入的批號「{search.batch_number}」是否正確存在於系統中。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,14 +13,8 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
import { Checkbox } from "@/Components/ui/checkbox";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/Components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -39,7 +33,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/Components/ui/select";
|
} from "@/Components/ui/select";
|
||||||
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search, Truck, PackageCheck } from "lucide-react";
|
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Truck, PackageCheck } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Can } from '@/Components/Permission/Can';
|
import { Can } from '@/Components/Permission/Can';
|
||||||
@@ -115,20 +109,20 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
|
|||||||
}
|
}
|
||||||
}, [order]);
|
}, [order]);
|
||||||
|
|
||||||
|
const canEdit = can('inventory_transfer.edit');
|
||||||
|
const isReadOnly = (order.status !== 'draft' || !canEdit);
|
||||||
|
const isItemsReadOnly = isReadOnly || !!order.requisition;
|
||||||
|
const isVending = order.to_warehouse_type === 'vending';
|
||||||
|
|
||||||
// Product Selection
|
// Product Selection
|
||||||
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
|
|
||||||
const [availableInventory, setAvailableInventory] = useState<any[]>([]);
|
const [availableInventory, setAvailableInventory] = useState<any[]>([]);
|
||||||
const [loadingInventory, setLoadingInventory] = useState(false);
|
const [loadingInventory, setLoadingInventory] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedInventory, setSelectedInventory] = useState<string[]>([]); // product_id-batch
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isProductDialogOpen) {
|
if (!isItemsReadOnly && order.from_warehouse_id) {
|
||||||
loadInventory();
|
loadInventory();
|
||||||
setSelectedInventory([]);
|
|
||||||
setSearchQuery('');
|
|
||||||
}
|
}
|
||||||
}, [isProductDialogOpen]);
|
}, [isItemsReadOnly, order.from_warehouse_id]);
|
||||||
|
|
||||||
const loadInventory = async () => {
|
const loadInventory = async () => {
|
||||||
setLoadingInventory(true);
|
setLoadingInventory(true);
|
||||||
@@ -143,57 +137,22 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelect = (key: string) => {
|
const handleAddItem = () => {
|
||||||
setSelectedInventory(prev =>
|
setItems([
|
||||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
...items,
|
||||||
);
|
{
|
||||||
};
|
product_id: "",
|
||||||
|
product_name: "",
|
||||||
const toggleSelectAll = () => {
|
product_code: "",
|
||||||
const filtered = availableInventory.filter(inv =>
|
batch_number: "",
|
||||||
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
expiry_date: null,
|
||||||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
unit: "",
|
||||||
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase()))
|
quantity: "",
|
||||||
);
|
max_quantity: 0,
|
||||||
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`);
|
position: "",
|
||||||
|
notes: ""
|
||||||
if (filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k))) {
|
|
||||||
setSelectedInventory(prev => prev.filter(k => !filteredKeys.includes(k)));
|
|
||||||
} else {
|
|
||||||
setSelectedInventory(prev => Array.from(new Set([...prev, ...filteredKeys])));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSelected = () => {
|
|
||||||
if (selectedInventory.length === 0) return;
|
|
||||||
|
|
||||||
const newItems = [...items];
|
|
||||||
let addedCount = 0;
|
|
||||||
|
|
||||||
availableInventory.forEach(inv => {
|
|
||||||
const key = `${inv.product_id}-${inv.batch_number}`;
|
|
||||||
if (selectedInventory.includes(key)) {
|
|
||||||
newItems.push({
|
|
||||||
product_id: inv.product_id,
|
|
||||||
product_name: inv.product_name,
|
|
||||||
product_code: inv.product_code,
|
|
||||||
batch_number: inv.batch_number,
|
|
||||||
expiry_date: inv.expiry_date,
|
|
||||||
unit: inv.unit_name,
|
|
||||||
quantity: 1,
|
|
||||||
max_quantity: inv.quantity,
|
|
||||||
notes: "",
|
|
||||||
});
|
|
||||||
addedCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setItems(newItems);
|
|
||||||
setIsProductDialogOpen(false);
|
|
||||||
|
|
||||||
if (addedCount > 0) {
|
|
||||||
toast.success(`已成功加入 ${addedCount} 個項目`);
|
|
||||||
}
|
}
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateItem = (index: number, field: string, value: any) => {
|
const handleUpdateItem = (index: number, field: string, value: any) => {
|
||||||
@@ -210,11 +169,16 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await router.put(route('inventory.transfer.update', [order.id]), {
|
const payload: any = {
|
||||||
items: items,
|
|
||||||
remarks: remarks,
|
remarks: remarks,
|
||||||
transit_warehouse_id: transitWarehouseId || '',
|
transit_warehouse_id: transitWarehouseId || '',
|
||||||
}, {
|
};
|
||||||
|
|
||||||
|
if (!order.requisition) {
|
||||||
|
payload.items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.put(route('inventory.transfer.update', [order.id]), payload, {
|
||||||
onSuccess: () => { },
|
onSuccess: () => { },
|
||||||
onError: () => toast.error("儲存失敗,請檢查輸入"),
|
onError: () => toast.error("儲存失敗,請檢查輸入"),
|
||||||
});
|
});
|
||||||
@@ -223,15 +187,19 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 確認出貨 / 確認過帳(無在途倉)
|
|
||||||
// 確認出貨 / 確認過帳(無在途倉)
|
// 確認出貨 / 確認過帳(無在途倉)
|
||||||
const handlePost = () => {
|
const handlePost = () => {
|
||||||
router.put(route('inventory.transfer.update', [order.id]), {
|
const payload: any = {
|
||||||
action: 'post',
|
action: 'post',
|
||||||
transit_warehouse_id: transitWarehouseId || '',
|
transit_warehouse_id: transitWarehouseId || '',
|
||||||
items: items,
|
|
||||||
remarks: remarks,
|
remarks: remarks,
|
||||||
}, {
|
};
|
||||||
|
|
||||||
|
if (!order.requisition) {
|
||||||
|
payload.items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.put(route('inventory.transfer.update', [order.id]), payload, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setIsPostDialogOpen(false);
|
setIsPostDialogOpen(false);
|
||||||
},
|
},
|
||||||
@@ -267,10 +235,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const canEdit = can('inventory_transfer.edit');
|
|
||||||
const isReadOnly = (order.status !== 'draft' || !canEdit);
|
|
||||||
const isItemsReadOnly = isReadOnly || !!order.requisition;
|
|
||||||
const isVending = order.to_warehouse_type === 'vending';
|
|
||||||
|
|
||||||
// 狀態 Badge 渲染
|
// 狀態 Badge 渲染
|
||||||
const renderStatusBadge = () => {
|
const renderStatusBadge = () => {
|
||||||
@@ -579,146 +544,10 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
|
|||||||
onOpenChange={setIsImportDialogOpen}
|
onOpenChange={setIsImportDialogOpen}
|
||||||
orderId={order.id}
|
orderId={order.id}
|
||||||
/>
|
/>
|
||||||
|
<Button variant="outline" className="button-outlined-primary" onClick={handleAddItem}>
|
||||||
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" className="button-outlined-primary">
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
加入商品
|
新增明細
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6">
|
|
||||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
|
||||||
<DialogTitle className="text-xl">選擇來源庫存 ({order.from_warehouse_name})</DialogTitle>
|
|
||||||
<div className="relative w-72">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
|
|
||||||
<Input
|
|
||||||
placeholder="搜尋品名、代號或條碼..."
|
|
||||||
className="pl-9 h-9 border-2 border-grey-3 focus:ring-primary-main"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex-1 overflow-auto pr-1">
|
|
||||||
{loadingInventory ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<Package className="h-10 w-10 animate-bounce mx-auto text-gray-300 mb-2" />
|
|
||||||
<p className="text-grey-2 text-sm">庫存資料載入中...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[50px] text-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={availableInventory.length > 0 && (() => {
|
|
||||||
const filtered = availableInventory.filter(inv =>
|
|
||||||
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
||||||
);
|
|
||||||
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`);
|
|
||||||
return filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k));
|
|
||||||
})()}
|
|
||||||
onCheckedChange={() => toggleSelectAll()}
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
|
|
||||||
<TableHead className="font-medium text-grey-600">品名 / 代號</TableHead>
|
|
||||||
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
|
||||||
<TableHead className="font-medium text-grey-600">效期</TableHead>
|
|
||||||
<TableHead className="text-right font-medium text-grey-600 pr-6">現有庫存</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{(() => {
|
|
||||||
const filtered = availableInventory.filter(inv =>
|
|
||||||
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
|
|
||||||
{searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered.map((inv) => {
|
|
||||||
const key = `${inv.product_id}-${inv.batch_number}`;
|
|
||||||
const isSelected = selectedInventory.includes(key);
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={key}
|
|
||||||
className={`hover:bg-primary-lightest/20 cursor-pointer transition-colors ${isSelected ? 'bg-primary-lightest/40' : ''}`}
|
|
||||||
onClick={() => toggleSelect(key)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected}
|
|
||||||
onCheckedChange={() => toggleSelect(key)}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell className="py-3">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-semibold text-grey-0">{inv.product_name}</span>
|
|
||||||
<span className="text-xs text-grey-2 font-mono">{inv.product_code}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
|
|
||||||
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
|
|
||||||
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex items-center justify-between border-t pt-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="px-3 py-1 bg-primary-lightest/50 border border-primary-light/20 rounded-full text-sm font-medium text-primary-main animate-in zoom-in duration-200">
|
|
||||||
已選取 {selectedInventory.length} 項商品
|
|
||||||
</div>
|
|
||||||
{selectedInventory.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-grey-3 hover:text-red-500 hover:bg-red-50 text-xs px-2 h-7"
|
|
||||||
onClick={() => setSelectedInventory([])}
|
|
||||||
>
|
|
||||||
清除全部
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="button-outlined-primary w-24"
|
|
||||||
onClick={() => setIsProductDialogOpen(false)}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="button-filled-primary min-w-32"
|
|
||||||
disabled={selectedInventory.length === 0}
|
|
||||||
onClick={handleAddSelected}
|
|
||||||
>
|
|
||||||
確認加入選取項
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -752,10 +581,41 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
|
|||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
<TableCell className="text-center text-gray-500 font-medium">{index + 1}</TableCell>
|
<TableCell className="text-center text-gray-500 font-medium">{index + 1}</TableCell>
|
||||||
<TableCell className="py-3">
|
<TableCell className="py-3">
|
||||||
|
{isItemsReadOnly || item.product_id ? (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-semibold text-gray-900">{item.product_name}</span>
|
<span className="font-semibold text-gray-900">{item.product_name}</span>
|
||||||
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<SearchableSelect
|
||||||
|
value={item.product_id ? `${item.product_id}|${item.batch_number || ''}` : ""}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const [pid, batch] = val.split('|');
|
||||||
|
const inv = availableInventory.find(i => String(i.product_id) === pid && (i.batch_number || '') === batch);
|
||||||
|
if (inv) {
|
||||||
|
const newItems = [...items];
|
||||||
|
newItems[index] = {
|
||||||
|
...newItems[index],
|
||||||
|
product_id: inv.product_id,
|
||||||
|
product_name: inv.product_name,
|
||||||
|
product_code: inv.product_code,
|
||||||
|
batch_number: inv.batch_number,
|
||||||
|
expiry_date: inv.expiry_date,
|
||||||
|
unit: inv.unit_name,
|
||||||
|
max_quantity: inv.quantity,
|
||||||
|
quantity: newItems[index].quantity || 1
|
||||||
|
};
|
||||||
|
setItems(newItems);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={availableInventory.map(inv => ({
|
||||||
|
label: `${inv.product_code} - ${inv.product_name} ${inv.batch_number ? `(批號: ${inv.batch_number})` : ''} - 庫存: ${inv.quantity}`,
|
||||||
|
value: `${inv.product_id}|${inv.batch_number || ''}`
|
||||||
|
}))}
|
||||||
|
placeholder={loadingInventory ? "載入庫存中..." : "搜尋名稱或代號選擇庫存"}
|
||||||
|
className="w-full min-w-[200px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm font-mono">
|
<TableCell className="text-sm font-mono">
|
||||||
<div>{item.batch_number || '-'}</div>
|
<div>{item.batch_number || '-'}</div>
|
||||||
@@ -766,7 +626,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-semibold text-primary-main">
|
<TableCell className="text-right font-semibold text-primary-main">
|
||||||
{item.max_quantity} {item.unit || item.unit_name}
|
{item.product_id ? `${item.max_quantity} ${item.unit || item.unit_name || ''}` : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-1 py-3">
|
<TableCell className="px-1 py-3">
|
||||||
{isItemsReadOnly ? (
|
{isItemsReadOnly ? (
|
||||||
|
|||||||
@@ -175,15 +175,33 @@ export default function Show({ requisition, warehouses }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleApprove = () => {
|
const handleApprove = () => {
|
||||||
// 確認每個核准數量
|
// 確認每個核准數量與庫存上限
|
||||||
for (const item of approvedItems) {
|
for (const item of approvedItems) {
|
||||||
|
const originalItem = requisition.items.find(i => i.id === item.id);
|
||||||
|
if (!originalItem) continue;
|
||||||
|
|
||||||
for (const batch of item.batches) {
|
for (const batch of item.batches) {
|
||||||
if (batch.qty !== "") {
|
if (batch.qty !== "") {
|
||||||
const qty = parseFloat(batch.qty);
|
const qty = parseFloat(batch.qty);
|
||||||
if (isNaN(qty) || qty < 0) {
|
if (isNaN(qty) || qty < 0) {
|
||||||
toast.error("核准數量不能為負數");
|
toast.error("核准數量不能為負數或無效數字");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 檢查是否超過批號最大可用庫存
|
||||||
|
if (batch.inventory_id && originalItem.supply_batches) {
|
||||||
|
const originalBatch = originalItem.supply_batches.find(b => b.inventory_id === batch.inventory_id);
|
||||||
|
if (originalBatch && qty > originalBatch.available_qty) {
|
||||||
|
toast.error(`「${originalItem.product_name}」批號 ${originalBatch.batch_number || '無批號'} 數量不可大於庫存上限 (${originalBatch.available_qty})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (batch.inventory_id === null) {
|
||||||
|
// 無批號情境:檢查總可用庫存
|
||||||
|
if (originalItem.supply_stock !== null && qty > originalItem.supply_stock) {
|
||||||
|
toast.error(`「${originalItem.product_name}」數量不可大於供貨倉庫存上限 (${originalItem.supply_stock})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user