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.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)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|min:2|max:8|unique:products,code',
|
||||
'barcode' => 'required|string|unique:products,barcode',
|
||||
'code' => 'nullable|unique:products,code',
|
||||
'barcode' => 'nullable|unique:products,barcode',
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
|
||||
'base_unit_id' => 'required|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',
|
||||
'conversion_rate' => 'nullable|numeric|min:0',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'member_price' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
], [
|
||||
'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',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
$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)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|min:2|max:8|unique:products,code,' . $product->id,
|
||||
'barcode' => 'required|string|unique:products,barcode,' . $product->id,
|
||||
'code' => 'nullable|unique:products,code,' . $product->id,
|
||||
'barcode' => 'nullable|unique:products,barcode,' . $product->id,
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
'base_unit_id' => 'required|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',
|
||||
'conversion_rate' => 'nullable|numeric|min:0',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'member_price' => 'nullable|numeric|min:0',
|
||||
'wholesale_price' => 'nullable|numeric|min:0',
|
||||
], [
|
||||
'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',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
if (empty($validated['code'])) {
|
||||
$validated['code'] = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
$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()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成隨機 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());
|
||||
});
|
||||
}], '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) {
|
||||
$query->selectRaw('count(*)')
|
||||
->from('warehouse_product_safety_stocks as ss')
|
||||
@@ -85,6 +98,17 @@ class WarehouseController extends Controller
|
||||
$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_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'),
|
||||
];
|
||||
|
||||
@@ -63,8 +63,14 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
||||
return null;
|
||||
}
|
||||
|
||||
// 處理商品代號:若為空則自動生成
|
||||
$code = $row['商品代號'] ?? null;
|
||||
if (empty($code)) {
|
||||
$code = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
return new Product([
|
||||
'code' => $row['商品代號'],
|
||||
'code' => $code,
|
||||
'barcode' => $row['條碼'],
|
||||
'name' => $row['商品名稱'],
|
||||
'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
|
||||
{
|
||||
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'],
|
||||
'類別名稱' => ['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::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/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::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');
|
||||
|
||||
@@ -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>
|
||||
<div className="text-sm text-gray-600 space-y-2 pb-2 pl-6">
|
||||
<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>:若填寫大單位,則「換算率」為必填(需大於 0)。</li>
|
||||
</ul>
|
||||
|
||||
@@ -27,11 +27,11 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { Link } from "@inertiajs/react";
|
||||
import type { Product } from "@/Pages/Product/Index";
|
||||
|
||||
interface ProductTableProps {
|
||||
products: Product[];
|
||||
onEdit: (product: Product) => void;
|
||||
onDelete: (id: string) => void;
|
||||
|
||||
startIndex: number;
|
||||
@@ -42,7 +42,6 @@ interface ProductTableProps {
|
||||
|
||||
export default function ProductTable({
|
||||
products,
|
||||
onEdit,
|
||||
onDelete,
|
||||
startIndex,
|
||||
sortField,
|
||||
@@ -96,8 +95,8 @@ export default function ProductTable({
|
||||
基本單位 <SortIcon field="base_unit_id" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[200px]">規格</TableHead>
|
||||
<TableHead>換算率</TableHead>
|
||||
<TableHead className="w-[200px]">規格</TableHead>
|
||||
<TableHead>儲位</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
@@ -133,6 +132,15 @@ export default function ProductTable({
|
||||
</Badge>
|
||||
</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]">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
@@ -149,15 +157,6 @@ export default function ProductTable({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{product.largeUnit ? (
|
||||
<span className="text-sm text-gray-500">
|
||||
1 {product.largeUnit?.name} = {Number(product.conversionRate)} {product.baseUnit?.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-600">{product.location || '-'}</span>
|
||||
</TableCell>
|
||||
@@ -174,14 +173,16 @@ export default function ProductTable({
|
||||
</Button>
|
||||
*/}
|
||||
<Can permission="products.edit">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(product)}
|
||||
className="button-outlined-primary"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<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 permission="products.delete">
|
||||
<AlertDialog>
|
||||
|
||||
@@ -135,6 +135,12 @@ export default function InventoryTable({
|
||||
<span className="text-sm text-gray-500">
|
||||
{hasInventory ? `${group.batches.length} 個批號` : '無庫存'}
|
||||
</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 className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
@@ -217,7 +223,23 @@ export default function InventoryTable({
|
||||
<TableCell>${batch.total_value?.toLocaleString()}</TableCell>
|
||||
</Can>
|
||||
<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>
|
||||
{batch.lastInboundDate ? formatDate(batch.lastInboundDate) : "-"}
|
||||
@@ -280,7 +302,7 @@ export default function InventoryTable({
|
||||
})}
|
||||
|
||||
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div >
|
||||
</TooltipProvider >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
|
||||
interface WarehouseCardProps {
|
||||
warehouse: Warehouse;
|
||||
@@ -59,9 +60,12 @@ export default function WarehouseCard({
|
||||
>
|
||||
{/* 警告橫幅 */}
|
||||
{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">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>低庫存警告</span>
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>低庫存警告</span>
|
||||
</div>
|
||||
<span className="font-bold">{stats.lowStockCount} 項</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -81,12 +85,16 @@ export default function WarehouseCard({
|
||||
</button>
|
||||
</div>
|
||||
<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 === 'quarantine' ? ' (不計入可用)' : ' (計入可用)'}
|
||||
</Badge>
|
||||
{warehouse.type === 'transit' && warehouse.license_plate && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -100,46 +108,36 @@ export default function WarehouseCard({
|
||||
|
||||
{/* 統計區塊 - 狀態標籤 */}
|
||||
<div className="space-y-3">
|
||||
{/* 銷售狀態與可用性說明 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">庫存可用性</span>
|
||||
{warehouse.type === 'quarantine' ? (
|
||||
<Badge variant="secondary" className="bg-red-100 text-red-700 border-red-200">
|
||||
不計入可用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
計入可用
|
||||
</Badge>
|
||||
|
||||
{/* 帳面庫存總計 (金額) */}
|
||||
<Can permission="inventory.view_cost">
|
||||
<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">
|
||||
<Package className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">帳面庫存總計</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-primary-main">
|
||||
${Number(stats.totalValue || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</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>
|
||||
|
||||
|
||||
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 { Plus, Search, Package, X, Upload } from 'lucide-react';
|
||||
import ProductTable from "@/Components/Product/ProductTable";
|
||||
import ProductDialog from "@/Components/Product/ProductDialog";
|
||||
import ProductImportDialog from "@/Components/Product/ProductImportDialog";
|
||||
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
|
||||
import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog";
|
||||
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 { debounce } from "lodash";
|
||||
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 [sortField, setSortField] = useState<string | null>(filters.sort_field || 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 [isUnitDialogOpen, setIsUnitDialogOpen] = useState(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
|
||||
// Sync state with props when they change (e.g. navigation)
|
||||
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) => {
|
||||
router.delete(route('products.destroy', id), {
|
||||
@@ -259,10 +247,12 @@ export default function ProductManagement({ products, categories, units, filters
|
||||
</Button>
|
||||
</Can>
|
||||
<Can permission="products.create">
|
||||
<Button onClick={handleAddProduct} className="flex-1 md:flex-none button-filled-primary">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增商品
|
||||
</Button>
|
||||
<Link href={route("products.create")}>
|
||||
<Button className="w-full md:w-auto button-filled-primary">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增商品
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,7 +261,6 @@ export default function ProductManagement({ products, categories, units, filters
|
||||
{/* Product Table */}
|
||||
<ProductTable
|
||||
products={products.data}
|
||||
onEdit={handleEditProduct}
|
||||
onDelete={handleDeleteProduct}
|
||||
startIndex={products.from}
|
||||
sortField={sortField}
|
||||
@@ -302,13 +291,6 @@ export default function ProductManagement({ products, categories, units, filters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
product={editingProduct}
|
||||
categories={categories}
|
||||
units={units}
|
||||
/>
|
||||
|
||||
<ProductImportDialog
|
||||
open={isImportDialogOpen}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface PageProps {
|
||||
available_amount: number;
|
||||
book_stock: number;
|
||||
book_amount: number;
|
||||
abnormal_amount: number;
|
||||
};
|
||||
filters: {
|
||||
search?: string;
|
||||
@@ -166,18 +167,15 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
</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">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-500 mb-1">可用庫存總計</span>
|
||||
<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">
|
||||
<span className="text-lg font-medium text-gray-400">
|
||||
( 總額:${totals.available_amount?.toLocaleString()} )
|
||||
<span className="text-3xl font-bold text-primary-main">
|
||||
${Number(totals.available_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</Can>
|
||||
</div>
|
||||
@@ -190,12 +188,24 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-500 mb-1">帳面庫存總計</span>
|
||||
<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">
|
||||
<span className="text-lg font-medium text-gray-400">
|
||||
( 總額:${totals.book_amount?.toLocaleString()} )
|
||||
<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>
|
||||
@@ -252,6 +262,8 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
warehouse={warehouse}
|
||||
stats={{
|
||||
totalQuantity: warehouse.book_stock || 0,
|
||||
totalValue: warehouse.book_amount || 0,
|
||||
abnormalValue: warehouse.abnormal_amount || 0,
|
||||
lowStockCount: warehouse.low_stock_count || 0,
|
||||
replenishmentNeeded: warehouse.low_stock_count || 0
|
||||
}}
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface Warehouse {
|
||||
driver_name?: string; // 司機姓名 (移動倉)
|
||||
book_stock?: number;
|
||||
available_stock?: number;
|
||||
book_amount?: number;
|
||||
abnormal_amount?: number;
|
||||
}
|
||||
// 倉庫中的庫存項目
|
||||
export interface WarehouseInventory {
|
||||
@@ -92,6 +94,7 @@ export interface Product {
|
||||
export interface WarehouseStats {
|
||||
totalQuantity: number;
|
||||
totalValue?: number; // 倉庫總值
|
||||
abnormalValue?: number; // 過期或瑕疵總值
|
||||
lowStockCount: number;
|
||||
replenishmentNeeded: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user