更新:優化配方詳情彈窗 UI 與一般修正
This commit is contained in:
158
.gemini/antigravity/skills/activity-logging/SKILL.md
Normal file
158
.gemini/antigravity/skills/activity-logging/SKILL.md
Normal 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`?
|
||||
140
.gemini/antigravity/skills/permission-management/SKILL.md
Normal file
140
.gemini/antigravity/skills/permission-management/SKILL.md
Normal 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` 進行顯示控制。
|
||||
990
.gemini/antigravity/skills/ui-consistency/SKILL.md
Normal file
990
.gemini/antigravity/skills/ui-consistency/SKILL.md
Normal 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 時,請務必參考此規範!
|
||||
@@ -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
|
||||
|
||||
@@ -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' => '所選分類不存在',
|
||||
|
||||
@@ -17,6 +17,7 @@ class Product extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'barcode',
|
||||
'name',
|
||||
'category_id',
|
||||
'brand',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
59
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Category {
|
||||
export interface Product {
|
||||
id: string;
|
||||
code: string;
|
||||
barcode?: string;
|
||||
name: string;
|
||||
categoryId: number;
|
||||
category?: Category;
|
||||
|
||||
@@ -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,19 +249,66 @@ 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;
|
||||
|
||||
const datePart = data.production_date; // YYYY-MM-DD
|
||||
if (product) {
|
||||
const datePart = data.production_date;
|
||||
const dateFormatted = datePart.replace(/-/g, '');
|
||||
const originCountry = 'TW';
|
||||
|
||||
// 呼叫 API 取得下一組流水號
|
||||
// 複用庫存批號 API,但這裡可能沒有選 warehouse,所以用第一個預設
|
||||
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
|
||||
|
||||
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
|
||||
@@ -267,11 +319,46 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
||||
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]);
|
||||
}
|
||||
|
||||
// 2. 自動載入配方列表
|
||||
const fetchRecipes = async () => {
|
||||
try {
|
||||
// 改為抓取所有配方
|
||||
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
||||
const recipesData = await res.json();
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user