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}
}
換算率
- {data.large_unit && *}
+ {data.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 (
+
+ );
+}
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');