Compare commits

...

15 Commits

Author SHA1 Message Date
e1aa452b3c fix(product): 補回清單頁面的 is_active 資料回傳並修正表格 colSpan
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 50s
2026-02-05 16:19:13 +08:00
397a8a6484 fix(product): 設定 is_active 欄位預設為 true 並更新現有資料為啟用 2026-02-05 16:18:03 +08:00
24aed44cd3 feat(product): 恢復並實作商品起停用狀態功能,包含表單開關與列表顯示 2026-02-05 16:15:06 +08:00
196fec3120 feat(product): 修正控制器邏輯並徹底移除商品起停用開關,統一設為啟用 2026-02-05 16:13:24 +08:00
096a114457 feat(product): 移除商品起停用功能,後端預設所有商品為啟用狀態 2026-02-05 16:12:55 +08:00
af06ca7695 fix(product): 修正商品詳情與編輯頁面的麵包屑層級,支援動態導航 2026-02-05 16:02:20 +08:00
1d5bc68444 feat(product): 優化編輯後的跳轉邏輯,支援依來源回傳詳情頁或列表 2026-02-05 16:01:29 +08:00
075b9f1c98 feat(product): 新增商品詳情查看功能 2026-02-05 15:58:59 +08:00
49bb05d85a style(warehouse): 根據使用者要求調整統計標籤文字 2026-02-05 15:54:24 +08:00
687af254bd style(warehouse): 優化瑕疵倉顯示邏輯並簡化標籤為「過期統計」 2026-02-05 15:53:24 +08:00
a518d390bd feat(inventory): 實作過期與瑕疵庫存總計顯示,並強化庫存明細過期提示 2026-02-05 15:50:14 +08:00
ba3c10ac13 feat(warehouse): 庫存統計卡片加入總金額顯示 (可用/帳面)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 48s
2026-02-05 13:18:22 +08:00
dada3a6512 feat(product): 商品代號加入隨機產生按鈕 (8碼大寫英數)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 58s
2026-02-05 13:12:52 +08:00
b99e391cc6 feat(inventory): 盤點單列印格式加入批號欄位(位於品名之後)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 53s
2026-02-05 13:05:50 +08:00
0aa7fd1f75 fix(supply-chain): 修正出貨單詳情頁組件匯入錯誤以修復正式站編譯失敗
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 46s
2026-02-05 12:08:25 +08:00
22 changed files with 1152 additions and 566 deletions

View File

@@ -273,6 +273,65 @@ tooltip
--- ---
## 3.5 頁面佈局規範(新增/編輯頁面)
### 標準結構
新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構:
```tsx
<AuthenticatedLayout breadcrumbs={...}>
<Head title="..." />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
{/* 返回按鈕 */}
<Link href={route('resource.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-4"
>
<ArrowLeft className="h-4 w-4" />
返回列表
</Button>
</Link>
{/* 頁面標題區塊 */}
<div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Icon className="h-6 w-6 text-primary-main" />
頁面標題
</h1>
<p className="text-gray-500 mt-1">
頁面說明文字
</p>
</div>
</div>
{/* 表單或內容區塊 */}
<FormComponent ... />
</div>
</AuthenticatedLayout>
```
### 關鍵規範
1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致
2. **Header 包裹**:使用 `<div className="mb-6">` 包裹返回按鈕與標題區塊
3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔
4. **標題區塊**:使用 `<div className="mb-4">` 包裹 h1 和 p 標籤
5. **標題樣式**`text-2xl font-bold text-grey-0 flex items-center gap-2`
6. **說明文字**`text-gray-500 mt-1`
### 範例頁面
- ✅ `/resources/js/Pages/PurchaseOrder/Create.tsx`(建立採購單)
- ✅ `/resources/js/Pages/Product/Create.tsx`(新增商品)
- ✅ `/resources/js/Pages/Product/Edit.tsx`(編輯商品)
---
## 4. 圖標規範 ## 4. 圖標規範
### 4.1 統一使用 lucide-react ### 4.1 統一使用 lucide-react

View File

@@ -100,6 +100,7 @@ class ProductController extends Controller
'price' => (float) $product->price, 'price' => (float) $product->price,
'member_price' => (float) $product->member_price, 'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price, 'wholesale_price' => (float) $product->wholesale_price,
'is_active' => (bool) $product->is_active,
]; ];
}); });
@@ -113,57 +114,122 @@ class ProductController extends Controller
]); ]);
} }
/**
* 顯示指定的資源。
*/
public function show(Product $product): Response
{
return Inertia::render('Product/Show', [
'product' => (object) [
'id' => (string) $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'categoryId' => $product->category_id,
'category' => $product->category ? (object) [
'id' => $product->category->id,
'name' => $product->category->name,
] : null,
'brand' => $product->brand,
'specification' => $product->specification,
'baseUnitId' => $product->base_unit_id,
'baseUnit' => $product->baseUnit ? (object) [
'id' => $product->baseUnit->id,
'name' => $product->baseUnit->name,
] : null,
'largeUnitId' => $product->large_unit_id,
'largeUnit' => $product->largeUnit ? (object) [
'id' => $product->largeUnit->id,
'name' => $product->largeUnit->name,
] : null,
'purchaseUnitId' => $product->purchase_unit_id,
'purchaseUnit' => $product->purchaseUnit ? (object) [
'id' => $product->purchaseUnit->id,
'name' => $product->purchaseUnit->name,
] : null,
'conversionRate' => (float) $product->conversion_rate,
'location' => $product->location,
'cost_price' => (float) $product->cost_price,
'price' => (float) $product->price,
'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price,
'is_active' => (bool) $product->is_active,
]
]);
}
/**
* 顯示建立表單。
*/
public function create(): Response
{
return Inertia::render('Product/Create', [
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
]);
}
/** /**
* 將新建立的資源儲存到儲存體中。 * 將新建立的資源儲存到儲存體中。
*/ */
public function store(Request $request) public function store(Request $request)
{ {
$validated = $request->validate([ $validated = $request->validate([
'code' => 'required|string|min:2|max:8|unique:products,code', 'code' => 'nullable|unique:products,code',
'barcode' => 'required|string|unique:products,barcode', 'barcode' => 'nullable|unique:products,barcode',
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id', 'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255', 'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string', 'specification' => 'nullable|string',
'base_unit_id' => 'required|exists:units,id', 'base_unit_id' => 'required|exists:units,id',
'large_unit_id' => 'nullable|exists:units,id', 'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id',
'purchase_unit_id' => 'nullable|exists:units,id', 'purchase_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'nullable|numeric|min:0',
'location' => 'nullable|string|max:255', 'location' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0', 'cost_price' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0', 'price' => 'nullable|numeric|min:0',
'member_price' => 'nullable|numeric|min:0', 'member_price' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0', 'wholesale_price' => 'nullable|numeric|min:0',
], [ 'is_active' => 'boolean',
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 8 碼',
'code.min' => '商品代號最少 2 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
'base_unit_id.required' => '基本庫存單位為必填',
'base_unit_id.exists' => '所選基本單位不存在',
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001',
'cost_price.numeric' => '成本價必須為數字',
'cost_price.min' => '成本價不能小於 0',
'price.numeric' => '售價必須為數字',
'price.min' => '售價不能小於 0',
'member_price.numeric' => '會員價必須為數字',
'member_price.min' => '會員價不能小於 0',
'wholesale_price.numeric' => '批發價必須為數字',
'wholesale_price.min' => '批發價不能小於 0',
]); ]);
if (empty($validated['code'])) {
$validated['code'] = $this->generateRandomCode();
}
$product = Product::create($validated); $product = Product::create($validated);
return redirect()->back()->with('success', '商品已建立'); return redirect()->route('products.index')->with('success', '商品已建立');
}
/**
* 顯示編輯表單。
*/
public function edit(Product $product): Response
{
return Inertia::render('Product/Edit', [
'product' => (object) [
'id' => (string) $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'categoryId' => $product->category_id,
'brand' => $product->brand,
'specification' => $product->specification,
'baseUnitId' => $product->base_unit_id,
'largeUnitId' => $product->large_unit_id,
'conversionRate' => (float) $product->conversion_rate,
'purchaseUnitId' => $product->purchase_unit_id,
'location' => $product->location,
'cost_price' => (float) $product->cost_price,
'price' => (float) $product->price,
'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price,
'is_active' => (bool) $product->is_active,
],
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
]);
} }
/** /**
@@ -172,50 +238,35 @@ class ProductController extends Controller
public function update(Request $request, Product $product) public function update(Request $request, Product $product)
{ {
$validated = $request->validate([ $validated = $request->validate([
'code' => 'required|string|min:2|max:8|unique:products,code,' . $product->id, 'code' => 'nullable|unique:products,code,' . $product->id,
'barcode' => 'required|string|unique:products,barcode,' . $product->id, 'barcode' => 'nullable|unique:products,barcode,' . $product->id,
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id', 'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255', 'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string', 'specification' => 'nullable|string',
'base_unit_id' => 'required|exists:units,id', 'base_unit_id' => 'required|exists:units,id',
'large_unit_id' => 'nullable|exists:units,id', 'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id',
'purchase_unit_id' => 'nullable|exists:units,id', 'purchase_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'nullable|numeric|min:0',
'location' => 'nullable|string|max:255', 'location' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0', 'cost_price' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0', 'price' => 'nullable|numeric|min:0',
'member_price' => 'nullable|numeric|min:0', 'member_price' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0', 'wholesale_price' => 'nullable|numeric|min:0',
], [ 'is_active' => 'boolean',
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 8 碼',
'code.min' => '商品代號最少 2 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
'base_unit_id.required' => '基本庫存單位為必填',
'base_unit_id.exists' => '所選基本單位不存在',
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001',
'cost_price.numeric' => '成本價必須為數字',
'cost_price.min' => '成本價不能小於 0',
'price.numeric' => '售價必須為數字',
'price.min' => '售價不能小於 0',
'member_price.numeric' => '會員價必須為數字',
'member_price.min' => '會員價不能小於 0',
'wholesale_price.numeric' => '批發價必須為數字',
'wholesale_price.min' => '批發價不能小於 0',
]); ]);
if (empty($validated['code'])) {
$validated['code'] = $this->generateRandomCode();
}
$product->update($validated); $product->update($validated);
return redirect()->back()->with('success', '商品已更新'); if ($request->input('from') === 'show') {
return redirect()->route('products.show', $product->id)->with('success', '商品已更新');
}
return redirect()->route('products.index')->with('success', '商品已更新');
} }
/** /**
@@ -259,4 +310,22 @@ class ProductController extends Controller
return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]); return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]);
} }
} }
/**
* 生成隨機 8 碼代號 (大寫英文+數字)
*/
private function generateRandomCode(): string
{
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$code = '';
do {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $characters[rand(0, strlen($characters) - 1)];
}
} while (Product::where('code', $code)->exists());
return $code;
}
} }

View File

@@ -30,8 +30,9 @@ class WarehouseController extends Controller
} }
$warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和 $warehouses = $query->withSum('inventories as book_stock', 'quantity') // 帳面庫存 = 所有庫存總和
->withSum('inventories as book_amount', 'total_value') // 帳面金額
->withSum(['inventories as available_stock' => function ($query) { ->withSum(['inventories as available_stock' => function ($query) {
// 可用庫存 = 庫存 > 0 且 品質正常 且 (未過期 或 無效期) 且 倉庫類型不為瑕疵倉 // 可用庫存條件
$query->where('quantity', '>', 0) $query->where('quantity', '>', 0)
->where('quality_status', 'normal') ->where('quality_status', 'normal')
->whereHas('warehouse', function ($q) { ->whereHas('warehouse', function ($q) {
@@ -42,6 +43,31 @@ class WarehouseController extends Controller
->orWhere('expiry_date', '>=', now()); ->orWhere('expiry_date', '>=', now());
}); });
}], 'quantity') }], 'quantity')
->withSum(['inventories as available_amount' => function ($query) {
// 可用金額條件 (與可用庫存一致)
$query->where('quantity', '>', 0)
->where('quality_status', 'normal')
->whereHas('warehouse', function ($q) {
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
})
->where(function ($q) {
$q->whereNull('expiry_date')
->orWhere('expiry_date', '>=', now());
});
}], 'total_value')
->withSum(['inventories as abnormal_amount' => function ($query) {
$query->where('quantity', '>', 0)
->where(function ($q) {
$q->where('quality_status', '!=', 'normal')
->orWhere(function ($sq) {
$sq->whereNotNull('expiry_date')
->where('expiry_date', '<', now());
})
->orWhereHas('warehouse', function ($wq) {
$wq->where('type', \App\Enums\WarehouseType::QUARANTINE);
});
});
}], 'total_value')
->addSelect(['low_stock_count' => function ($query) { ->addSelect(['low_stock_count' => function ($query) {
$query->selectRaw('count(*)') $query->selectRaw('count(*)')
->from('warehouse_product_safety_stocks as ss') ->from('warehouse_product_safety_stocks as ss')
@@ -52,9 +78,6 @@ class WarehouseController extends Controller
->paginate($perPage) ->paginate($perPage)
->withQueryString(); ->withQueryString();
// 移除原本對 is_sellable 的手動修正邏輯,現在由 type 自動過濾
// 計算全域總計 (不分頁) // 計算全域總計 (不分頁)
$totals = [ $totals = [
'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0) 'available_stock' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
@@ -66,7 +89,28 @@ class WarehouseController extends Controller
$q->whereNull('expiry_date') $q->whereNull('expiry_date')
->orWhere('expiry_date', '>=', now()); ->orWhere('expiry_date', '>=', now());
})->sum('quantity'), })->sum('quantity'),
'available_amount' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
->where('quality_status', 'normal')
->whereHas('warehouse', function ($q) {
$q->where('type', '!=', \App\Enums\WarehouseType::QUARANTINE);
})
->where(function ($q) {
$q->whereNull('expiry_date')
->orWhere('expiry_date', '>=', now());
})->sum('total_value'),
'abnormal_amount' => \App\Modules\Inventory\Models\Inventory::where('quantity', '>', 0)
->where(function ($q) {
$q->where('quality_status', '!=', 'normal')
->orWhere(function ($sq) {
$sq->whereNotNull('expiry_date')
->where('expiry_date', '<', now());
})
->orWhereHas('warehouse', function ($wq) {
$wq->where('type', \App\Enums\WarehouseType::QUARANTINE);
});
})->sum('total_value'),
'book_stock' => \App\Modules\Inventory\Models\Inventory::sum('quantity'), 'book_stock' => \App\Modules\Inventory\Models\Inventory::sum('quantity'),
'book_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'),
]; ];
return Inertia::render('Warehouse/Index', [ return Inertia::render('Warehouse/Index', [

View File

@@ -63,8 +63,14 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
return null; return null;
} }
// 處理商品代號:若為空則自動生成
$code = $row['商品代號'] ?? null;
if (empty($code)) {
$code = $this->generateRandomCode();
}
return new Product([ return new Product([
'code' => $row['商品代號'], 'code' => $code,
'barcode' => $row['條碼'], 'barcode' => $row['條碼'],
'name' => $row['商品名稱'], 'name' => $row['商品名稱'],
'category_id' => $categoryId, 'category_id' => $categoryId,
@@ -81,10 +87,28 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
]); ]);
} }
/**
* 生成隨機 8 碼代號 (大寫英文+數字)
*/
private function generateRandomCode(): string
{
$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$code = '';
do {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $characters[rand(0, strlen($characters) - 1)];
}
} while (Product::where('code', $code)->exists());
return $code;
}
public function rules(): array public function rules(): array
{ {
return [ return [
'商品代號' => ['required', 'string', 'min:2', 'max:8', 'unique:products,code'], '商品代號' => ['nullable', 'string', 'min:2', 'max:8', 'unique:products,code'],
'條碼' => ['required', 'string', 'unique:products,barcode'], '條碼' => ['required', 'string', 'unique:products,barcode'],
'商品名稱' => ['required', 'string'], '商品名稱' => ['required', 'string'],
'類別名稱' => ['required', function($attribute, $value, $fail) { '類別名稱' => ['required', function($attribute, $value, $fail) {

View File

@@ -31,10 +31,12 @@ class Product extends Model
'price', 'price',
'member_price', 'member_price',
'wholesale_price', 'wholesale_price',
'is_active',
]; ];
protected $casts = [ protected $casts = [
'conversion_rate' => 'decimal:4', 'conversion_rate' => 'decimal:4',
'is_active' => 'boolean',
]; ];
/** /**

View File

@@ -33,6 +33,9 @@ Route::middleware('auth')->group(function () {
Route::get('/products/template', [ProductController::class, 'template'])->name('products.template'); Route::get('/products/template', [ProductController::class, 'template'])->name('products.template');
Route::post('/products/import', [ProductController::class, 'import'])->middleware('permission:products.create')->name('products.import'); Route::post('/products/import', [ProductController::class, 'import'])->middleware('permission:products.create')->name('products.import');
Route::get('/products', [ProductController::class, 'index'])->name('products.index'); Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::get('/products/create', [ProductController::class, 'create'])->middleware('permission:products.create')->name('products.create');
Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show');
Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->middleware('permission:products.edit')->name('products.edit');
Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store'); Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store');
Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update'); Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update');
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->middleware('permission:products.delete')->name('products.destroy'); Route::delete('/products/{product}', [ProductController::class, 'destroy'])->middleware('permission:products.delete')->name('products.destroy');

View File

@@ -0,0 +1,31 @@
<?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->boolean('is_active')->default(true)->after('wholesale_price')->comment('是否啟用');
});
// 更新現有資料為啟用
\DB::table('products')->whereNull('is_active')->orWhere('is_active', false)->update(['is_active' => true]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
};

View File

@@ -0,0 +1,14 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Modules\Inventory\Models\Product;
class UpdateProductsActiveSeeder extends Seeder
{
public function run(): void
{
Product::query()->update(['is_active' => true]);
}
}

View File

@@ -1,390 +0,0 @@
import { useEffect } from "react";
import { Wand2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { useForm } from "@inertiajs/react";
import { toast } from "sonner";
import type { Product, Category } from "@/Pages/Product/Index";
import type { Unit } from "@/Components/Unit/UnitManagerDialog";
interface ProductDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
product: Product | null;
categories: Category[];
units: Unit[];
onSave?: (product: any) => void; // Legacy prop, can be removed if fully switching to Inertia submit within dialog
}
export default function ProductDialog({
open,
onOpenChange,
product,
categories,
units,
}: ProductDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
code: "",
barcode: "",
name: "",
category_id: "",
brand: "",
specification: "",
base_unit_id: "",
large_unit_id: "",
conversion_rate: "",
purchase_unit_id: "",
location: "",
cost_price: "",
price: "",
member_price: "",
wholesale_price: "",
});
useEffect(() => {
if (open) {
clearErrors();
if (product) {
setData({
code: product.code,
barcode: product.barcode || "",
name: product.name,
category_id: product.categoryId.toString(),
brand: product.brand || "",
specification: product.specification || "",
base_unit_id: product.baseUnitId?.toString() || "",
large_unit_id: product.largeUnitId?.toString() || "",
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
purchase_unit_id: product.purchaseUnitId?.toString() || "",
location: product.location || "",
cost_price: product.cost_price?.toString() || "",
price: product.price?.toString() || "",
member_price: product.member_price?.toString() || "",
wholesale_price: product.wholesale_price?.toString() || "",
});
} else {
reset();
// Set default category if available
if (categories.length > 0) {
setData("category_id", categories[0].id.toString());
}
}
}
}, [open, product, categories]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (product) {
put(route("products.update", product.id), {
onSuccess: () => {
onOpenChange(false);
reset();
},
onError: () => {
toast.error("更新失敗,請檢查輸入資料");
}
});
} else {
post(route("products.store"), {
onSuccess: () => {
onOpenChange(false);
reset();
},
onError: () => {
toast.error("新增失敗,請檢查輸入資料");
}
});
}
};
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">
<DialogHeader>
<DialogTitle>{product ? "編輯商品" : "新增商品"}</DialogTitle>
<DialogDescription>
{product ? "修改商品資料" : "建立新的商品資料"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-4">
{/* 基本資訊區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="category_id">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={data.category_id}
onValueChange={(value) => setData("category_id", value)}
options={categories.map((c) => ({ label: c.name, value: c.id.toString() }))}
placeholder="選擇分類"
searchPlaceholder="搜尋分類..."
className={errors.category_id ? "border-red-500" : ""}
/>
{errors.category_id && <p className="text-sm text-red-500">{errors.category_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="name">
<span className="text-red-500">*</span>
</Label>
<Input
id="name"
value={data.name}
onChange={(e) => setData("name", e.target.value)}
placeholder="例:法國麵粉"
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="code">
<span className="text-red-500">*</span>
</Label>
<Input
id="code"
value={data.code}
onChange={(e) => setData("code", e.target.value)}
placeholder="例A1 (2-8碼)"
maxLength={8}
className={errors.code ? "border-red-500" : ""}
/>
{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)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
// 掃描後自動跳轉到下一個欄位(品牌)
document.getElementById('brand')?.focus();
}
}}
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
id="brand"
value={data.brand}
onChange={(e) => setData("brand", e.target.value)}
placeholder="例:鳥越製粉"
/>
{errors.brand && <p className="text-sm text-red-500">{errors.brand}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="location"></Label>
<Input
id="location"
value={data.location}
onChange={(e) => setData("location", e.target.value)}
placeholder="例A-1-1"
/>
{errors.location && <p className="text-sm text-red-500">{errors.location}</p>}
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="specification"></Label>
<Textarea
id="specification"
value={data.specification}
onChange={(e) => setData("specification", e.target.value)}
placeholder="例25kg/袋灰分0.45%"
className="resize-none"
/>
{errors.specification && <p className="text-sm text-red-500">{errors.specification}</p>}
</div>
</div>
</div>
{/* 價格設定區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cost_price"></Label>
<Input
id="cost_price"
type="number"
min="0"
step="any"
value={data.cost_price}
onChange={(e) => setData("cost_price", e.target.value)}
placeholder="0"
className={errors.cost_price ? "border-red-500" : ""}
/>
{errors.cost_price && <p className="text-sm text-red-500">{errors.cost_price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="price"></Label>
<Input
id="price"
type="number"
min="0"
step="any"
value={data.price}
onChange={(e) => setData("price", e.target.value)}
placeholder="0"
className={errors.price ? "border-red-500" : ""}
/>
{errors.price && <p className="text-sm text-red-500">{errors.price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="member_price"></Label>
<Input
id="member_price"
type="number"
min="0"
step="any"
value={data.member_price}
onChange={(e) => setData("member_price", e.target.value)}
placeholder="0"
className={errors.member_price ? "border-red-500" : ""}
/>
{errors.member_price && <p className="text-sm text-red-500">{errors.member_price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="wholesale_price"></Label>
<Input
id="wholesale_price"
type="number"
min="0"
step="any"
value={data.wholesale_price}
onChange={(e) => setData("wholesale_price", e.target.value)}
placeholder="0"
className={errors.wholesale_price ? "border-red-500" : ""}
/>
{errors.wholesale_price && <p className="text-sm text-red-500">{errors.wholesale_price}</p>}
</div>
</div>
</div>
{/* 單位設定區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="base_unit_id">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={data.base_unit_id}
onValueChange={(value) => setData("base_unit_id", value)}
options={units.map((u) => ({ label: u.name, value: u.id.toString() }))}
placeholder="選擇單位"
searchPlaceholder="搜尋單位..."
className={errors.base_unit_id ? "border-red-500" : ""}
/>
{errors.base_unit_id && <p className="text-sm text-red-500">{errors.base_unit_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="large_unit_id"></Label>
<SearchableSelect
value={data.large_unit_id}
onValueChange={(value) => setData("large_unit_id", value)}
options={[
{ label: "無", value: "none" },
...units.map((u) => ({ label: u.name, value: u.id.toString() }))
]}
placeholder="無"
searchPlaceholder="搜尋單位..."
className={errors.large_unit_id ? "border-red-500" : ""}
/>
{errors.large_unit_id && <p className="text-sm text-red-500">{errors.large_unit_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="conversion_rate">
{data.large_unit_id && <span className="text-red-500">*</span>}
</Label>
<Input
id="conversion_rate"
type="number"
step="any"
value={data.conversion_rate}
onChange={(e) => setData("conversion_rate", e.target.value)}
placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : ""}
disabled={!data.large_unit_id}
/>
{errors.conversion_rate && <p className="text-sm text-red-500">{errors.conversion_rate}</p>}
</div>
</div>
{data.large_unit_id && data.base_unit_id && data.conversion_rate && (
<div className="bg-blue-50 p-3 rounded text-sm text-blue-700">
1 {units.find(u => u.id.toString() === data.large_unit_id)?.name} = {data.conversion_rate} {units.find(u => u.id.toString() === data.base_unit_id)?.name}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="button-outlined-primary"
>
</Button>
<Button type="submit" className="button-filled-primary" disabled={processing}>
{processing ? "儲存... " : (product ? "儲存變更" : "新增")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog >
);
}

View File

@@ -0,0 +1,345 @@
import { Wand2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Switch } from "@/Components/ui/switch";
import { useForm } from "@inertiajs/react";
import { toast } from "sonner";
import type { Category, Product } from "@/Pages/Product/Index";
import type { Unit } from "@/Components/Unit/UnitManagerDialog";
interface ProductFormProps {
initialData?: Product | null;
categories: Category[];
units: Unit[];
onSubmitsuccess?: () => void;
}
export default function ProductForm({
initialData,
categories,
units,
}: ProductFormProps) {
const isEdit = !!initialData;
const urlParams = new URLSearchParams(window.location.search);
const from = urlParams.get('from');
const { data, setData, post, put, processing, errors } = useForm({
code: initialData?.code || "",
barcode: initialData?.barcode || "",
name: initialData?.name || "",
category_id: initialData?.categoryId?.toString() || (categories.length > 0 ? categories[0].id.toString() : ""),
brand: initialData?.brand || "",
specification: initialData?.specification || "",
base_unit_id: initialData?.baseUnitId?.toString() || "",
large_unit_id: initialData?.largeUnitId?.toString() || "",
conversion_rate: initialData?.conversionRate?.toString() || "",
purchase_unit_id: initialData?.purchaseUnitId?.toString() || "",
location: initialData?.location || "",
cost_price: initialData?.cost_price?.toString() || "",
price: initialData?.price?.toString() || "",
member_price: initialData?.member_price?.toString() || "",
wholesale_price: initialData?.wholesale_price?.toString() || "",
is_active: initialData?.is_active ?? true,
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isEdit) {
put(route("products.update", { product: initialData.id, from }), {
onSuccess: () => toast.success("商品已更新"),
onError: () => toast.error("更新失敗,請檢查輸入資料"),
});
} else {
post(route("products.store"), {
onSuccess: () => toast.success("商品已建立"),
onError: () => toast.error("新增失敗,請檢查輸入資料"),
});
}
};
const generateRandomBarcode = () => {
const randomDigits = Math.floor(Math.random() * 9000000000) + 1000000000;
setData("barcode", randomDigits.toString());
};
const generateRandomCode = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let result = "";
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
setData("code", result);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* 基本資訊 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-6">
<div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-bold text-grey-0"></h3>
<div className="flex items-center gap-2">
<Label htmlFor="is_active" className="text-sm font-medium text-gray-600">
{data.is_active ? "啟用" : "停用"}
</Label>
<Switch
id="is_active"
checked={data.is_active}
onCheckedChange={(checked) => setData("is_active", checked)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="category_id">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={data.category_id}
onValueChange={(value) => setData("category_id", value)}
options={categories.map((c) => ({ label: c.name, value: c.id.toString() }))}
placeholder="選擇分類"
className={errors.category_id ? "border-red-500" : ""}
/>
{errors.category_id && <p className="text-sm text-red-500">{errors.category_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="name">
<span className="text-red-500">*</span>
</Label>
<Input
id="name"
value={data.name}
onChange={(e) => setData("name", e.target.value)}
placeholder="例:法國麵粉"
className={errors.name ? "border-red-500 h-9" : "h-9"}
/>
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="code">
<span className="text-gray-400 font-normal">()</span>
</Label>
<div className="flex gap-2">
<Input
id="code"
value={data.code}
onChange={(e) => setData("code", e.target.value)}
placeholder="例:A1 (未填將自動生成)"
maxLength={8}
className={`flex-1 h-9 ${errors.code ? "border-red-500" : ""}`}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={generateRandomCode}
title="隨機生成代號"
className="shrink-0 button-outlined-primary h-9 w-9"
>
<Wand2 className="h-4 w-4" />
</Button>
</div>
{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 h-9 ${errors.barcode ? "border-red-500" : ""}`}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={generateRandomBarcode}
title="隨機生成條碼"
className="shrink-0 button-outlined-primary h-9 w-9"
>
<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
id="brand"
value={data.brand}
onChange={(e) => setData("brand", e.target.value)}
placeholder="例:鳥越製粉"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="location"></Label>
<Input
id="location"
value={data.location}
onChange={(e) => setData("location", e.target.value)}
placeholder="例A-1-1"
className="h-9"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="specification"></Label>
<Textarea
id="specification"
value={data.specification}
onChange={(e) => setData("specification", e.target.value)}
placeholder="例25kg/袋灰分0.45%"
className="resize-none"
/>
</div>
</div>
</div>
{/* 價格設定 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-6">
<div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-bold text-grey-0"></h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="cost_price"></Label>
<Input
id="cost_price"
type="number"
step="any"
value={data.cost_price}
onChange={(e) => setData("cost_price", e.target.value)}
placeholder="0"
className="h-9 text-right"
/>
</div>
<div className="space-y-2">
<Label htmlFor="price"></Label>
<Input
id="price"
type="number"
step="any"
value={data.price}
onChange={(e) => setData("price", e.target.value)}
placeholder="0"
className="h-9 text-right"
/>
</div>
<div className="space-y-2">
<Label htmlFor="member_price"></Label>
<Input
id="member_price"
type="number"
step="any"
value={data.member_price}
onChange={(e) => setData("member_price", e.target.value)}
placeholder="0"
className="h-9 text-right"
/>
</div>
<div className="space-y-2">
<Label htmlFor="wholesale_price"></Label>
<Input
id="wholesale_price"
type="number"
step="any"
value={data.wholesale_price}
onChange={(e) => setData("wholesale_price", e.target.value)}
placeholder="0"
className="h-9 text-right"
/>
</div>
</div>
</div>
{/* 單位設定 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-6">
<div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-bold text-grey-0"></h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="base_unit_id">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={data.base_unit_id}
onValueChange={(value) => setData("base_unit_id", value)}
options={units.map((u) => ({ label: u.name, value: u.id.toString() }))}
placeholder="選擇單位"
className={errors.base_unit_id ? "border-red-500" : ""}
/>
{errors.base_unit_id && <p className="text-sm text-red-500">{errors.base_unit_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="large_unit_id"> ()</Label>
<SearchableSelect
value={data.large_unit_id}
onValueChange={(value) => setData("large_unit_id", value)}
options={[
{ label: "無", value: "" },
...units.map((u) => ({ label: u.name, value: u.id.toString() }))
]}
placeholder="無"
/>
</div>
<div className="space-y-2">
<Label htmlFor="conversion_rate">
{data.large_unit_id && <span className="text-red-500">*</span>}
</Label>
<Input
id="conversion_rate"
type="number"
step="any"
value={data.conversion_rate}
onChange={(e) => setData("conversion_rate", e.target.value)}
placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : "例如: 25"}
disabled={!data.large_unit_id}
className="h-9"
/>
{errors.conversion_rate && <p className="text-sm text-red-500">{errors.conversion_rate}</p>}
</div>
</div>
{data.large_unit_id && data.base_unit_id && data.conversion_rate && (
<div className="bg-primary-lightest p-3 rounded-lg text-sm text-primary-main font-medium border border-primary-light">
1 {units.find(u => u.id.toString() === data.large_unit_id)?.name} = {data.conversion_rate} {units.find(u => u.id.toString() === data.base_unit_id)?.name}
</div>
)}
</div>
{/* 提交按鈕 */}
<div className="flex justify-end pt-6 border-t">
<Button
type="submit"
className="button-filled-primary px-8"
disabled={processing}
>
{processing ? "處理中..." : (isEdit ? "儲存變更" : "建立商品")}
</Button>
</div>
</form>
);
}

View File

@@ -111,8 +111,9 @@ export default function ProductImportDialog({ open, onOpenChange }: ProductImpor
<AccordionContent> <AccordionContent>
<div className="text-sm text-gray-600 space-y-2 pb-2 pl-6"> <div className="text-sm text-gray-600 space-y-2 pb-2 pl-6">
<ul className="list-disc space-y-1"> <ul className="list-disc space-y-1">
<li><span className="font-medium text-gray-700"></span> (2-8 )</li> <li><span className="font-medium text-gray-700"></span></li>
<li><span className="font-medium text-gray-700"></span></li> <li><span className="font-medium text-gray-700"></span>2-8 + 8 </li>
<li><span className="font-medium text-gray-700"></span></li>
<li><span className="font-medium text-gray-700"></span></li> <li><span className="font-medium text-gray-700"></span></li>
<li><span className="font-medium text-gray-700"></span> 0</li> <li><span className="font-medium text-gray-700"></span> 0</li>
</ul> </ul>

View File

@@ -8,7 +8,7 @@ import {
} from "@/Components/ui/table"; } from "@/Components/ui/table";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; import { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown, Eye } from "lucide-react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -27,11 +27,11 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/Components/ui/alert-dialog"; } from "@/Components/ui/alert-dialog";
import { Can } from "@/Components/Permission/Can"; import { Can } from "@/Components/Permission/Can";
import { Link } from "@inertiajs/react";
import type { Product } from "@/Pages/Product/Index"; import type { Product } from "@/Pages/Product/Index";
interface ProductTableProps { interface ProductTableProps {
products: Product[]; products: Product[];
onEdit: (product: Product) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
startIndex: number; startIndex: number;
@@ -42,7 +42,6 @@ interface ProductTableProps {
export default function ProductTable({ export default function ProductTable({
products, products,
onEdit,
onDelete, onDelete,
startIndex, startIndex,
sortField, sortField,
@@ -96,16 +95,17 @@ export default function ProductTable({
<SortIcon field="base_unit_id" /> <SortIcon field="base_unit_id" />
</button> </button>
</TableHead> </TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead> <TableHead className="text-center"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{products.length === 0 ? ( {products.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8 text-gray-500"> <TableCell colSpan={9} className="text-center py-8 text-gray-500">
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -133,6 +133,15 @@ export default function ProductTable({
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{product.baseUnit?.name || '-'}</TableCell> <TableCell>{product.baseUnit?.name || '-'}</TableCell>
<TableCell>
{product.largeUnit ? (
<span className="text-sm text-gray-500">
1 {product.largeUnit?.name} = {Number(product.conversionRate)} {product.baseUnit?.name}
</span>
) : (
'-'
)}
</TableCell>
<TableCell className="max-w-[200px]"> <TableCell className="max-w-[200px]">
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
@@ -149,39 +158,39 @@ export default function ProductTable({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</TableCell> </TableCell>
<TableCell>
{product.largeUnit ? (
<span className="text-sm text-gray-500">
1 {product.largeUnit?.name} = {Number(product.conversionRate)} {product.baseUnit?.name}
</span>
) : (
'-'
)}
</TableCell>
<TableCell> <TableCell>
<span className="text-sm text-gray-600">{product.location || '-'}</span> <span className="text-sm text-gray-600">{product.location || '-'}</span>
</TableCell> </TableCell>
<TableCell className="text-center">
{product.is_active ? (
<Badge className="bg-green-100 text-green-700 hover:bg-green-100 border-none"></Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 hover:bg-gray-100 border-none"></Badge>
)}
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{/* <Link href={route("products.show", product.id)}>
<Button
variant="ghost"
size="sm"
onClick={() => handleViewBarcode(product)}
className="h-8 px-2 text-primary hover:text-primary-dark hover:bg-primary-lightest"
>
<Eye className="h-4 w-4" />
</Button>
*/}
<Can permission="products.edit">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onEdit(product)}
className="button-outlined-primary" className="button-outlined-primary"
title="查看詳情"
> >
<Pencil className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
</Link>
<Can permission="products.edit">
<Link href={route("products.edit", product.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can> </Can>
<Can permission="products.delete"> <Can permission="products.delete">
<AlertDialog> <AlertDialog>

View File

@@ -135,6 +135,12 @@ export default function InventoryTable({
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
{hasInventory ? `${group.batches.length} 個批號` : '無庫存'} {hasInventory ? `${group.batches.length} 個批號` : '無庫存'}
</span> </span>
{group.batches.some(b => b.expiryDate && new Date(b.expiryDate) < new Date()) && (
<Badge className="bg-red-50 text-red-600 border-red-200">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
)}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-sm"> <div className="text-sm">
@@ -217,7 +223,23 @@ export default function InventoryTable({
<TableCell>${batch.total_value?.toLocaleString()}</TableCell> <TableCell>${batch.total_value?.toLocaleString()}</TableCell>
</Can> </Can>
<TableCell> <TableCell>
{batch.expiryDate ? formatDate(batch.expiryDate) : "-"} {batch.expiryDate ? (
<div className="flex items-center gap-2">
<span className={new Date(batch.expiryDate) < new Date() ? "text-red-600 font-medium" : ""}>
{formatDate(batch.expiryDate)}
</span>
{new Date(batch.expiryDate) < new Date() && (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-4 w-4 text-red-500 cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
</div>
) : "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
{batch.lastInboundDate ? formatDate(batch.lastInboundDate) : "-"} {batch.lastInboundDate ? formatDate(batch.lastInboundDate) : "-"}
@@ -280,7 +302,7 @@ export default function InventoryTable({
})} })}
</div> </div >
</TooltipProvider> </TooltipProvider >
); );
} }

View File

@@ -23,6 +23,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/Components/ui/dialog"; } from "@/Components/ui/dialog";
import { Can } from "@/Components/Permission/Can";
interface WarehouseCardProps { interface WarehouseCardProps {
warehouse: Warehouse; warehouse: Warehouse;
@@ -59,9 +60,12 @@ export default function WarehouseCard({
> >
{/* 警告橫幅 */} {/* 警告橫幅 */}
{hasWarning && ( {hasWarning && (
<div className="absolute top-0 left-0 right-0 bg-orange-500 text-white px-4 py-1 flex items-center gap-2 text-sm"> <div className="absolute top-0 left-0 right-0 bg-orange-500 text-white px-4 py-1 flex items-center justify-between text-sm">
<AlertTriangle className="h-4 w-4" /> <div className="flex items-center gap-2">
<span></span> <AlertTriangle className="h-4 w-4" />
<span></span>
</div>
<span className="font-bold">{stats.lowStockCount} </span>
</div> </div>
)} )}
@@ -81,12 +85,16 @@ export default function WarehouseCard({
</button> </button>
</div> </div>
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">
<Badge variant="outline" className="text-xs font-normal"> <Badge
variant={warehouse.type === 'quarantine' ? "secondary" : "outline"}
className={`text-xs font-normal ${warehouse.type === 'quarantine' ? 'bg-red-100 text-red-700 border-red-200' : ''}`}
>
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'} {WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
{warehouse.type === 'quarantine' ? ' (不計入可用)' : ' (計入可用)'}
</Badge> </Badge>
{warehouse.type === 'transit' && warehouse.license_plate && ( {warehouse.type === 'transit' && warehouse.license_plate && (
<Badge variant="secondary" className="text-xs font-normal bg-yellow-100 text-yellow-800 border-yellow-200"> <Badge variant="secondary" className="text-xs font-normal bg-yellow-100 text-yellow-800 border-yellow-200">
{warehouse.license_plate} {warehouse.license_plate} {warehouse.driver_name && `(${warehouse.driver_name})`}
</Badge> </Badge>
)} )}
</div> </div>
@@ -100,46 +108,40 @@ export default function WarehouseCard({
{/* 統計區塊 - 狀態標籤 */} {/* 統計區塊 - 狀態標籤 */}
<div className="space-y-3"> <div className="space-y-3">
{/* 銷售狀態與可用性說明 */}
<div className="flex items-center justify-between"> {/* 帳面庫存總計 (金額) - 瑕疵倉隱藏此項以減少重複 */}
<span className="text-sm text-gray-500"></span> <Can permission="inventory.view_cost">
{warehouse.type === 'quarantine' ? ( {warehouse.type !== 'quarantine' && (
<Badge variant="secondary" className="bg-red-100 text-red-700 border-red-200"> <div className="flex items-center justify-between p-3 rounded-lg bg-primary-50/50 border border-primary-100">
<div className="flex items-center gap-2 text-primary-700">
</Badge> <Package className="h-4 w-4" />
) : ( <span className="text-sm font-medium"></span>
<Badge variant="default" className="bg-green-600"> </div>
<div className="text-sm font-bold text-primary-main">
</Badge> ${Number(stats.totalValue || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
)} )}
</div> </Can>
{/* 過期統計 (金額) */}
<Can permission="inventory.view_cost">
{Number(stats.abnormalValue || 0) > 0 && (
<div className="flex items-center justify-between p-3 rounded-lg bg-red-50/50 border border-red-100 mt-3">
<div className="flex items-center gap-2 text-red-700">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm font-medium">
{warehouse.type === 'quarantine' ? '瑕疵總計' : '過期統計'}
</span>
</div>
<div className="text-sm font-bold text-red-600">
${Number(stats.abnormalValue || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
)}
</Can>
{/* 低庫存警告狀態 */}
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2 text-gray-600">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div>
{hasWarning ? (
<Badge className="bg-orange-500 text-white hover:bg-orange-600 border-none px-2 py-0.5">
{stats.lowStockCount}
</Badge>
) : (
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-100 border-green-200">
</Badge>
)}
</div>
</div>
{/* 移動倉司機資訊 */}
{warehouse.type === 'transit' && warehouse.driver_name && (
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<span className="text-sm text-gray-500"></span>
<span className="text-sm font-medium text-gray-900">{warehouse.driver_name}</span>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -18,6 +18,7 @@ interface PrintProps {
unit: string; unit: string;
quantity: number; quantity: number;
counted_qty: number | null; counted_qty: number | null;
batch_number: string | null;
notes: string; notes: string;
}>; }>;
}; };
@@ -114,6 +115,7 @@ export default function Print({ doc }: PrintProps) {
<th className="border border-black px-2 py-1 w-12 text-center"></th> <th className="border border-black px-2 py-1 w-12 text-center"></th>
<th className="border border-black px-2 py-1 w-32 text-left"></th> <th className="border border-black px-2 py-1 w-32 text-left"></th>
<th className="border border-black px-2 py-1 text-left"></th> <th className="border border-black px-2 py-1 text-left"></th>
<th className="border border-black px-2 py-1 w-32 text-left"></th>
<th className="border border-black px-2 py-1 w-32 text-left"></th> <th className="border border-black px-2 py-1 w-32 text-left"></th>
<th className="border border-black px-2 py-1 w-20 text-right"></th> <th className="border border-black px-2 py-1 w-20 text-right"></th>
<th className="border border-black px-2 py-1 w-16 text-center"></th> <th className="border border-black px-2 py-1 w-16 text-center"></th>
@@ -126,6 +128,7 @@ export default function Print({ doc }: PrintProps) {
<td className="border border-black px-2 py-2 text-center">{index + 1}</td> <td className="border border-black px-2 py-2 text-center">{index + 1}</td>
<td className="border border-black px-2 py-2 font-mono">{item.product_code}</td> <td className="border border-black px-2 py-2 font-mono">{item.product_code}</td>
<td className="border border-black px-2 py-2">{item.product_name}</td> <td className="border border-black px-2 py-2">{item.product_name}</td>
<td className="border border-black px-2 py-2 font-mono text-xs">{item.batch_number || '-'}</td>
<td className="border border-black px-2 py-2">{item.specification || '-'}</td> <td className="border border-black px-2 py-2">{item.specification || '-'}</td>
<td className="border border-black px-2 py-2 text-right"> <td className="border border-black px-2 py-2 text-right">
{item.counted_qty !== null ? Number(item.counted_qty).toFixed(2) : ''} {item.counted_qty !== null ? Number(item.counted_qty).toFixed(2) : ''}

View File

@@ -0,0 +1,54 @@
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link } from "@inertiajs/react";
import { Package, ArrowLeft } from "lucide-react";
import { Button } from "@/Components/ui/button";
import ProductForm from "@/Components/Product/ProductForm";
import { getCreateBreadcrumbs } from "@/utils/breadcrumb";
import type { Category } from "./Index";
import type { Unit } from "@/Components/Unit/UnitManagerDialog";
interface Props {
categories: Category[];
units: Unit[];
}
export default function Create({ categories, units }: Props) {
return (
<AuthenticatedLayout
breadcrumbs={getCreateBreadcrumbs("products")}
>
<Head title="新增商品" />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Link href={route("products.index")}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-4"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
{/* 表單內容 */}
<ProductForm
categories={categories}
units={units}
/>
</div>
</AuthenticatedLayout >
);
}

View File

@@ -0,0 +1,76 @@
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link } from "@inertiajs/react";
import { Package, ArrowLeft } from "lucide-react";
import { Button } from "@/Components/ui/button";
import ProductForm from "@/Components/Product/ProductForm";
import { getEditBreadcrumbs, BREADCRUMB_MAP } from "@/utils/breadcrumb";
import type { Category, Product } from "./Index";
import type { Unit } from "@/Components/Unit/UnitManagerDialog";
interface Props {
product: Product;
categories: Category[];
units: Unit[];
}
export default function Edit({ product, categories, units }: Props) {
const urlParams = new URLSearchParams(window.location.search);
const from = urlParams.get('from');
const backUrl = from === 'show' ? route('products.show', product.id) : route('products.index');
const backText = from === 'show' ? "返回商品詳情" : "返回商品列表";
// 動態產生麵包屑
const breadcrumbs = from === 'show'
? [
...JSON.parse(JSON.stringify(BREADCRUMB_MAP.products)),
{ label: `商品詳情 (${product.name})`, href: route('products.show', product.id) },
{ label: "編輯", isPage: true }
]
: getEditBreadcrumbs("products");
// 修正詳情層級的 isPage 狀態
if (from === 'show' && breadcrumbs.length > 1) {
breadcrumbs[breadcrumbs.length - 3].isPage = false; // "商品資料管理" 設為 false
breadcrumbs[breadcrumbs.length - 2].isPage = false; // "商品詳情" 設為 false
}
return (
<AuthenticatedLayout
breadcrumbs={breadcrumbs}
>
<Head title={`編輯商品 - ${product.name}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Link href={backUrl}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-4"
>
<ArrowLeft className="h-4 w-4" />
{backText}
</Button>
</Link>
<div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" />
{product.name}
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
{/* 表單內容 */}
<ProductForm
initialData={product}
categories={categories}
units={units}
/>
</div>
</AuthenticatedLayout >
);
}

View File

@@ -4,12 +4,11 @@ import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Plus, Search, Package, X, Upload } from 'lucide-react'; import { Plus, Search, Package, X, Upload } from 'lucide-react';
import ProductTable from "@/Components/Product/ProductTable"; import ProductTable from "@/Components/Product/ProductTable";
import ProductDialog from "@/Components/Product/ProductDialog";
import ProductImportDialog from "@/Components/Product/ProductImportDialog"; import ProductImportDialog from "@/Components/Product/ProductImportDialog";
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog"; import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog"; import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, usePage } from "@inertiajs/react"; import { Head, router, usePage, Link } from "@inertiajs/react";
import { PageProps as GlobalPageProps } from "@/types/global"; import { PageProps as GlobalPageProps } from "@/types/global";
import { debounce } from "lodash"; import { debounce } from "lodash";
import Pagination from "@/Components/shared/Pagination"; import Pagination from "@/Components/shared/Pagination";
@@ -44,6 +43,7 @@ export interface Product {
price?: number; price?: number;
member_price?: number; member_price?: number;
wholesale_price?: number; wholesale_price?: number;
is_active?: boolean;
} }
interface PageProps { interface PageProps {
@@ -70,11 +70,9 @@ export default function ProductManagement({ products, categories, units, filters
const [perPage, setPerPage] = useState<string>(filters.per_page || "10"); const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
const [sortField, setSortField] = useState<string | null>(filters.sort_field || null); const [sortField, setSortField] = useState<string | null>(filters.sort_field || null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(filters.sort_direction as "asc" | "desc" || null); const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(filters.sort_direction as "asc" | "desc" || null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false); const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false);
const [isUnitDialogOpen, setIsUnitDialogOpen] = useState(false); const [isUnitDialogOpen, setIsUnitDialogOpen] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
// Sync state with props when they change (e.g. navigation) // Sync state with props when they change (e.g. navigation)
useEffect(() => { useEffect(() => {
@@ -163,15 +161,6 @@ export default function ProductManagement({ products, categories, units, filters
); );
}; };
const handleAddProduct = () => {
setEditingProduct(null);
setIsDialogOpen(true);
};
const handleEditProduct = (product: Product) => {
setEditingProduct(product);
setIsDialogOpen(true);
};
const handleDeleteProduct = (id: string) => { const handleDeleteProduct = (id: string) => {
router.delete(route('products.destroy', id), { router.delete(route('products.destroy', id), {
@@ -259,10 +248,12 @@ export default function ProductManagement({ products, categories, units, filters
</Button> </Button>
</Can> </Can>
<Can permission="products.create"> <Can permission="products.create">
<Button onClick={handleAddProduct} className="flex-1 md:flex-none button-filled-primary"> <Link href={route("products.create")}>
<Plus className="mr-2 h-4 w-4" /> <Button className="w-full md:w-auto button-filled-primary">
<Plus className="mr-2 h-4 w-4" />
</Button>
</Button>
</Link>
</Can> </Can>
</div> </div>
</div> </div>
@@ -271,7 +262,6 @@ export default function ProductManagement({ products, categories, units, filters
{/* Product Table */} {/* Product Table */}
<ProductTable <ProductTable
products={products.data} products={products.data}
onEdit={handleEditProduct}
onDelete={handleDeleteProduct} onDelete={handleDeleteProduct}
startIndex={products.from} startIndex={products.from}
sortField={sortField} sortField={sortField}
@@ -302,13 +292,6 @@ export default function ProductManagement({ products, categories, units, filters
</div> </div>
</div> </div>
<ProductDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
product={editingProduct}
categories={categories}
units={units}
/>
<ProductImportDialog <ProductImportDialog
open={isImportDialogOpen} open={isImportDialogOpen}

View File

@@ -0,0 +1,204 @@
/**
* 商品詳細資訊頁面
*/
import { Head, Link } from "@inertiajs/react";
import { ArrowLeft, Package, Tag, Layers, MapPin, DollarSign } from "lucide-react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Label } from "@/Components/ui/label";
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
import { Can } from "@/Components/Permission/Can";
interface Product {
id: string;
code: string;
barcode: string;
name: string;
categoryId: number;
category?: { id: number; name: string };
brand?: string;
specification?: string;
baseUnitId: number;
baseUnit?: { id: number; name: string };
largeUnitId?: number;
largeUnit?: { id: number; name: string };
purchaseUnitId?: number;
purchaseUnit?: { id: number; name: string };
conversionRate: number;
location?: string;
cost_price: number;
price: number;
member_price: number;
wholesale_price: number;
is_active: boolean;
}
interface Props {
product: Product;
}
export default function ProductShow({ product }: Props) {
return (
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("products", `商品詳情 (${product.name})`)}>
<Head title={`商品詳情 - ${product.name}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* 返回按鈕 */}
<div className="mb-6">
<Link href={route('products.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"></p>
</div>
<div className="flex gap-2">
<Can permission="products.edit">
<Link href={route('products.edit', { product: product.id, from: 'show' })}>
<Button className="button-filled-primary">
</Button>
</Link>
</Can>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* 左側:基本資料 */}
<div className="md:col-span-2 space-y-6">
<div className="bg-white rounded-lg border border-border p-6 shadow-sm">
<div className="flex items-center gap-2 mb-4 text-primary font-bold">
<Tag className="h-4 w-4" />
<h3></h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<p className="mt-1 font-semibold text-lg">{product.name}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<p className="mt-1 font-mono text-gray-700">{product.code}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<p className="mt-1 font-mono text-gray-700">{product.barcode || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<p className="mt-1">{product.brand || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<div className="mt-1">
<Badge variant="outline">{product.category?.name || "未分類"}</Badge>
</div>
</div>
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<div className="mt-1">
<Badge className={product.is_active ? "bg-green-100 text-green-700 border-green-200" : "bg-gray-100 text-gray-500 border-gray-200"}>
{product.is_active ? "啟用中" : "已停用"}
</Badge>
</div>
</div>
</div>
</div>
{/* 規格與儲位 */}
<div className="bg-white rounded-lg border border-border p-6 shadow-sm">
<div className="flex items-center gap-2 mb-4 text-primary font-bold">
<MapPin className="h-4 w-4" />
<h3></h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<p className="mt-1 whitespace-pre-wrap text-gray-700">{product.specification || "-"}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<p className="mt-1 font-medium">{product.location || "-"}</p>
</div>
</div>
</div>
</div>
{/* 右側:單位與價格 */}
<div className="space-y-6">
{/* 單位與換算 */}
<div className="bg-white rounded-lg border border-border p-6 shadow-sm">
<div className="flex items-center gap-2 mb-4 text-primary font-bold">
<Layers className="h-4 w-4" />
<h3></h3>
</div>
<div className="space-y-4">
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"> ()</Label>
<p className="mt-1 font-medium">{product.baseUnit?.name || "-"}</p>
</div>
{product.largeUnit && (
<>
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<p className="mt-1 font-medium">{product.largeUnit?.name || "-"}</p>
</div>
<div className="p-3 bg-gray-50 rounded border border-dashed text-sm">
1 {product.largeUnit.name} = <span className="font-bold text-primary-main">{product.conversionRate}</span> {product.baseUnit?.name}
</div>
</>
)}
<div>
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<p className="mt-1">{product.purchaseUnit?.name || product.baseUnit?.name || "-"}</p>
</div>
</div>
</div>
{/* 價格資訊 */}
<Can permission="inventory.view_cost">
<div className="bg-white rounded-lg border border-border p-6 shadow-sm">
<div className="flex items-center gap-2 mb-4 text-primary font-bold">
<DollarSign className="h-4 w-4" />
<h3></h3>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center border-b pb-2">
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<span className="font-mono font-bold text-red-600">${product.cost_price.toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center border-b pb-2">
<Label className="text-muted-foreground text-xs text-secondary-text"></Label>
<span className="font-mono font-bold text-primary-main">${product.price.toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center border-b pb-2 text-sm">
<Label className="text-muted-foreground text-xs"></Label>
<span className="font-mono">${product.member_price.toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center text-sm">
<Label className="text-muted-foreground text-xs"></Label>
<span className="font-mono">${product.wholesale_price.toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
</div>
</div>
</div>
</Can>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -1,16 +1,17 @@
import { ArrowLeft, Package, Clock, User, CheckCircle2, AlertCircle, Trash2, Edit } from "lucide-react"; import { ArrowLeft, Package, Info, CheckCircle2, AlertCircle, Trash2, Edit } from "lucide-react";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react"; import { Head, Link, router } from "@inertiajs/react";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
import ActivityLogSection from "@/Components/ActivityLog/ActivityLogSection"; import ActivityLog from "@/Components/ActivityLog/ActivityLog";
interface Props { interface Props {
order: any; order: any;
activities: any[];
} }
export default function ShippingOrderShow({ order }: Props) { export default function ShippingOrderShow({ order, activities = [] }: Props) {
const isDraft = order.status === 'draft'; const isDraft = order.status === 'draft';
const isCompleted = order.status === 'completed'; const isCompleted = order.status === 'completed';
@@ -171,9 +172,8 @@ export default function ShippingOrderShow({ order }: Props) {
{/* 活動日誌區塊 */} {/* 活動日誌區塊 */}
<div className="mt-8"> <div className="mt-8">
<ActivityLogSection <ActivityLog
targetType="App\Modules\Procurement\Models\ShippingOrder" activities={activities}
targetId={order.id}
/> />
</div> </div>
</div> </div>

View File

@@ -34,7 +34,10 @@ interface PageProps {
}; };
totals: { totals: {
available_stock: number; available_stock: number;
available_amount: number;
book_stock: number; book_stock: number;
book_amount: number;
abnormal_amount: number;
}; };
filters: { filters: {
search?: string; search?: string;
@@ -164,14 +167,18 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
</div> </div>
{/* 統計區塊 */} {/* 統計區塊 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<Card className="shadow-sm"> <Card className="shadow-sm">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium text-gray-500 mb-1"></span> <span className="text-sm font-medium text-gray-500 mb-1"></span>
<span className="text-3xl font-bold text-primary-main"> <div className="flex items-baseline gap-2">
{totals.available_stock.toLocaleString()} <Can permission="inventory.view_cost">
</span> <span className="text-3xl font-bold text-primary-main">
${Number(totals.available_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</Can>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -180,9 +187,28 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium text-gray-500 mb-1"></span> <span className="text-sm font-medium text-gray-500 mb-1"></span>
<span className="text-3xl font-bold text-gray-700"> <div className="flex items-baseline gap-2">
{totals.book_stock.toLocaleString()} <Can permission="inventory.view_cost">
</span> <span className="text-3xl font-bold text-gray-700">
${Number(totals.book_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</Can>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-sm border-red-100 bg-red-50/10">
<CardContent className="p-6">
<div className="flex flex-col">
<span className="text-sm font-medium text-red-500 mb-1"></span>
<div className="flex items-baseline gap-2">
<Can permission="inventory.view_cost">
<span className="text-3xl font-bold text-red-600">
${Number(totals.abnormal_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</Can>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -236,6 +262,8 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
warehouse={warehouse} warehouse={warehouse}
stats={{ stats={{
totalQuantity: warehouse.book_stock || 0, totalQuantity: warehouse.book_stock || 0,
totalValue: warehouse.book_amount || 0,
abnormalValue: warehouse.abnormal_amount || 0,
lowStockCount: warehouse.low_stock_count || 0, lowStockCount: warehouse.low_stock_count || 0,
replenishmentNeeded: warehouse.low_stock_count || 0 replenishmentNeeded: warehouse.low_stock_count || 0
}} }}

View File

@@ -29,6 +29,8 @@ export interface Warehouse {
driver_name?: string; // 司機姓名 (移動倉) driver_name?: string; // 司機姓名 (移動倉)
book_stock?: number; book_stock?: number;
available_stock?: number; available_stock?: number;
book_amount?: number;
abnormal_amount?: number;
} }
// 倉庫中的庫存項目 // 倉庫中的庫存項目
export interface WarehouseInventory { export interface WarehouseInventory {
@@ -92,6 +94,7 @@ export interface Product {
export interface WarehouseStats { export interface WarehouseStats {
totalQuantity: number; totalQuantity: number;
totalValue?: number; // 倉庫總值 totalValue?: number; // 倉庫總值
abnormalValue?: number; // 過期或瑕疵總值
lowStockCount: number; lowStockCount: number;
replenishmentNeeded: number; replenishmentNeeded: number;
} }