diff --git a/.agent/skills/ui-consistency/SKILL.md b/.agent/skills/ui-consistency/SKILL.md index f1878ee..9cac96b 100644 --- a/.agent/skills/ui-consistency/SKILL.md +++ b/.agent/skills/ui-consistency/SKILL.md @@ -273,6 +273,65 @@ tooltip --- +## 3.5 頁面佈局規範(新增/編輯頁面) + +### 標準結構 + +新增/編輯頁面(如:商品新增、採購單建立)應遵循以下標準結構: + +```tsx + + + +
+ {/* Header */} +
+ {/* 返回按鈕 */} + + + + + {/* 頁面標題區塊 */} +
+

+ + 頁面標題 +

+

+ 頁面說明文字 +

+
+
+ + {/* 表單或內容區塊 */} + +
+
+``` + +### 關鍵規範 + +1. **外層容器**:使用 `className="container mx-auto p-6 max-w-7xl"` 確保寬度與間距一致 +2. **Header 包裹**:使用 `
` 包裹返回按鈕與標題區塊 +3. **返回按鈕**:加上 `mb-4` 與標題區塊分隔 +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 diff --git a/app/Modules/Inventory/Controllers/ProductController.php b/app/Modules/Inventory/Controllers/ProductController.php index b4cfb61..4ba8ef5 100644 --- a/app/Modules/Inventory/Controllers/ProductController.php +++ b/app/Modules/Inventory/Controllers/ProductController.php @@ -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; + } } diff --git a/app/Modules/Inventory/Controllers/WarehouseController.php b/app/Modules/Inventory/Controllers/WarehouseController.php index aca2356..000bf80 100644 --- a/app/Modules/Inventory/Controllers/WarehouseController.php +++ b/app/Modules/Inventory/Controllers/WarehouseController.php @@ -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'), ]; diff --git a/app/Modules/Inventory/Imports/ProductImport.php b/app/Modules/Inventory/Imports/ProductImport.php index f881227..9836d32 100644 --- a/app/Modules/Inventory/Imports/ProductImport.php +++ b/app/Modules/Inventory/Imports/ProductImport.php @@ -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) { diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index 27d7ec0..4e7dbd9 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -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'); diff --git a/resources/js/Components/Product/ProductDialog.tsx b/resources/js/Components/Product/ProductDialog.tsx deleted file mode 100644 index eea361a..0000000 --- a/resources/js/Components/Product/ProductDialog.tsx +++ /dev/null @@ -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 ( - - - - {product ? "編輯商品" : "新增商品"} - - {product ? "修改商品資料" : "建立新的商品資料"} - - - -
- - {/* 基本資訊區塊 */} -
-

基本資訊

-
-
- - 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 &&

{errors.category_id}

} -
- -
- - setData("name", e.target.value)} - placeholder="例:法國麵粉" - className={errors.name ? "border-red-500" : ""} - /> - {errors.name &&

{errors.name}

} -
- -
- -
- setData("code", e.target.value)} - placeholder="例:A1 (2-8碼)" - maxLength={8} - className={`flex-1 ${errors.code ? "border-red-500" : ""}`} - /> - -
- {errors.code &&

{errors.code}

} -
- -
- -
- 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" : ""}`} - /> - -
- {errors.barcode &&

{errors.barcode}

} -
- -
- - setData("brand", e.target.value)} - placeholder="例:鳥越製粉" - /> - {errors.brand &&

{errors.brand}

} -
-
- - setData("location", e.target.value)} - placeholder="例:A-1-1" - /> - {errors.location &&

{errors.location}

} -
- -
- -