更新:優化配方詳情彈窗 UI 與一般修正
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s

This commit is contained in:
2026-01-29 16:13:56 +08:00
parent 7619dc24f7
commit 746eeb6f01
23 changed files with 1925 additions and 79 deletions

View File

@@ -0,0 +1,158 @@
---
name: 操作紀錄實作規範
description: 規範系統內 Activity Log 的實作標準,包含後端資料過濾、快照策略、與前端顯示邏輯。
---
# 操作紀錄實作規範
本文件說明如何在開發新功能時,依據系統規範實作 `spatie/laravel-activitylog` 操作紀錄,確保資料儲存效率與前端顯示一致性。
## 1. 後端實作標準 (Backend)
所有 Model 之操作紀錄應遵循「僅儲存變動資料」與「保留關鍵快照」兩大原則。
### 1.1 啟用 Activity Log
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
```php
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Product extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
->dontSubmitEmptyLogs(); // 若無變動則不記錄
}
}
```
### 1.2 手動記錄 (Manual Logging)
若需在 Controller 手動記錄(例如需客製化邏輯),**必須**自行實作變動過濾,不可直接儲存所有屬性。
**錯誤範例 (Do NOT do this):**
```php
// ❌ 錯誤:這會導致每次更新都記錄所有欄位,即使它們沒變
activity()
->withProperties(['attributes' => $newAttributes, 'old' => $oldAttributes])
->log('updated');
```
**正確範例 (Do this):**
```php
// ✅ 正確:自行比對差異,只存變動值
$changedAttributes = [];
$changedOldAttributes = [];
foreach ($newAttributes as $key => $value) {
if ($value != ($oldAttributes[$key] ?? null)) {
$changedAttributes[$key] = $value;
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
}
}
if (!empty($changedAttributes)) {
activity()
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
->log('updated');
}
```
### 1.3 快照策略 (Snapshot Strategy)
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊(如名稱、代號、類別名稱)。
**主要方式:使用 `tapActivity` (推薦)**
```php
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$properties = $activity->properties;
$snapshot = $properties['snapshot'] ?? [];
// 保存關鍵關聯名稱 (避免關聯資料刪除後 ID 失效)
$snapshot['category_name'] = $this->category ? $this->category->name : null;
$snapshot['po_number'] = $this->code; // 儲存單號
// 保存自身名稱 (Context)
$snapshot['name'] = $this->name;
$properties['snapshot'] = $snapshot;
$activity->properties = $properties;
}
```
## 2. 顯示名稱映射 (UI Mapping)
### 2.1 對象名稱映射 (Mapping)
需在 `ActivityLogController.php` 中設定 Model 與中文名稱的對應,讓前端列表能顯示中文對象(如「公共事業費」而非 `UtilityFee`)。
**位置**: `app/Http/Controllers/Admin/ActivityLogController.php`
```php
protected function getSubjectMap()
{
return [
'App\Modules\Inventory\Models\Product' => '商品',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費', // ✅ 新增映射
];
}
```
### 2.2 欄位名稱中文化 (Field Translation)
需在前端 `ActivityDetailDialog` 中設定欄位名稱的中文翻譯。
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
```typescript
const fieldLabels: Record<string, string> = {
// ... 既有欄位
'transaction_date': '費用日期',
'category': '費用類別',
'amount': '金額',
};
```
## 3. 前端顯示邏輯 (Frontend)
### 3.1 列表描述生成 (Description Generation)
前端 `LogTable.tsx` 會依據 `properties.snapshot` 中的欄位自動組建描述例如「Admin 新增 電話費 公共事業費」)。
若您的 Model 使用了特殊的識別欄位(例如 `category`**必須**將其加入 `nameParams` 陣列中。
**位置**: `resources/js/Components/ActivityLog/LogTable.tsx`
```typescript
const nameParams = [
'po_number', 'name', 'code',
'category_name',
'category' // ✅ 確保加入此欄位,前端才能抓到 $snapshot['category']
];
```
### 3.2 詳情過濾邏輯
前端 `ActivityDetailDialog` 已內建智慧過濾邏輯:
- **Created**: 顯示初始化欄位。
- **Updated**: **僅顯示有變動的欄位** (由 `isChanged` 判斷)。
- **Deleted**: 顯示刪除前的完整資料。
開發者僅需確保傳入的 `attributes``old` 資料結構正確,過濾邏輯會自動運作。
## 檢核清單
- [ ] **Backend**: Model 是否已設定 `logOnlyDirty` 或手動實作過濾?
- [ ] **Backend**: 是否已透過 `tapActivity` 或手動方式記錄 Snapshot關鍵名稱
- [ ] **Backend**: 是否已在 `ActivityLogController` 加入 Model 中文名稱映射?
- [ ] **Frontend**: 是否已在 `ActivityDetailDialog` 加入欄位中文翻譯?
- [ ] **Frontend**: 若使用特殊識別欄位,是否已加入 `LogTable``nameParams`

View File

@@ -0,0 +1,140 @@
---
name: 權限管理與實作規範
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
---
# 權限管理與實作規範
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
## 1. 定義權限 (Backend)
所有權限皆定義於 `database/seeders/PermissionSeeder.php`
### 步驟:
1. 開啟 `database/seeders/PermissionSeeder.php`
2. 在 `$permissions` 陣列中新增功能對應的權限字串。
* **命名慣例**`{resource}.{action}` (例如:`system.view_logs`, `products.create`)
* 常用動作:`view`, `create`, `edit`, `delete`, `publish`, `export`
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
* `super-admin`:通常擁有所有權限(程式碼中 `Permission::all()` 自動涵蓋,無需手動新增)。
* `admin`:通常擁有大部分權限。
* 其他角色 (`warehouse-manager`, `purchaser`, `viewer`):依業務邏輯分配。
### 範例:
```php
// 1. 新增權限字串
$permissions = [
// ... 現有權限
'system.view_logs', // 新增:檢視系統日誌
];
// ...
// 2. 分配給角色
$admin->givePermissionTo([
// ... 現有權限
'system.view_logs',
]);
```
## 2. 套用資料庫變更
修改 Seeder 後,必須重新執行 Seeder 以將權限寫入資料庫。
```bash
# 對於所有租戶執行 Seeder (開發環境)
php artisan tenants:seed --class=PermissionSeeder
```
## 3. 路由保護 (Backend Middleware)
`routes/web.php` 中,使用 `permission:{name}` middleware 保護路由。
### 範例:
```php
// 單一權限保護
Route::get('/logs', [LogController::class, 'index'])
->middleware('permission:system.view_logs')
->name('logs.index');
// 路由群組保護
Route::middleware('permission:products.view')->group(function () {
// ...
});
// 多重權限 (OR 邏輯:有其一即可)
Route::middleware('permission:products.create|products.edit')->group(function () {
// ...
});
```
## 4. 前端權限判斷 (React Component)
使用自訂 Hook `usePermission` 來控制 UI 元素的顯示(例如:隱藏沒有權限的按鈕)。
### 引入 Hook
```tsx
import { usePermission } from "@/hooks/usePermission";
```
### 使用方式:
```tsx
export default function ProductIndex() {
const { can } = usePermission();
return (
<div>
<h1>商品列表</h1>
{/* 只有擁有 create 權限才顯示按鈕 */}
{can('products.create') && (
<Button>新增商品</Button>
)}
{/* 組合判斷 */}
{can('products.edit') && <EditButton />}
</div>
);
}
```
### 權限 Hook 介面說明:
- `can(permission: string)`: 檢查當前使用者是否擁有指定權限。
- `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。
- `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。
## 5. 配置權限群組名稱 (Backend UI Config)
為了讓新權限在「角色與權限」管理介面中顯示正確的中文分組標題,需修改 Controller 設定。
### 步驟:
1. 開啟 `app/Http/Controllers/Admin/RoleController.php`
2. 找到 `getGroupedPermissions` 方法。
3. 在 `$groupDefinitions` 陣列中,新增 `{resource}` 對應的中文名稱。
### 範例:
```php
$groupDefinitions = [
'products' => '商品資料管理',
// ...
'utility_fees' => '公共事業費管理', // 新增此行
];
```
## 檢核清單
- [ ] `PermissionSeeder.php` 已新增權限字串。
- [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。
- [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。
- [ ] `RoleController.php` 已新增權限群組的中文名稱映射。
- [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。
- [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。

View File

@@ -0,0 +1,990 @@
---
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. **權限控制**:所有操作按鈕必須使用 `<Can>` 元件包裹
---
## 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
<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.1 按鈕樣式類別
專案在 `resources/css/app.css` 中定義了統一的按鈕樣式,**必須**使用這些類別:
#### Filled 按鈕(實心按鈕)— 用於主要操作
```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>
```
---
## 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.1 表格容器
```tsx
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
{/* 表格內容 */}
</Table>
</div>
```
### 5.2 表格標題列
```tsx
<TableHeader className="bg-gray-50">
<TableRow>
<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().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";
// 在表格下方
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>每頁顯示</span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[80px] h-8"
showSearch={false}
/>
<span>筆</span>
</div>
<Pagination links={data.links} />
</div>
```
### 6.2 每頁筆數狀態管理
```tsx
const [perPage, setPerPage] = useState<string>(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 樣式(最常用)
<Badge variant="outline">{item.category?.name || '-'}</Badge>
// 預設樣式(主題色背景)
<Badge variant="default">啟用中</Badge>
// 錯誤樣式
<Badge variant="destructive">停用</Badge>
```
### 7.2 角色顯示(特殊樣式)
```tsx
<div className="flex flex-wrap gap-2">
{user.roles.map(role => (
<div
key={role.id}
className={cn(
"inline-flex items-center px-2.5 py-1 rounded-md border",
role.name === 'super-admin'
? "bg-purple-50 border-purple-200"
: "bg-gray-50 border-gray-200"
)}
>
<div className="flex items-center gap-1.5">
{role.name === 'super-admin' && <Shield className="h-3.5 w-3.5 text-purple-600" />}
<span className={cn(
"text-sm font-medium",
role.name === 'super-admin' ? "text-purple-700" : "text-gray-900"
)}>
{role.display_name}
</span>
</div>
</div>
))}
</div>
```
---
## 8. 頁面佈局規範
### 8.1 頁面結構
```tsx
export default function ResourceIndex() {
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '分類名稱', href: '#' },
{ label: '頁面名稱', href: route('resource.index'), isPage: true },
]}
>
<Head title="頁面標題" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面頭部 */}
{/* 主要內容 */}
{/* 分頁元件 */}
</div>
</AuthenticatedLayout>
);
}
```
### 8.2 標準頁面頭部
```tsx
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<IconComponent className="h-6 w-6 text-[#01ab83]" />
頁面標題
</h1>
<p className="text-gray-500 mt-1">
頁面說明文字
</p>
</div>
<Can permission="resource.create">
<Link href={route('resource.create')}>
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增項目
</Button>
</Link>
</Can>
</div>
```
---
## 9. 權限控制規範
### 9.1 使用 Can 元件
**所有**涉及權限的 UI 元素都必須使用 `<Can>` 元件包裹:
```tsx
import { Can } from "@/Components/Permission/Can";
<Can permission="resource.create">
{/* 新增按鈕 */}
</Can>
<Can permission="resource.edit">
{/* 編輯按鈕 */}
</Can>
<Can permission="resource.delete">
{/* 刪除按鈕 */}
</Can>
```
### 9.2 權限命名規範
遵循 `resource.action` 格式:
- `resource.view`:查看列表/詳情
- `resource.create`:新增
- `resource.edit`:編輯
- `resource.delete`:刪除
### 9.3 多權限判斷
```tsx
// 滿足任一權限即可
<Can permission={['products.edit', 'products.delete']}>
<div>管理操作</div>
</Can>
// 必須滿足所有權限
import { CanAll } from "@/Components/Permission/Can";
<CanAll permissions={['products.edit', 'products.delete']}>
<button>完整管理</button>
</CanAll>
```
---
## 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
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
<form onSubmit={handleSubmit}>
{/* 表單欄位 */}
</form>
</div>
```
### 11.2 表單欄位
```tsx
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
欄位名稱 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={data.field}
onChange={(e) => 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 && <p className="mt-1 text-sm text-red-500">{errors.field}</p>}
</div>
```
### 11.3 下拉選單
使用 `SearchableSelect` 元件:
```tsx
import { SearchableSelect } from "@/Components/ui/searchable-select";
<SearchableSelect
value={data.category_id}
onValueChange={(value) => 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 或固定高度計算
<DialogContent className="max-w-3xl">
<ScrollArea className="h-[500px]">
{/* 內容 */}
</ScrollArea>
</DialogContent>
// ✅ 正確:直接使用 overflow-y-auto 與 max-h
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>...</DialogHeader>
<form className="p-6">
{/* 內容會自動滾動 */}
</form>
<DialogFooter>...</DialogFooter>
</DialogContent>
```
---
## 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";
import { Input } from "@/Components/ui/input";
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
className="pl-9 block w-full"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
```
## 11.7 搜尋選單樣式 (SearchableSelect Style)
`SearchableSelect` 元件在表單或篩選列中使用時,高度必須設定為 `h-9` 以與輸入框對齊。
```tsx
<SearchableSelect
className="h-9" // 確保高度一致
// ...other props
/>
```
## 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
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
<Input className="h-9" placeholder="..." />
</div>
```
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
### ✅ 權限
- [ ] 所有操作按鈕都用 `<Can>` 包裹
- [ ] 權限命名符合 `resource.action` 格式
### ✅ 通知
- [ ] 使用 `toast` 提供操作反饋
- [ ] 成功/錯誤訊息明確
### ✅ 整體
- [ ] 頁面有標準頭部(標題 + 圖標 + 說明 + 新增按鈕)
- [ ] 容器寬度使用 `max-w-7xl`
- [ ] 使用正確的佈局(`AuthenticatedLayout`
---
## 13. 常見錯誤與修正
### ❌ 錯誤:自定義按鈕樣式
```tsx
// ❌ 錯誤
<Button className="bg-green-500 text-white hover:bg-green-600">
新增
</Button>
// ✅ 正確
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
新增
</Button>
```
### ❌ 錯誤:混用圖標庫
```tsx
// ❌ 錯誤
import { FaEdit } from 'react-icons/fa';
<FaEdit />
// ✅ 正確
import { Pencil } from 'lucide-react';
<Pencil className="h-4 w-4" />
```
### ❌ 錯誤:操作欄未置中
```tsx
// ❌ 錯誤
<TableCell>
<Button>編輯</Button>
<Button>刪除</Button>
</TableCell>
// ✅ 正確
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<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>
</div>
</TableCell>
```
### ❌ 錯誤:缺少權限控制
```tsx
// ❌ 錯誤
<Button onClick={handleDelete}>刪除</Button>
// ✅ 正確
<Can permission="resource.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</Can>
```
---
## 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 時,請務必參考此規範!

View File

@@ -102,6 +102,7 @@ jobs:
# 3. Laravel 初始化與優化
php artisan storage:link &&
php artisan migrate --force &&
php artisan tenants:migrate --force &&
php artisan db:seed --force &&
php artisan optimize:clear &&
php artisan optimize &&
@@ -195,6 +196,7 @@ jobs:
php artisan storage:link &&
php artisan migrate --force &&
php artisan tenants:migrate --force &&
php artisan optimize:clear &&
php artisan optimize &&
php artisan view:cache

View File

@@ -25,6 +25,7 @@ class ProductController extends Controller
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('brand', 'like', "%{$search}%");
});
}
@@ -66,6 +67,7 @@ class ProductController extends Controller
return (object) [
'id' => (string) $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'categoryId' => $product->category_id,
'category' => $product->category ? (object) [
@@ -110,6 +112,7 @@ class ProductController extends Controller
{
$validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code',
'barcode' => 'required|string|unique:products,barcode',
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
@@ -123,6 +126,8 @@ class ProductController extends Controller
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
@@ -145,6 +150,7 @@ class ProductController extends Controller
{
$validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
'barcode' => 'required|string|unique:products,barcode,' . $product->id,
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
@@ -157,6 +163,8 @@ class ProductController extends Controller
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',

View File

@@ -17,6 +17,7 @@ class Product extends Model
protected $fillable = [
'code',
'barcode',
'name',
'category_id',
'brand',

View File

@@ -8,20 +8,28 @@ use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Core\Contracts\CoreServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class ProductionOrderController extends Controller
{
protected $inventoryService;
protected $coreService;
protected $procurementService;
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
public function __construct(
InventoryServiceInterface $inventoryService,
CoreServiceInterface $coreService,
ProcurementServiceInterface $procurementService
)
{
$this->inventoryService = $inventoryService;
$this->coreService = $coreService;
$this->procurementService = $procurementService;
}
/**
@@ -37,9 +45,6 @@ class ProductionOrderController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('code', 'like', "%{$search}%")
->orWhere('output_batch_number', 'like', "%{$search}%");
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
$q->where('code', 'like', "%{$search}%")
->orWhere('output_batch_number', 'like', "%{$search}%");
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
@@ -205,15 +210,29 @@ class ProductionOrderController extends Controller
// 手動水和明細資料
$items = $productionOrder->items;
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
$inventories = $this->inventoryService->getInventoriesByIds(
$inventoryIds,
['product.baseUnit', 'sourcePurchaseOrder.vendor']
['product.baseUnit']
)->keyBy('id');
// 手動載入 Purchase Orders
$poIds = $inventories->pluck('source_purchase_order_id')->unique()->filter()->toArray();
$purchaseOrders = collect();
if (!empty($poIds)) {
$purchaseOrders = $this->procurementService->getPurchaseOrdersByIds($poIds, ['vendor'])->keyBy('id');
}
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->inventory = $inventories->get($item->inventory_id);
if ($item->inventory) {
// 手動掛載 PO
$poId = $item->inventory->source_purchase_order_id;
$item->inventory->sourcePurchaseOrder = $purchaseOrders->get($poId);
}
$item->unit = $units->get($item->unit_id);
}

View File

@@ -188,4 +188,118 @@ class RecipeController extends Controller
$recipe->delete();
return redirect()->back()->with('success', '配方已刪除');
}
/**
* 獲取配方詳細資料 (API)
*/
/**
* 獲取配方詳細資料 (API)
*/
public function show(Recipe $recipe)
{
// Manual Hydration for strict modularity
$recipe->product = $this->inventoryService->getProduct($recipe->product_id);
$items = $recipe->items;
$productIds = $items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->product = $products->get($item->product_id);
$item->unit = $units->get($item->unit_id);
}
return response()->json($recipe);
}
/**
* 獲取商品最新有效配方 (API)
*/
public function getLatestByProduct($productId)
{
// 放寬條件,只要 product_id 相符就抓最新的
$recipe = Recipe::where('product_id', (int)$productId)
->orderBy('created_at', 'desc')
->first();
if (!$recipe) {
return response()->json(null);
}
// Load items with product info
$items = $recipe->items;
$productIds = $items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$formattedItems = $items->map(function ($item) use ($products) {
$product = $products->get($item->product_id);
return [
'product_id' => $item->product_id,
'product_name' => $product->name ?? '未知商品',
'product_code' => $product->code ?? '',
'quantity' => $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $product->baseUnit->name ?? '',
];
});
return response()->json([
'id' => $recipe->id,
'name' => $recipe->name,
'code' => $recipe->code,
'yield_quantity' => $recipe->yield_quantity,
'items' => $formattedItems,
]);
}
/**
* 獲取商品所有有效配方列表 (API)
*/
public function getByProduct($productId)
{
$recipes = Recipe::where('product_id', (int)$productId)
->where('is_active', true)
->orderBy('created_at', 'desc')
->get();
if ($recipes->isEmpty()) {
return response()->json([]);
}
// 預先載入必要的關聯與數據
// 為了效能,我們只在列表顯示基本資訊,詳細 Item 資料等選中後再透過 getLatestByProduct (或是重構為 getDetails) 獲取
// 不過為了前端方便,若配方不多,直接回傳完整結構也可以。
// 這裡選擇回傳完整結構,因為配方通常不會太多
$recipes->load('items');
// 收集所有 recipe items 中的 product ids
$allProductIds = $recipes->pluck('items')->flatten()->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($allProductIds)->keyBy('id');
$result = $recipes->map(function ($recipe) use ($products) {
$formattedItems = $recipe->items->map(function ($item) use ($products) {
$product = $products->get($item->product_id);
return [
'product_id' => $item->product_id,
'product_name' => $product->name ?? '未知商品',
'product_code' => $product->code ?? '',
'quantity' => $item->quantity,
'unit_id' => $item->unit_id,
'unit_name' => $product->baseUnit->name ?? '',
];
});
return [
'id' => $recipe->id,
'name' => $recipe->name,
'code' => $recipe->code,
'yield_quantity' => $recipe->yield_quantity,
'items' => $formattedItems,
'created_at' => $recipe->created_at->toIso8601String(),
];
});
return response()->json($result);
}
}

View File

@@ -27,5 +27,13 @@ class RecipeItem extends Model
return $this->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);
}
}

View File

@@ -29,4 +29,10 @@ Route::middleware('auth')->group(function () {
Route::get('/api/production/warehouses/{warehouse}/inventories', [ProductionOrderController::class, 'getWarehouseInventories'])
->middleware('permission:production_orders.create')
->name('api.production.warehouses.inventories');
Route::get('/api/production/recipes/latest-by-product/{productId}', [RecipeController::class, 'getLatestByProduct'])
->name('api.production.recipes.latest-by-product');
Route::get('/api/production/recipes/by-product/{productId}', [RecipeController::class, 'getByProduct'])
->name('api.production.recipes.by-product');
});

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->string('barcode')->nullable()->unique()->index()->after('code')->comment('條碼編號');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('barcode');
});
}
};

59
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/lodash": "^4.17.21",
@@ -75,7 +76,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1672,6 +1672,52 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -2539,7 +2585,6 @@
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2550,7 +2595,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2561,7 +2605,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2669,7 +2712,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2882,8 +2924,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
@@ -3762,7 +3803,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3824,7 +3864,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -3837,7 +3876,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -4330,7 +4368,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",

View File

@@ -31,6 +31,7 @@
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/lodash": "^4.17.21",

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { Wand2 } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -36,6 +37,7 @@ export default function ProductDialog({
}: ProductDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
code: "",
barcode: "",
name: "",
category_id: "",
brand: "",
@@ -52,6 +54,7 @@ export default function ProductDialog({
if (product) {
setData({
code: product.code,
barcode: product.barcode || "",
name: product.name,
category_id: product.categoryId.toString(),
brand: product.brand || "",
@@ -99,6 +102,11 @@ export default function ProductDialog({
}
};
const generateRandomBarcode = () => {
const randomDigits = Math.floor(Math.random() * 9000000000) + 1000000000;
setData("barcode", randomDigits.toString());
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
@@ -159,6 +167,32 @@ export default function ProductDialog({
{errors.code && <p className="text-sm text-red-500">{errors.code}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="barcode">
<span className="text-red-500">*</span>
</Label>
<div className="flex gap-2">
<Input
id="barcode"
value={data.barcode}
onChange={(e) => setData("barcode", e.target.value)}
placeholder="輸入條碼或自動生成"
className={`flex-1 ${errors.barcode ? "border-red-500" : ""}`}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={generateRandomBarcode}
title="隨機生成條碼"
className="shrink-0 button-outlined-primary"
>
<Wand2 className="h-4 w-4" />
</Button>
</div>
{errors.barcode && <p className="text-sm text-red-500">{errors.barcode}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="brand"></Label>
<Input

View File

@@ -74,11 +74,7 @@ export default function ProductTable({
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead>
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900">
<SortIcon field="code" />
</button>
</TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead>
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900">
<SortIcon field="name" />
@@ -112,12 +108,15 @@ export default function ProductTable({
{startIndex + index}
</TableCell>
<TableCell className="font-mono text-sm text-gray-700">
{product.code}
{product.barcode || "-"}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{product.name}</span>
{product.brand && <span className="text-xs text-gray-400">{product.brand}</span>}
<div className="flex items-center gap-2">
<span className="font-medium text-grey-0">{product.name}</span>
{product.brand && <Badge variant="secondary" className="text-[10px] h-4 px-1 bg-gray-100 text-gray-500 border-none">{product.brand}</Badge>}
</div>
<span className="text-xs text-gray-400 font-mono">: {product.code}</span>
</div>
</TableCell>
<TableCell>

View File

@@ -281,7 +281,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600"></AlertDialogAction>
<AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -317,7 +317,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="bg-primary-600 hover:bg-primary-700"></AlertDialogAction>
<AlertDialogAction onClick={handlePost} className="button-filled-primary"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -259,14 +259,14 @@ export default function Show({ doc }: any) {
</div>
</TableCell>
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
<TableCell className="text-right font-medium">{item.system_qty.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{item.system_qty.toFixed(0)}</TableCell>
<TableCell className="text-right px-1 py-3">
{isCompleted ? (
<span className="font-semibold mr-2">{item.counted_qty}</span>
) : (
<Input
type="number"
step="0.01"
step="1"
value={formItem.counted_qty ?? ''}
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
onWheel={(e: any) => e.target.blur()}
@@ -284,7 +284,7 @@ export default function Show({ doc }: any) {
: 'text-red-600'
}`}>
{formItem.counted_qty !== '' && formItem.counted_qty !== null
? diff.toFixed(2)
? diff.toFixed(0)
: '-'}
</span>
</TableCell>

View File

@@ -22,6 +22,7 @@ export interface Category {
export interface Product {
id: string;
code: string;
barcode?: string;
name: string;
categoryId: number;
category?: Category;

View File

@@ -4,11 +4,11 @@
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar, AlertCircle } from 'lucide-react';
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm } from "@inertiajs/react";
import toast, { Toaster } from 'react-hot-toast';
import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
@@ -90,12 +90,17 @@ export default function ProductionCreate({ products, warehouses }: Props) {
const [bomItems, setBomItems] = useState<BomItem[]>([]);
// 多配方支援
const [recipes, setRecipes] = useState<any[]>([]);
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
const { data, setData, processing, errors } = useForm({
product_id: "",
warehouse_id: "",
output_quantity: "",
output_batch_number: "",
output_box_count: "",
// 移除 Box Count UI
// 移除相關邏輯
production_date: new Date().toISOString().split('T')[0],
expiry_date: "",
remark: "",
@@ -244,34 +249,116 @@ export default function ProductionCreate({ products, warehouses }: Props) {
})));
}, [bomItems]);
// 自動產生成品批號(當選擇商品或日期變動時)
// 應用配方到表單 (獨立函式)
const applyRecipe = (recipe: any) => {
if (!recipe || !recipe.items) return;
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量
setData('output_quantity', String(yieldQty));
const ratio = 1;
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0");
const calculatedQty = (baseQty * ratio).toFixed(4); // 保持精度
return {
inventory_id: "",
quantity_used: String(calculatedQty),
unit_id: String(item.unit_id),
ui_warehouse_id: selectedWarehouse || "", // 自動帶入目前選擇的倉庫
ui_product_id: String(item.product_id),
ui_product_name: item.product_name,
ui_batch_number: "",
ui_available_qty: 0,
ui_input_quantity: String(calculatedQty),
ui_selected_unit: 'base',
ui_base_unit_name: item.unit_name,
ui_base_unit_id: item.unit_id,
ui_conversion_rate: 1,
};
});
setBomItems(newBomItems);
// 若有選倉庫,預先載入庫存資料以供選擇
if (selectedWarehouse) {
fetchWarehouseInventory(selectedWarehouse);
}
toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${yieldQty}`
});
};
// 當手動切換配方時
useEffect(() => {
if (!selectedRecipeId) return;
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
if (targetRecipe) {
applyRecipe(targetRecipe);
}
}, [selectedRecipeId]);
// 自動產生成品批號與載入配方
useEffect(() => {
if (!data.product_id) return;
// 1. 自動產生成品批號
const product = products.find(p => String(p.id) === data.product_id);
if (!product) return;
if (product) {
const datePart = data.production_date;
const dateFormatted = datePart.replace(/-/g, '');
const originCountry = 'TW';
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
const datePart = data.production_date; // YYYY-MM-DD
const dateFormatted = datePart.replace(/-/g, '');
const originCountry = 'TW';
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
.then(res => res.json())
.then(result => {
const seq = result.nextSequence || '01';
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
setData('output_batch_number', suggested);
})
.catch(() => {
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
setData('output_batch_number', suggested);
});
}
// 呼叫 API 取得下一組流水號
// 複用庫存批號 API但這裡可能沒有選 warehouse所以用第一個預設
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
// 2. 自動載入配方列表
const fetchRecipes = async () => {
try {
// 改為抓取所有配方
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
const recipesData = await res.json();
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
.then(res => res.json())
.then(result => {
const seq = result.nextSequence || '01';
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
setData('output_batch_number', suggested);
})
.catch(() => {
// Fallback若 API 失敗,使用預設 01
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
setData('output_batch_number', suggested);
});
}, [data.product_id, data.production_date]);
if (Array.isArray(recipesData) && recipesData.length > 0) {
setRecipes(recipesData);
// 預設選取最新的 (第一個)
const latest = recipesData[0];
setSelectedRecipeId(String(latest.id));
} else {
// 若無配方
setRecipes([]);
setSelectedRecipeId("");
setBomItems([]); // 清空 BOM
}
} catch (e) {
console.error("Failed to fetch recipes", e);
setRecipes([]);
setBomItems([]);
}
};
fetchRecipes();
}, [data.product_id]);
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
useEffect(() => {
if (bomItems.length > 0 && data.output_quantity) {
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
// 但如果是剛載入inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
}
}, [data.output_quantity]);
// 提交表單
const submit = (status: 'draft' | 'completed') => {
@@ -286,12 +373,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm">{missingFields.join('、')}</span>
</div>
);
toast.error("請填寫必要欄位", {
description: `缺漏:${missingFields.join('、')}`
});
return;
}
}
@@ -313,12 +397,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
}, {
onError: (errors) => {
const errorCount = Object.keys(errors).length;
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm"> {errorCount} </span>
</div>
);
toast.error("建立失敗,請檢查表單", {
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
});
}
});
};
@@ -331,7 +412,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title="建立生產單" />
<Toaster position="top-right" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<Link href={route('production-orders.index')}>
@@ -394,6 +475,28 @@ export default function ProductionCreate({ products, warehouses }: Props) {
className="w-full h-9"
/>
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
{/* 配方選擇 (放在成品商品底下) */}
{recipes.length > 0 && (
<div className="pt-2">
<div className="flex justify-between items-center mb-1">
<Label className="text-xs font-medium text-grey-2">使</Label>
<span className="text-[10px] text-blue-500">
</span>
</div>
<SearchableSelect
value={selectedRecipeId}
onValueChange={setSelectedRecipeId}
options={recipes.map(r => ({
label: `${r.name} (${r.code})`,
value: String(r.id),
}))}
placeholder="選擇配方"
className="w-full h-9"
/>
</div>
)}
</div>
<div className="space-y-1">
@@ -420,15 +523,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Input
value={data.output_box_count}
onChange={(e) => setData('output_box_count', e.target.value)}
placeholder="例如: 10"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>

View File

@@ -0,0 +1,166 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Loader2, Package, Calendar, Clock, BookOpen } from "lucide-react";
interface RecipeDetailModalProps {
isOpen: boolean;
onClose: () => void;
recipe: any | null; // Detailed recipe object with items
isLoading?: boolean;
}
export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: RecipeDetailModalProps) {
if (!isOpen) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0">
<DialogHeader className="p-6 pb-4 border-b pr-12">
<div className="flex items-center gap-3 mb-2">
<DialogTitle className="text-xl font-bold text-gray-900">
</DialogTitle>
{recipe && (
<Badge variant={recipe.is_active ? "default" : "secondary"} className="text-xs font-normal">
{recipe.is_active ? "啟用中" : "已停用"}
</Badge>
)}
</div>
{/* 現代化元數據條 */}
{recipe && (
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-gray-400" />
<span className="font-medium text-gray-700">{recipe.code}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span> {new Date(recipe.created_at).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-400" />
<span> {new Date(recipe.updated_at).toLocaleDateString()}</span>
</div>
</div>
)}
</DialogHeader>
<div className="bg-gray-50/50 p-6 min-h-[300px]">
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary-main" />
</div>
) : recipe ? (
<div className="space-y-6">
{/* 基本資訊區塊 */}
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50 hover:bg-gray-50/50">
<TableHead className="w-[150px]"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium text-gray-700"></TableCell>
<TableCell className="text-gray-900 font-medium">{recipe.name}</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium text-gray-700"></TableCell>
<TableCell className="text-gray-900 font-medium">
<div className="flex items-center gap-2">
<span>{recipe.product?.name || '-'}</span>
<span className="text-gray-400 text-xs bg-gray-100 px-1.5 py-0.5 rounded">{recipe.product?.code}</span>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium text-gray-700"></TableCell>
<TableCell className="text-gray-900 font-medium">
{Number(recipe.yield_quantity).toLocaleString()} {recipe.product?.base_unit?.name || '份'}
</TableCell>
</TableRow>
{recipe.description && (
<TableRow>
<TableCell className="font-medium text-gray-700 align-top pt-3"></TableCell>
<TableCell className="text-gray-600 leading-relaxed py-3">
{recipe.description}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* BOM 表格區塊 */}
<div>
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2 px-1 mb-3">
<Package className="w-4 h-4 text-primary-main" />
(BOM)
</h3>
<div className="border rounded-md overflow-hidden bg-white shadow-sm">
<Table>
<TableHeader className="bg-gray-50/50">
<TableRow>
<TableHead> / </TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipe.items?.length > 0 ? (
recipe.items.map((item: any, index: number) => (
<TableRow key={index} className="hover:bg-gray-50/50">
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="text-gray-900">{item.product?.name || 'Unknown'}</span>
<span className="text-xs text-gray-400">{item.product?.code}</span>
</div>
</TableCell>
<TableCell className="text-right font-medium text-gray-900">
{Number(item.quantity).toLocaleString()}
</TableCell>
<TableCell className="text-gray-600">
{item.unit?.name || '-'}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{item.remark || '-'}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
) : (
<div className="py-12 text-center text-gray-500"></div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -3,7 +3,7 @@
*/
import { useState, useEffect } from "react";
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen } from 'lucide-react';
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen, Eye } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
@@ -15,6 +15,8 @@ import { Label } from "@/Components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Can } from "@/Components/Permission/Can";
import { RecipeDetailModal } from "./Components/RecipeDetailModal";
import axios from 'axios';
import {
AlertDialog,
AlertDialogAction,
@@ -59,6 +61,11 @@ export default function RecipeIndex({ recipes, filters }: Props) {
const [search, setSearch] = useState(filters.search || "");
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
// View Modal State
const [viewRecipe, setViewRecipe] = useState<any | null>(null);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [isViewLoading, setIsViewLoading] = useState(false);
useEffect(() => {
setSearch(filters.search || "");
setPerPage(filters.per_page || "10");
@@ -95,6 +102,20 @@ export default function RecipeIndex({ recipes, filters }: Props) {
}
};
const handleView = async (id: number) => {
setIsViewModalOpen(true);
setIsViewLoading(true);
setViewRecipe(null);
try {
const response = await axios.get(route('recipes.show', id));
setViewRecipe(response.data);
} catch (error) {
console.error("Failed to load recipe details", error);
} finally {
setIsViewLoading(false);
}
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
<Head title="配方管理" />
@@ -171,7 +192,7 @@ export default function RecipeIndex({ recipes, filters }: Props) {
<TableHead className="text-right"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="text-center w-[120px]"></TableHead>
<TableHead className="text-center w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -221,6 +242,17 @@ export default function RecipeIndex({ recipes, filters }: Props) {
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Can permission="recipes.view">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查看明細"
onClick={() => handleView(recipe.id)}
>
<Eye className="h-4 w-4" />
</Button>
</Can>
<Can permission="recipes.edit">
<Link href={route('recipes.edit', recipe.id)}>
<Button
@@ -296,6 +328,13 @@ export default function RecipeIndex({ recipes, filters }: Props) {
<Pagination links={recipes.links} />
</div>
</div>
<RecipeDetailModal
isOpen={isViewModalOpen}
onClose={() => setIsViewModalOpen(false)}
recipe={viewRecipe}
isLoading={isViewLoading}
/>
</div>
</AuthenticatedLayout>
);

View File

@@ -130,7 +130,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
<CardContent className="p-6">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-500 mb-1"></span>
<span className="text-3xl font-bold text-blue-600">
<span className="text-3xl font-bold text-primary-main">
{totals.available_stock.toLocaleString()}
</span>
</div>

File diff suppressed because one or more lines are too long