feat(inventory): 實作過期與瑕疵庫存總計顯示,並強化庫存明細過期提示
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -113,57 +113,77 @@ class ProductController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示建立表單。
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
'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 +192,31 @@ 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', '商品已更新');
|
return redirect()->route('products.index')->with('success', '商品已更新');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,4 +260,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,19 @@ class WarehouseController extends Controller
|
|||||||
->orWhere('expiry_date', '>=', now());
|
->orWhere('expiry_date', '>=', now());
|
||||||
});
|
});
|
||||||
}], 'total_value')
|
}], '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')
|
||||||
@@ -85,6 +98,17 @@ class WarehouseController extends Controller
|
|||||||
$q->whereNull('expiry_date')
|
$q->whereNull('expiry_date')
|
||||||
->orWhere('expiry_date', '>=', now());
|
->orWhere('expiry_date', '>=', now());
|
||||||
})->sum('total_value'),
|
})->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'),
|
'book_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ 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}/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');
|
||||||
|
|||||||
@@ -1,411 +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());
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="code"
|
|
||||||
value={data.code}
|
|
||||||
onChange={(e) => setData("code", e.target.value)}
|
|
||||||
placeholder="例:A1 (2-8碼)"
|
|
||||||
maxLength={8}
|
|
||||||
className={`flex-1 ${errors.code ? "border-red-500" : ""}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={generateRandomCode}
|
|
||||||
title="隨機生成代號"
|
|
||||||
className="shrink-0 button-outlined-primary"
|
|
||||||
>
|
|
||||||
<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)}
|
|
||||||
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 >
|
|
||||||
);
|
|
||||||
}
|
|
||||||
331
resources/js/Components/Product/ProductForm.tsx
Normal file
331
resources/js/Components/Product/ProductForm.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
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 { 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 { 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() || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
put(route("products.update", initialData.id), {
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,8 +95,8 @@ 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>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -133,6 +132,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,15 +157,6 @@ 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>
|
||||||
@@ -174,14 +173,16 @@ export default function ProductTable({
|
|||||||
</Button>
|
</Button>
|
||||||
*/}
|
*/}
|
||||||
<Can permission="products.edit">
|
<Can permission="products.edit">
|
||||||
<Button
|
<Link href={route("products.edit", product.id)}>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => onEdit(product)}
|
size="sm"
|
||||||
className="button-outlined-primary"
|
className="button-outlined-primary"
|
||||||
>
|
title="編輯"
|
||||||
<Pencil className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</Can>
|
</Can>
|
||||||
<Can permission="products.delete">
|
<Can permission="products.delete">
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
|
|||||||
@@ -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 >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,36 @@ 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' ? (
|
<div className="flex items-center justify-between p-3 rounded-lg bg-primary-50/50 border border-primary-100">
|
||||||
<Badge variant="secondary" className="bg-red-100 text-red-700 border-red-200">
|
<div className="flex items-center gap-2 text-primary-700">
|
||||||
不計入可用
|
<Package className="h-4 w-4" />
|
||||||
</Badge>
|
<span className="text-sm font-medium">帳面庫存總計</span>
|
||||||
) : (
|
</div>
|
||||||
<Badge variant="default" className="bg-green-600">
|
<div className="text-sm font-bold text-primary-main">
|
||||||
計入可用
|
${Number(stats.totalValue || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</Badge>
|
</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">過期與瑕疵總計</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-red-600">
|
||||||
|
${Number(stats.abnormalValue || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|||||||
54
resources/js/Pages/Product/Create.tsx
Normal file
54
resources/js/Pages/Product/Create.tsx
Normal 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 >
|
||||||
|
);
|
||||||
|
}
|
||||||
56
resources/js/Pages/Product/Edit.tsx
Normal file
56
resources/js/Pages/Product/Edit.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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 } 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) {
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={getEditBreadcrumbs("products")}
|
||||||
|
>
|
||||||
|
<Head title={`編輯商品 - ${product.name}`} />
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
編輯商品:{product.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
修改商品的基本資訊、價格或庫存單位設定。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表單內容 */}
|
||||||
|
<ProductForm
|
||||||
|
initialData={product}
|
||||||
|
categories={categories}
|
||||||
|
units={units}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout >
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -70,11 +69,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 +160,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 +247,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 +261,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 +291,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}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface PageProps {
|
|||||||
available_amount: number;
|
available_amount: number;
|
||||||
book_stock: number;
|
book_stock: number;
|
||||||
book_amount: number;
|
book_amount: number;
|
||||||
|
abnormal_amount: number;
|
||||||
};
|
};
|
||||||
filters: {
|
filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -166,18 +167,15 @@ 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>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-3xl font-bold text-primary-main">
|
|
||||||
{totals.available_stock.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<Can permission="inventory.view_cost">
|
<Can permission="inventory.view_cost">
|
||||||
<span className="text-lg font-medium text-gray-400">
|
<span className="text-3xl font-bold text-primary-main">
|
||||||
( 總額:${totals.available_amount?.toLocaleString()} )
|
${Number(totals.available_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
</Can>
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,12 +188,24 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
|||||||
<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>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-3xl font-bold text-gray-700">
|
|
||||||
{totals.book_stock.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<Can permission="inventory.view_cost">
|
<Can permission="inventory.view_cost">
|
||||||
<span className="text-lg font-medium text-gray-400">
|
<span className="text-3xl font-bold text-gray-700">
|
||||||
( 總額:${totals.book_amount?.toLocaleString()} )
|
${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>
|
</span>
|
||||||
</Can>
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,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
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user