diff --git a/.agent/rules/framework.md b/.agent/rules/framework.md index a0fab41..a3e5419 100644 --- a/.agent/rules/framework.md +++ b/.agent/rules/framework.md @@ -71,4 +71,7 @@ Routes: kebab-case (小寫橫線分隔) 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。 -必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 \ No newline at end of file +必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 + +7.運行機制 +因為是運行在docker上 所以要執行php的話 要執行docker exce \ No newline at end of file diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index a32d15e..418b0b1 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Product; +use App\Models\Unit; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -14,7 +15,7 @@ class ProductController extends Controller */ public function index(Request $request): Response { - $query = Product::with('category'); + $query = Product::with(['category', 'baseUnit', 'largeUnit', 'purchaseUnit']); if ($request->filled('search')) { $search = $request->search; @@ -61,8 +62,10 @@ class ProductController extends Controller $categories = \App\Models\Category::where('is_active', true)->get(); return Inertia::render('Product/Index', [ + 'products' => $products, 'products' => $products, 'categories' => $categories, + 'units' => Unit::all(), 'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']), ]); } @@ -77,15 +80,17 @@ class ProductController extends Controller 'category_id' => 'required|exists:categories,id', 'brand' => 'nullable|string|max:255', 'specification' => 'nullable|string', - 'base_unit' => 'required|string|max:50', - 'large_unit' => 'nullable|string|max:50', - 'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001', - 'purchase_unit' => 'nullable|string|max:50', + + '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', ], [ 'name.required' => '商品名稱為必填', 'category_id.required' => '請選擇分類', 'category_id.exists' => '所選分類不存在', - 'base_unit.required' => '基本庫存單位為必填', + 'base_unit_id.required' => '基本庫存單位為必填', + 'base_unit_id.exists' => '所選基本單位不存在', 'conversion_rate.required_with' => '填寫大單位時,換算率為必填', 'conversion_rate.numeric' => '換算率必須為數字', 'conversion_rate.min' => '換算率最小為 0.0001', @@ -109,14 +114,24 @@ class ProductController extends Controller */ public function update(Request $request, Product $product) { - $validated = $request->validate([ + $validated = $request->validate([ 'name' => 'required|string|max:255', 'category_id' => 'required|exists:categories,id', 'brand' => 'nullable|string|max:255', 'specification' => 'nullable|string', - 'base_unit' => 'required|string|max:50', - 'large_unit' => 'nullable|string|max:50', - 'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001', + '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', + ], [ + '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', ]); $product->update($validated); diff --git a/app/Http/Controllers/UnitController.php b/app/Http/Controllers/UnitController.php new file mode 100644 index 0000000..93d6b22 --- /dev/null +++ b/app/Http/Controllers/UnitController.php @@ -0,0 +1,70 @@ +validate([ + 'name' => 'required|string|max:255|unique:units,name', + 'code' => 'nullable|string|max:50', + ], [ + 'name.required' => '單位名稱為必填項目', + 'name.unique' => '該單位名稱已存在', + 'name.max' => '單位名稱不能超過 255 個字元', + 'code.max' => '單位代碼不能超過 50 個字元', + ]); + + Unit::create($validated); + + return redirect()->back()->with('success', '單位已建立'); + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, Unit $unit) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255|unique:units,name,' . $unit->id, + 'code' => 'nullable|string|max:50', + ], [ + 'name.required' => '單位名稱為必填項目', + 'name.unique' => '該單位名稱已存在', + 'name.max' => '單位名稱不能超過 255 個字元', + 'code.max' => '單位代碼不能超過 50 個字元', + ]); + + $unit->update($validated); + + return redirect()->back()->with('success', '單位已更新'); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Unit $unit) + { + // Check if unit is used in any product + $isUsed = Product::where('base_unit_id', $unit->id) + ->orWhere('large_unit_id', $unit->id) + ->orWhere('purchase_unit_id', $unit->id) + ->exists(); + + if ($isUsed) { + return redirect()->back()->with('error', '該單位已被商品使用,無法刪除'); + } + + $unit->delete(); + + return redirect()->back()->with('success', '單位已刪除'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 546321f..e0ab02a 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -40,6 +40,10 @@ class HandleInertiaRequests extends Middleware 'auth' => [ 'user' => $request->user(), ], + 'flash' => [ + 'success' => $request->session()->get('success'), + 'error' => $request->session()->get('error'), + ], ]; } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 0dba315..6d63bef 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -17,10 +17,10 @@ class Product extends Model 'category_id', 'brand', 'specification', - 'base_unit', - 'large_unit', + 'base_unit_id', + 'large_unit_id', 'conversion_rate', - 'purchase_unit', + 'purchase_unit_id', ]; protected $casts = [ @@ -35,6 +35,21 @@ class Product extends Model return $this->belongsTo(Category::class); } + public function baseUnit(): BelongsTo + { + return $this->belongsTo(Unit::class, 'base_unit_id'); + } + + public function largeUnit(): BelongsTo + { + return $this->belongsTo(Unit::class, 'large_unit_id'); + } + + public function purchaseUnit(): BelongsTo + { + return $this->belongsTo(Unit::class, 'purchase_unit_id'); + } + public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany { return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps(); diff --git a/app/Models/Unit.php b/app/Models/Unit.php new file mode 100644 index 0000000..bcfddfd --- /dev/null +++ b/app/Models/Unit.php @@ -0,0 +1,17 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'code', + ]; +} diff --git a/database/migrations/2026_01_08_103000_create_units_table.php b/database/migrations/2026_01_08_103000_create_units_table.php new file mode 100644 index 0000000..06e0ad6 --- /dev/null +++ b/database/migrations/2026_01_08_103000_create_units_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name')->unique()->comment('單位名稱'); + $table->string('code')->nullable()->comment('單位代碼 (如: kg)'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('units'); + } +}; diff --git a/database/migrations/2026_01_08_103500_update_products_table_units.php b/database/migrations/2026_01_08_103500_update_products_table_units.php new file mode 100644 index 0000000..e076015 --- /dev/null +++ b/database/migrations/2026_01_08_103500_update_products_table_units.php @@ -0,0 +1,43 @@ +dropColumn(['base_unit', 'large_unit', 'purchase_unit']); + + // Add new foreign key columns + $table->foreignId('base_unit_id')->nullable()->after('specification')->constrained('units')->nullOnDelete()->comment('基本庫存單位ID'); + $table->foreignId('large_unit_id')->nullable()->after('base_unit_id')->constrained('units')->nullOnDelete()->comment('大單位ID'); + $table->foreignId('purchase_unit_id')->nullable()->after('conversion_rate')->constrained('units')->nullOnDelete()->comment('採購單位ID'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + // Remove foreign keys + $table->dropForeign(['base_unit_id']); + $table->dropForeign(['large_unit_id']); + $table->dropForeign(['purchase_unit_id']); + $table->dropColumn(['base_unit_id', 'large_unit_id', 'purchase_unit_id']); + + // Add back string columns (nullable since data is lost) + $table->string('base_unit')->nullable()->comment('基本庫存單位 (e.g. g, ml)'); + $table->string('large_unit')->nullable()->comment('大單位 (e.g. 桶, 箱)'); + $table->string('purchase_unit')->nullable()->comment('採購單位'); + }); + } +}; diff --git a/database/seeders/UnitSeeder.php b/database/seeders/UnitSeeder.php new file mode 100644 index 0000000..07eb690 --- /dev/null +++ b/database/seeders/UnitSeeder.php @@ -0,0 +1,36 @@ + '個', 'code' => 'pc'], + ['name' => '箱', 'code' => 'box'], + ['name' => '瓶', 'code' => 'btl'], + ['name' => '包', 'code' => 'pkg'], + ['name' => '公斤', 'code' => 'kg'], + ['name' => '公克', 'code' => 'g'], + ['name' => '公升', 'code' => 'l'], + ['name' => '毫升', 'code' => 'ml'], + ['name' => '籃', 'code' => 'bsk'], + ['name' => '桶', 'code' => 'bucket'], + ['name' => '罐', 'code' => 'can'], + ]; + + foreach ($units as $unit) { + Unit::firstOrCreate( + ['name' => $unit['name']], + ['code' => $unit['code']] + ); + } + } +} diff --git a/resources/js/Components/Category/CategoryManagerDialog.tsx b/resources/js/Components/Category/CategoryManagerDialog.tsx index 94bd84a..8ff264f 100644 --- a/resources/js/Components/Category/CategoryManagerDialog.tsx +++ b/resources/js/Components/Category/CategoryManagerDialog.tsx @@ -59,7 +59,6 @@ export default function CategoryManagerDialog({ post(route("categories.store"), { onSuccess: () => { reset(); - toast.success("分類已新增"); }, onError: (errors) => { toast.error("新增失敗: " + (errors.name || "未知錯誤")); @@ -83,7 +82,6 @@ export default function CategoryManagerDialog({ router.put(route("categories.update", id), { name: editName }, { onSuccess: () => { setEditingId(null); - toast.success("分類已更新"); }, onError: (errors) => { toast.error("更新失敗: " + (errors.name || "未知錯誤")); @@ -94,7 +92,7 @@ export default function CategoryManagerDialog({ const handleDelete = (id: number) => { router.delete(route("categories.destroy", id), { onSuccess: () => { - toast.success("分類已刪除"); + // 不在此處理 toast,交由全域 flash 處理 }, onError: () => { toast.error("刪除失敗,請確認該分類下無商品"); diff --git a/resources/js/Components/Product/ProductDialog.tsx b/resources/js/Components/Product/ProductDialog.tsx index 256493e..a00cd9a 100644 --- a/resources/js/Components/Product/ProductDialog.tsx +++ b/resources/js/Components/Product/ProductDialog.tsx @@ -21,19 +21,15 @@ import { import { useForm } from "@inertiajs/react"; import { toast } from "sonner"; import type { Product, Category } from "@/Pages/Product/Index"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/Components/ui/dropdown-menu"; -import { ChevronDown } from "lucide-react"; +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 } @@ -42,16 +38,17 @@ export default function ProductDialog({ onOpenChange, product, categories, + units, }: ProductDialogProps) { const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({ name: "", category_id: "", brand: "", specification: "", - base_unit: "公斤", - large_unit: "", + base_unit_id: "", + large_unit_id: "", conversion_rate: "", - purchase_unit: "", + purchase_unit_id: "", }); useEffect(() => { @@ -63,10 +60,10 @@ export default function ProductDialog({ category_id: product.category_id.toString(), brand: product.brand || "", specification: product.specification || "", - base_unit: product.base_unit, - large_unit: product.large_unit || "", + base_unit_id: product.base_unit_id?.toString() || "", + large_unit_id: product.large_unit_id?.toString() || "", conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "", - purchase_unit: product.purchase_unit || "", + purchase_unit_id: product.purchase_unit_id?.toString() || "", }); } else { reset(); @@ -188,50 +185,52 @@ export default function ProductDialog({

單位設定

-
- - setData("large_unit", e.target.value)} - placeholder="例:箱、袋" - /> - {errors.large_unit &&

{errors.large_unit}

} + + + {errors.large_unit_id &&

{errors.large_unit_id}

}
setData("conversion_rate", e.target.value)} - placeholder={data.large_unit ? `1 ${data.large_unit} = ? ${data.base_unit}` : ""} - disabled={!data.large_unit} + 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 &&

{errors.conversion_rate}

}
- - setData("purchase_unit", e.target.value)} - placeholder="通常同大單位" - /> - {errors.purchase_unit &&

{errors.purchase_unit}

} + + + {errors.purchase_unit_id &&

{errors.purchase_unit_id}

}
- {data.large_unit && data.base_unit && data.conversion_rate && ( + {data.large_unit_id && data.base_unit_id && data.conversion_rate && (
- 預覽:1 {data.large_unit} = {data.conversion_rate} {data.base_unit} + 預覽: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}
)} diff --git a/resources/js/Components/Product/ProductTable.tsx b/resources/js/Components/Product/ProductTable.tsx index 35c667d..e4a3da7 100644 --- a/resources/js/Components/Product/ProductTable.tsx +++ b/resources/js/Components/Product/ProductTable.tsx @@ -121,11 +121,11 @@ export default function ProductTable({ {product.category?.name || '-'} - {product.base_unit} + {product.baseUnit?.name || '-'} - {product.large_unit ? ( + {product.largeUnit ? ( - 1 {product.large_unit} = {Number(product.conversion_rate)} {product.base_unit} + 1 {product.largeUnit?.name} = {Number(product.conversion_rate)} {product.baseUnit?.name} ) : ( '-' diff --git a/resources/js/Components/Unit/UnitManagerDialog.tsx b/resources/js/Components/Unit/UnitManagerDialog.tsx new file mode 100644 index 0000000..80cc5df --- /dev/null +++ b/resources/js/Components/Unit/UnitManagerDialog.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/Components/ui/alert-dialog"; +import { router, useForm } from "@inertiajs/react"; +import { toast } from "sonner"; +import { Trash2, Edit2, Check, X, Plus, Loader2 } from "lucide-react"; + +export interface Unit { + id: number; + name: string; + code: string | null; +} + +interface UnitManagerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + units: Unit[]; +} + +export default function UnitManagerDialog({ + open, + onOpenChange, + units, +}: UnitManagerDialogProps) { + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editCode, setEditCode] = useState(""); + + const { data, setData, post, processing, reset, errors, clearErrors } = useForm({ + name: "", + code: "", + }); + + useEffect(() => { + if (!open) { + reset(); + clearErrors(); + setEditingId(null); + } + }, [open]); + + const handleAdd = (e: React.FormEvent) => { + e.preventDefault(); + if (!data.name.trim()) return; + + post(route("units.store"), { + onSuccess: () => { + reset(); + }, + onError: (errors) => { + toast.error("新增失敗: " + (errors.name || errors.code || "未知錯誤")); + } + }); + }; + + const startEdit = (unit: Unit) => { + setEditingId(unit.id); + setEditName(unit.name); + setEditCode(unit.code || ""); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditName(""); + setEditCode(""); + }; + + const saveEdit = (id: number) => { + if (!editName.trim()) return; + + router.put(route("units.update", id), { name: editName, code: editCode }, { + onSuccess: () => { + setEditingId(null); + }, + onError: (errors) => { + toast.error("更新失敗: " + (errors.name || errors.code || "未知錯誤")); + } + }); + }; + + const handleDelete = (id: number) => { + router.delete(route("units.destroy", id), { + onSuccess: () => { + // 由全域 flash 處理 + }, + onError: () => { + toast.error("刪除失敗,請確認該單位無關聯商品"); + } + }); + }; + + return ( + + + + 管理單位 + + 在此新增、修改或刪除常用單位。刪除前請確認無關聯商品。 + + + +
+ {/* Add New Section */} +
+

快速新增

+
+
+
+ + setData("name", e.target.value)} + className={errors.name ? "border-red-500" : ""} + /> + {errors.name &&

{errors.name}

} +
+
+ + setData("code", e.target.value)} + className={errors.code ? "border-red-500" : ""} + /> + {errors.code &&

{errors.code}

} +
+
+ +
+
+ + {/* List Section */} +
+
+

現有單位

+ 共 {units.length} 個項目 +
+ +
+ + + + # + 單位名稱 + 代碼 + 操作 + + + + {units.length === 0 ? ( + + + 目前尚無單位,請從上方新增。 + + + ) : ( + units.map((unit, index) => ( + + + {index + 1} + + + {editingId === unit.id ? ( + setEditName(e.target.value)} + className="h-9 focus-visible:ring-1" + autoFocus + placeholder="單位名稱" + onKeyDown={(e) => { + if (e.key === 'Enter') saveEdit(unit.id); + if (e.key === 'Escape') cancelEdit(); + }} + /> + ) : ( + {unit.name} + )} + + + {editingId === unit.id ? ( + setEditCode(e.target.value)} + className="h-9 focus-visible:ring-1" + placeholder="代碼" + onKeyDown={(e) => { + if (e.key === 'Enter') saveEdit(unit.id); + if (e.key === 'Escape') cancelEdit(); + }} + /> + ) : ( + {unit.code || '-'} + )} + + + {editingId === unit.id ? ( +
+ + +
+ ) : ( +
+ + + + + + + + + 確認刪除單位 + + 確定要刪除「{unit.name}」嗎?
+ 若該單位下仍有商品,系統將會拒絕刪除。 +
+
+ + 取消 + handleDelete(unit.id)} + className="bg-red-600 hover:bg-red-700" + > + 確認刪除 + + +
+
+
+ )} +
+
+ )) + )} +
+
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/resources/js/Components/ui/alert-dialog.tsx b/resources/js/Components/ui/alert-dialog.tsx index 5fe79d3..160e9f1 100644 --- a/resources/js/Components/ui/alert-dialog.tsx +++ b/resources/js/Components/ui/alert-dialog.tsx @@ -12,13 +12,20 @@ function AlertDialog({ return ; } -function AlertDialogTrigger({ - ...props -}: React.ComponentProps) { +const AlertDialogTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { return ( - + ); -} +}); +AlertDialogTrigger.displayName = AlertDialogPrimitive.Trigger.displayName; function AlertDialogPortal({ ...props @@ -28,119 +35,140 @@ function AlertDialogPortal({ ); } -function AlertDialogOverlay({ - className, - ...props -}: React.ComponentProps) { +const AlertDialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { return ( ); -} +}); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; -function AlertDialogContent({ - className, - ...props -}: React.ComponentProps) { +const AlertDialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { return ( ); -} +}); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; -function AlertDialogHeader({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ); -} +const AlertDialogHeader = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( +
+)); +AlertDialogHeader.displayName = "AlertDialogHeader"; -function AlertDialogFooter({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ); -} +const AlertDialogFooter = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( +
+)); +AlertDialogFooter.displayName = "AlertDialogFooter"; -function AlertDialogTitle({ - className, - ...props -}: React.ComponentProps) { +const AlertDialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { return ( ); -} +}); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; -function AlertDialogDescription({ - className, - ...props -}: React.ComponentProps) { +const AlertDialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { return ( ); -} +}); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; -function AlertDialogAction({ - className, - ...props -}: React.ComponentProps) { +const AlertDialogAction = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { return ( ); -} +}); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; -function AlertDialogCancel({ - className, - ...props -}: React.ComponentProps) { +const AlertDialogCancel = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { return ( ); -} +}); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { AlertDialog, diff --git a/resources/js/Components/ui/dialog.tsx b/resources/js/Components/ui/dialog.tsx index 2ff996f..46e1eb7 100644 --- a/resources/js/Components/ui/dialog.tsx +++ b/resources/js/Components/ui/dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { XIcon } from "lucide-react"; +import { X } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -12,11 +12,20 @@ function Dialog({ return ; } -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return ; -} +const DialogTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => { + return ( + + ); +}); +DialogTrigger.displayName = DialogPrimitive.Trigger.displayName; function DialogPortal({ ...props @@ -33,96 +42,98 @@ function DialogClose({ const DialogOverlay = React.forwardRef< React.ComponentRef, React.ComponentProps ->(({ className, ...props }, ref) => { - return ( - - ); -}); -DialogOverlay.displayName = "DialogOverlay"; +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ComponentRef, React.ComponentProps ->(({ className, children, ...props }, ref) => { - return ( - - - - {children} - - - Close - - - - ); -}); -DialogContent.displayName = "DialogContent"; - -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
(({ className, children, ...props }, ref) => ( + + + - ); -} + > + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const DialogHeader = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( +
+)); +DialogHeader.displayName = "DialogHeader"; -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const DialogFooter = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( +
+)); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, diff --git a/resources/js/Components/ui/select.tsx b/resources/js/Components/ui/select.tsx index caedaa7..5ea6d59 100644 --- a/resources/js/Components/ui/select.tsx +++ b/resources/js/Components/ui/select.tsx @@ -82,9 +82,9 @@ function SelectContent({ {children} diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 4f8563d..9b6464d 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -15,7 +15,7 @@ import { User, ChevronDown } from "lucide-react"; -import { Toaster } from "sonner"; +import { toast, Toaster } from "sonner"; import { useState, useEffect } from "react"; import { Link, usePage } from "@inertiajs/react"; import { cn } from "@/lib/utils"; @@ -139,6 +139,20 @@ export default function AuthenticatedLayout({ localStorage.setItem("sidebar-collapsed", String(isCollapsed)); }, [isCollapsed]); + // 全域監聽 flash 訊息並顯示 Toast + useEffect(() => { + // @ts-ignore + if (props.flash?.success) { + // @ts-ignore + toast.success(props.flash.success); + } + // @ts-ignore + if (props.flash?.error) { + // @ts-ignore + toast.error(props.flash.error); + } + }, [props.flash]); + const toggleExpand = (itemId: string) => { if (isCollapsed) { setIsCollapsed(false); diff --git a/resources/js/Pages/Product/Index.tsx b/resources/js/Pages/Product/Index.tsx index e93c090..b7e73f6 100644 --- a/resources/js/Pages/Product/Index.tsx +++ b/resources/js/Pages/Product/Index.tsx @@ -12,6 +12,7 @@ import { Plus, Search, X } from "lucide-react"; import ProductTable from "@/Components/Product/ProductTable"; import ProductDialog from "@/Components/Product/ProductDialog"; import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog"; +import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, router } from "@inertiajs/react"; import { debounce } from "lodash"; @@ -31,10 +32,13 @@ export interface Product { category?: Category; brand?: string; specification?: string; - base_unit: string; - large_unit?: string; + base_unit_id: number; + baseUnit?: Unit; + large_unit_id?: number; + largeUnit?: Unit; conversion_rate?: number; - purchase_unit?: string; + purchase_unit_id?: number; + purchaseUnit?: Unit; created_at: string; updated_at: string; } @@ -46,6 +50,7 @@ interface PageProps { from: number; }; categories: Category[]; + units: Unit[]; filters: { search?: string; category_id?: string; @@ -55,7 +60,7 @@ interface PageProps { }; } -export default function ProductManagement({ products, categories, filters }: PageProps) { +export default function ProductManagement({ products, categories, units, filters }: PageProps) { const [searchTerm, setSearchTerm] = useState(filters.search || ""); const [typeFilter, setTypeFilter] = useState(filters.category_id || "all"); const [perPage, setPerPage] = useState(filters.per_page || "10"); @@ -63,6 +68,7 @@ export default function ProductManagement({ products, categories, filters }: Pag 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 [editingProduct, setEditingProduct] = useState(null); // Sync state with props when they change (e.g. navigation) @@ -163,13 +169,11 @@ export default function ProductManagement({ products, categories, filters }: Pag }; const handleDeleteProduct = (id: number) => { - if (confirm("確定要刪除嗎?")) { - router.delete(route('products.destroy', id), { - onSuccess: () => { - // Toast handled by flash message usually, or add here if needed - } - }); - } + router.delete(route('products.destroy', id), { + onSuccess: () => { + // Toast handled by flash message + } + }); }; return ( @@ -226,6 +230,13 @@ export default function ProductManagement({ products, categories, filters }: Pag > 管理分類 +
); diff --git a/routes/web.php b/routes/web.php index c9c2b69..81f471d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Http\Controllers\WarehouseController; use App\Http\Controllers\InventoryController; use App\Http\Controllers\SafetyStockController; use App\Http\Controllers\TransferOrderController; +use App\Http\Controllers\UnitController; Route::get('/login', [LoginController::class, 'show'])->name('login'); Route::post('/login', [LoginController::class, 'store']); @@ -27,10 +28,16 @@ Route::middleware('auth')->group(function () { Route::put('/categories/{category}', [CategoryController::class, 'update'])->name('categories.update'); Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy'); + // 單位管理 + Route::post('/units', [UnitController::class, 'store'])->name('units.store'); + Route::put('/units/{unit}', [UnitController::class, 'update'])->name('units.update'); + Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->name('units.destroy'); + // 商品管理 Route::get('/products', [ProductController::class, 'index'])->name('products.index'); Route::post('/products', [ProductController::class, 'store'])->name('products.store'); Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update'); + Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy'); // 廠商管理 Route::get('/vendors', [VendorController::class, 'index'])->name('vendors.index');