新增單位管理以及一些功能修正
This commit is contained in:
@@ -71,4 +71,7 @@ Routes: kebab-case (小寫橫線分隔)
|
||||
|
||||
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||
|
||||
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
|
||||
7.運行機制
|
||||
因為是運行在docker上 所以要執行php的話 要執行docker exce
|
||||
@@ -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);
|
||||
|
||||
70
app/Http/Controllers/UnitController.php
Normal file
70
app/Http/Controllers/UnitController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Unit;
|
||||
use App\Models\Product; // Import Product to check for usage
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UnitController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->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', '單位已刪除');
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ class HandleInertiaRequests extends Middleware
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
],
|
||||
'flash' => [
|
||||
'success' => $request->session()->get('success'),
|
||||
'error' => $request->session()->get('error'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
17
app/Models/Unit.php
Normal file
17
app/Models/Unit.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Unit extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UnitFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
];
|
||||
}
|
||||
29
database/migrations/2026_01_08_103000_create_units_table.php
Normal file
29
database/migrations/2026_01_08_103000_create_units_table.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('units', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique()->comment('單位名稱');
|
||||
$table->string('code')->nullable()->comment('單位代碼 (如: kg)');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('units');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// Drop old string columns
|
||||
$table->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('採購單位');
|
||||
});
|
||||
}
|
||||
};
|
||||
36
database/seeders/UnitSeeder.php
Normal file
36
database/seeders/UnitSeeder.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Unit;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class UnitSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$units = [
|
||||
['name' => '個', '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']]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("刪除失敗,請確認該分類下無商品");
|
||||
|
||||
@@ -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({
|
||||
<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">
|
||||
<Label htmlFor="base_unit_id">
|
||||
基本庫存單位 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="base_unit"
|
||||
value={data.base_unit}
|
||||
onChange={(e) => setData("base_unit", e.target.value)}
|
||||
placeholder="可輸入或選擇..."
|
||||
className={errors.base_unit ? "border-red-500 flex-1" : "flex-1"}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{["公斤", "公克", "公升", "毫升", "個", "支", "包", "罐", "瓶", "箱", "袋"].map((u) => (
|
||||
<DropdownMenuItem key={u} onClick={() => setData("base_unit", u)}>
|
||||
{u}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{errors.base_unit && <p className="text-sm text-red-500">{errors.base_unit}</p>}
|
||||
<Select
|
||||
value={data.base_unit_id}
|
||||
onValueChange={(value) => setData("base_unit_id", value)}
|
||||
>
|
||||
<SelectTrigger id="base_unit_id" className={errors.base_unit_id ? "border-red-500" : ""}>
|
||||
<SelectValue placeholder="選擇單位" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((unit) => (
|
||||
<SelectItem key={unit.id} value={unit.id.toString()}>
|
||||
{unit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{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">大單位</Label>
|
||||
<Input
|
||||
id="large_unit"
|
||||
value={data.large_unit}
|
||||
onChange={(e) => setData("large_unit", e.target.value)}
|
||||
placeholder="例:箱、袋"
|
||||
/>
|
||||
{errors.large_unit && <p className="text-sm text-red-500">{errors.large_unit}</p>}
|
||||
<Label htmlFor="large_unit_id">大單位</Label>
|
||||
<Select
|
||||
value={data.large_unit_id}
|
||||
onValueChange={(value) => setData("large_unit_id", value)}
|
||||
>
|
||||
<SelectTrigger id="large_unit_id" className={errors.large_unit_id ? "border-red-500" : ""}>
|
||||
<SelectValue placeholder="無" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">無</SelectItem>
|
||||
{units.map((unit) => (
|
||||
<SelectItem key={unit.id} value={unit.id.toString()}>
|
||||
{unit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{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 && <span className="text-red-500">*</span>}
|
||||
{data.large_unit_id && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="conversion_rate"
|
||||
@@ -239,27 +238,37 @@ export default function ProductDialog({
|
||||
step="0.0001"
|
||||
value={data.conversion_rate}
|
||||
onChange={(e) => 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 && <p className="text-sm text-red-500">{errors.conversion_rate}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="purchase_unit">採購單位</Label>
|
||||
<Input
|
||||
id="purchase_unit"
|
||||
value={data.purchase_unit}
|
||||
onChange={(e) => setData("purchase_unit", e.target.value)}
|
||||
placeholder="通常同大單位"
|
||||
/>
|
||||
{errors.purchase_unit && <p className="text-sm text-red-500">{errors.purchase_unit}</p>}
|
||||
<Label htmlFor="purchase_unit_id">採購單位</Label>
|
||||
<Select
|
||||
value={data.purchase_unit_id}
|
||||
onValueChange={(value) => setData("purchase_unit_id", value)}
|
||||
>
|
||||
<SelectTrigger id="purchase_unit_id" className={errors.purchase_unit_id ? "border-red-500" : ""}>
|
||||
<SelectValue placeholder="通常同大單位" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">無</SelectItem>
|
||||
{units.map((unit) => (
|
||||
<SelectItem key={unit.id} value={unit.id.toString()}>
|
||||
{unit.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.purchase_unit_id && <p className="text-sm text-red-500">{errors.purchase_unit_id}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.large_unit && data.base_unit && data.conversion_rate && (
|
||||
{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 {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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -121,11 +121,11 @@ export default function ProductTable({
|
||||
{product.category?.name || '-'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{product.base_unit}</TableCell>
|
||||
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{product.large_unit ? (
|
||||
{product.largeUnit ? (
|
||||
<span className="text-sm text-gray-500">
|
||||
1 {product.large_unit} = {Number(product.conversion_rate)} {product.base_unit}
|
||||
1 {product.largeUnit?.name} = {Number(product.conversion_rate)} {product.baseUnit?.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
|
||||
309
resources/js/Components/Unit/UnitManagerDialog.tsx
Normal file
309
resources/js/Components/Unit/UnitManagerDialog.tsx
Normal file
@@ -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<number | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理單位</DialogTitle>
|
||||
<DialogDescription>
|
||||
在此新增、修改或刪除常用單位。刪除前請確認無關聯商品。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 space-y-6">
|
||||
{/* Add New Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-l-4 border-primary pl-2">快速新增</h3>
|
||||
<form onSubmit={handleAdd} className="flex items-end gap-3 p-4 bg-white border rounded-lg shadow-sm">
|
||||
<div className="flex-1 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-unit-name" className="text-xs text-gray-500">單位名稱</Label>
|
||||
<Input
|
||||
id="new-unit-name"
|
||||
placeholder="例如: 箱, 包"
|
||||
value={data.name}
|
||||
onChange={(e) => setData("name", e.target.value)}
|
||||
className={errors.name ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-unit-code" className="text-xs text-gray-500">代碼 (選填)</Label>
|
||||
<Input
|
||||
id="new-unit-code"
|
||||
placeholder="例如: box, kg"
|
||||
value={data.code}
|
||||
onChange={(e) => setData("code", e.target.value)}
|
||||
className={errors.code ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={processing} className="button-filled-primary h-10 px-6">
|
||||
{processing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
新增
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* List Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium border-l-4 border-primary pl-2">現有單位</h3>
|
||||
<span className="text-xs text-gray-400">共 {units.length} 個項目</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] font-medium text-gray-700">#</TableHead>
|
||||
<TableHead className="font-medium text-gray-700">單位名稱</TableHead>
|
||||
<TableHead className="font-medium text-gray-700">代碼</TableHead>
|
||||
<TableHead className="w-[140px] text-right font-medium text-gray-700">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{units.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-12 text-gray-400">
|
||||
目前尚無單位,請從上方新增。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
units.map((unit, index) => (
|
||||
<TableRow key={unit.id}>
|
||||
<TableCell className="py-3 text-center text-gray-500 font-medium">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
{editingId === unit.id ? (
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => 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();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium text-gray-700">{unit.name}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
{editingId === unit.id ? (
|
||||
<Input
|
||||
value={editCode}
|
||||
onChange={(e) => 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();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-500">{unit.code || '-'}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-3">
|
||||
{editingId === unit.id ? (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => saveEdit(unit.id)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 button-outlined-primary"
|
||||
onClick={() => startEdit(unit)}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 button-outlined-error"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除單位</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除「{unit.name}」嗎?<br />
|
||||
若該單位下仍有商品,系統將會拒絕刪除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(unit.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="button-outlined-primary px-8"
|
||||
>
|
||||
關閉
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,20 @@ function AlertDialog({
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
const AlertDialogTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Trigger>,
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
<AlertDialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-trigger"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
AlertDialogTrigger.displayName = AlertDialogPrimitive.Trigger.displayName;
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
@@ -28,119 +35,140 @@ function AlertDialogPortal({
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[150] bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[150] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const AlertDialogHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const AlertDialogFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), "bg-red-600 hover:bg-red-700 text-white border-transparent", className)}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
"bg-red-600 hover:bg-red-700 text-white border-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), "button-outlined-primary mt-0", className)}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"button-outlined-primary mt-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
|
||||
@@ -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 <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentProps<typeof DialogPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dialog-trigger"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
@@ -33,96 +42,98 @@ function DialogClose({
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentProps<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[100] bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[100] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DialogHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DialogFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentProps<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentProps<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
|
||||
@@ -82,9 +82,9 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[150] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
@@ -95,7 +95,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>(filters.category_id || "all");
|
||||
const [perPage, setPerPage] = useState<string>(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<Product | null>(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
|
||||
>
|
||||
管理分類
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsUnitDialogOpen(true)}
|
||||
className="flex-1 md:flex-none button-outlined-primary"
|
||||
>
|
||||
管理單位
|
||||
</Button>
|
||||
<Button onClick={handleAddProduct} className="flex-1 md:flex-none button-filled-primary">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增商品
|
||||
@@ -270,6 +281,7 @@ export default function ProductManagement({ products, categories, filters }: Pag
|
||||
onOpenChange={setIsDialogOpen}
|
||||
product={editingProduct}
|
||||
categories={categories}
|
||||
units={units}
|
||||
/>
|
||||
|
||||
<CategoryManagerDialog
|
||||
@@ -277,6 +289,12 @@ export default function ProductManagement({ products, categories, filters }: Pag
|
||||
onOpenChange={setIsCategoryDialogOpen}
|
||||
categories={categories}
|
||||
/>
|
||||
|
||||
<UnitManagerDialog
|
||||
open={isUnitDialogOpen}
|
||||
onOpenChange={setIsUnitDialogOpen}
|
||||
units={units}
|
||||
/>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user