新增單位管理以及一些功能修正

This commit is contained in:
2026-01-08 11:52:25 +08:00
parent eca2f38395
commit 48115082e5
19 changed files with 872 additions and 246 deletions

View File

@@ -71,4 +71,7 @@ Routes: kebab-case (小寫橫線分隔)
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
7.運行機制
因為是運行在docker上 所以要執行php的話 要執行docker exce

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Product; use App\Models\Product;
use App\Models\Unit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -14,7 +15,7 @@ class ProductController extends Controller
*/ */
public function index(Request $request): Response public function index(Request $request): Response
{ {
$query = Product::with('category'); $query = Product::with(['category', 'baseUnit', 'largeUnit', 'purchaseUnit']);
if ($request->filled('search')) { if ($request->filled('search')) {
$search = $request->search; $search = $request->search;
@@ -61,8 +62,10 @@ class ProductController extends Controller
$categories = \App\Models\Category::where('is_active', true)->get(); $categories = \App\Models\Category::where('is_active', true)->get();
return Inertia::render('Product/Index', [ return Inertia::render('Product/Index', [
'products' => $products,
'products' => $products, 'products' => $products,
'categories' => $categories, 'categories' => $categories,
'units' => Unit::all(),
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']), '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', 'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255', 'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string', 'specification' => 'nullable|string',
'base_unit' => 'required|string|max:50',
'large_unit' => 'nullable|string|max:50', 'base_unit_id' => 'required|exists:units,id',
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001', 'large_unit_id' => 'nullable|exists:units,id',
'purchase_unit' => 'nullable|string|max:50', 'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
'purchase_unit_id' => 'nullable|exists:units,id',
], [ ], [
'name.required' => '商品名稱為必填', 'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類', 'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在', 'category_id.exists' => '所選分類不存在',
'base_unit.required' => '基本庫存單位為必填', 'base_unit_id.required' => '基本庫存單位為必填',
'base_unit_id.exists' => '所選基本單位不存在',
'conversion_rate.required_with' => '填寫大單位時,換算率為必填', 'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字', 'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001', 'conversion_rate.min' => '換算率最小為 0.0001',
@@ -109,14 +114,24 @@ class ProductController extends Controller
*/ */
public function update(Request $request, Product $product) public function update(Request $request, Product $product)
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id', 'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255', 'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string', 'specification' => 'nullable|string',
'base_unit' => 'required|string|max:50', 'base_unit_id' => 'required|exists:units,id',
'large_unit' => 'nullable|string|max:50', 'large_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001', '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); $product->update($validated);

View 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', '單位已刪除');
}
}

View File

@@ -40,6 +40,10 @@ class HandleInertiaRequests extends Middleware
'auth' => [ 'auth' => [
'user' => $request->user(), 'user' => $request->user(),
], ],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
]; ];
} }
} }

View File

@@ -17,10 +17,10 @@ class Product extends Model
'category_id', 'category_id',
'brand', 'brand',
'specification', 'specification',
'base_unit', 'base_unit_id',
'large_unit', 'large_unit_id',
'conversion_rate', 'conversion_rate',
'purchase_unit', 'purchase_unit_id',
]; ];
protected $casts = [ protected $casts = [
@@ -35,6 +35,21 @@ class Product extends Model
return $this->belongsTo(Category::class); 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 public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{ {
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps(); return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();

17
app/Models/Unit.php Normal file
View 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',
];
}

View 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');
}
};

View File

@@ -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('採購單位');
});
}
};

View 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']]
);
}
}
}

View File

@@ -59,7 +59,6 @@ export default function CategoryManagerDialog({
post(route("categories.store"), { post(route("categories.store"), {
onSuccess: () => { onSuccess: () => {
reset(); reset();
toast.success("分類已新增");
}, },
onError: (errors) => { onError: (errors) => {
toast.error("新增失敗: " + (errors.name || "未知錯誤")); toast.error("新增失敗: " + (errors.name || "未知錯誤"));
@@ -83,7 +82,6 @@ export default function CategoryManagerDialog({
router.put(route("categories.update", id), { name: editName }, { router.put(route("categories.update", id), { name: editName }, {
onSuccess: () => { onSuccess: () => {
setEditingId(null); setEditingId(null);
toast.success("分類已更新");
}, },
onError: (errors) => { onError: (errors) => {
toast.error("更新失敗: " + (errors.name || "未知錯誤")); toast.error("更新失敗: " + (errors.name || "未知錯誤"));
@@ -94,7 +92,7 @@ export default function CategoryManagerDialog({
const handleDelete = (id: number) => { const handleDelete = (id: number) => {
router.delete(route("categories.destroy", id), { router.delete(route("categories.destroy", id), {
onSuccess: () => { onSuccess: () => {
toast.success("分類已刪除"); // 不在此處理 toast交由全域 flash 處理
}, },
onError: () => { onError: () => {
toast.error("刪除失敗,請確認該分類下無商品"); toast.error("刪除失敗,請確認該分類下無商品");

View File

@@ -21,19 +21,15 @@ import {
import { useForm } from "@inertiajs/react"; import { useForm } from "@inertiajs/react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Product, Category } from "@/Pages/Product/Index"; import type { Product, Category } from "@/Pages/Product/Index";
import { import type { Unit } from "@/Components/Unit/UnitManagerDialog";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
interface ProductDialogProps { interface ProductDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
product: Product | null; product: Product | null;
categories: Category[]; categories: Category[];
units: Unit[];
onSave?: (product: any) => void; // Legacy prop, can be removed if fully switching to Inertia submit within dialog 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, onOpenChange,
product, product,
categories, categories,
units,
}: ProductDialogProps) { }: ProductDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({ const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
name: "", name: "",
category_id: "", category_id: "",
brand: "", brand: "",
specification: "", specification: "",
base_unit: "公斤", base_unit_id: "",
large_unit: "", large_unit_id: "",
conversion_rate: "", conversion_rate: "",
purchase_unit: "", purchase_unit_id: "",
}); });
useEffect(() => { useEffect(() => {
@@ -63,10 +60,10 @@ export default function ProductDialog({
category_id: product.category_id.toString(), category_id: product.category_id.toString(),
brand: product.brand || "", brand: product.brand || "",
specification: product.specification || "", specification: product.specification || "",
base_unit: product.base_unit, base_unit_id: product.base_unit_id?.toString() || "",
large_unit: product.large_unit || "", large_unit_id: product.large_unit_id?.toString() || "",
conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "", conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "",
purchase_unit: product.purchase_unit || "", purchase_unit_id: product.purchase_unit_id?.toString() || "",
}); });
} else { } else {
reset(); reset();
@@ -188,50 +185,52 @@ export default function ProductDialog({
<h3 className="text-lg font-medium border-b pb-2"></h3> <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="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="base_unit"> <Label htmlFor="base_unit_id">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</Label> </Label>
<div className="flex gap-2"> <Select
<Input value={data.base_unit_id}
id="base_unit" onValueChange={(value) => setData("base_unit_id", value)}
value={data.base_unit} >
onChange={(e) => setData("base_unit", e.target.value)} <SelectTrigger id="base_unit_id" className={errors.base_unit_id ? "border-red-500" : ""}>
placeholder="可輸入或選擇..." <SelectValue placeholder="選擇單位" />
className={errors.base_unit ? "border-red-500 flex-1" : "flex-1"} </SelectTrigger>
/> <SelectContent>
<DropdownMenu> {units.map((unit) => (
<DropdownMenuTrigger asChild> <SelectItem key={unit.id} value={unit.id.toString()}>
<Button variant="outline" size="icon" className="shrink-0"> {unit.name}
<ChevronDown className="h-4 w-4" /> </SelectItem>
</Button> ))}
</DropdownMenuTrigger> </SelectContent>
<DropdownMenuContent align="end"> </Select>
{["公斤", "公克", "公升", "毫升", "個", "支", "包", "罐", "瓶", "箱", "袋"].map((u) => ( {errors.base_unit_id && <p className="text-sm text-red-500">{errors.base_unit_id}</p>}
<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>}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="large_unit"></Label> <Label htmlFor="large_unit_id"></Label>
<Input <Select
id="large_unit" value={data.large_unit_id}
value={data.large_unit} onValueChange={(value) => setData("large_unit_id", value)}
onChange={(e) => setData("large_unit", e.target.value)} >
placeholder="例:箱、袋" <SelectTrigger id="large_unit_id" className={errors.large_unit_id ? "border-red-500" : ""}>
/> <SelectValue placeholder="無" />
{errors.large_unit && <p className="text-sm text-red-500">{errors.large_unit}</p>} </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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="conversion_rate"> <Label htmlFor="conversion_rate">
{data.large_unit && <span className="text-red-500">*</span>} {data.large_unit_id && <span className="text-red-500">*</span>}
</Label> </Label>
<Input <Input
id="conversion_rate" id="conversion_rate"
@@ -239,27 +238,37 @@ export default function ProductDialog({
step="0.0001" step="0.0001"
value={data.conversion_rate} value={data.conversion_rate}
onChange={(e) => setData("conversion_rate", e.target.value)} onChange={(e) => setData("conversion_rate", e.target.value)}
placeholder={data.large_unit ? `1 ${data.large_unit} = ? ${data.base_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} disabled={!data.large_unit_id}
/> />
{errors.conversion_rate && <p className="text-sm text-red-500">{errors.conversion_rate}</p>} {errors.conversion_rate && <p className="text-sm text-red-500">{errors.conversion_rate}</p>}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="purchase_unit"></Label> <Label htmlFor="purchase_unit_id"></Label>
<Input <Select
id="purchase_unit" value={data.purchase_unit_id}
value={data.purchase_unit} onValueChange={(value) => setData("purchase_unit_id", value)}
onChange={(e) => setData("purchase_unit", e.target.value)} >
placeholder="通常同大單位" <SelectTrigger id="purchase_unit_id" className={errors.purchase_unit_id ? "border-red-500" : ""}>
/> <SelectValue placeholder="通常同大單位" />
{errors.purchase_unit && <p className="text-sm text-red-500">{errors.purchase_unit}</p>} </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>
</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"> <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>
)} )}
</div> </div>

View File

@@ -121,11 +121,11 @@ export default function ProductTable({
{product.category?.name || '-'} {product.category?.name || '-'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{product.base_unit}</TableCell> <TableCell>{product.baseUnit?.name || '-'}</TableCell>
<TableCell> <TableCell>
{product.large_unit ? ( {product.largeUnit ? (
<span className="text-sm text-gray-500"> <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> </span>
) : ( ) : (
'-' '-'

View 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>
);
}

View File

@@ -12,13 +12,20 @@ function AlertDialog({
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />; return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
} }
function AlertDialogTrigger({ const AlertDialogTrigger = React.forwardRef<
...props React.ComponentRef<typeof AlertDialogPrimitive.Trigger>,
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { React.ComponentProps<typeof AlertDialogPrimitive.Trigger>
>(({ className, ...props }, ref) => {
return ( 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({ function AlertDialogPortal({
...props ...props
@@ -28,119 +35,140 @@ function AlertDialogPortal({
); );
} }
function AlertDialogOverlay({ const AlertDialogOverlay = React.forwardRef<
className, React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
...props React.ComponentProps<typeof AlertDialogPrimitive.Overlay>
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) { >(({ className, ...props }, ref) => {
return ( return (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
ref={ref}
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( 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, className,
)} )}
{...props} {...props}
/> />
); );
} });
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
function AlertDialogContent({ const AlertDialogContent = React.forwardRef<
className, React.ComponentRef<typeof AlertDialogPrimitive.Content>,
...props React.ComponentProps<typeof AlertDialogPrimitive.Content>
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) { >(({ className, ...props }, ref) => {
return ( return (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay /> <AlertDialogOverlay />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref}
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( 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, className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
); );
} });
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
function AlertDialogHeader({ const AlertDialogHeader = React.forwardRef<
className, HTMLDivElement,
...props React.ComponentProps<"div">
}: React.ComponentProps<"div">) { >(({ className, ...props }, ref) => (
return ( <div
<div ref={ref}
data-slot="alert-dialog-header" data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
); ));
} AlertDialogHeader.displayName = "AlertDialogHeader";
function AlertDialogFooter({ const AlertDialogFooter = React.forwardRef<
className, HTMLDivElement,
...props React.ComponentProps<"div">
}: React.ComponentProps<"div">) { >(({ className, ...props }, ref) => (
return ( <div
<div ref={ref}
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className, className,
)} )}
{...props} {...props}
/> />
); ));
} AlertDialogFooter.displayName = "AlertDialogFooter";
function AlertDialogTitle({ const AlertDialogTitle = React.forwardRef<
className, React.ComponentRef<typeof AlertDialogPrimitive.Title>,
...props React.ComponentProps<typeof AlertDialogPrimitive.Title>
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { >(({ className, ...props }, ref) => {
return ( return (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
ref={ref}
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
); );
} });
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
function AlertDialogDescription({ const AlertDialogDescription = React.forwardRef<
className, React.ComponentRef<typeof AlertDialogPrimitive.Description>,
...props React.ComponentProps<typeof AlertDialogPrimitive.Description>
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) { >(({ className, ...props }, ref) => {
return ( return (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
ref={ref}
data-slot="alert-dialog-description" data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
); );
} });
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
function AlertDialogAction({ const AlertDialogAction = React.forwardRef<
className, React.ComponentRef<typeof AlertDialogPrimitive.Action>,
...props React.ComponentProps<typeof AlertDialogPrimitive.Action>
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) { >(({ className, ...props }, ref) => {
return ( return (
<AlertDialogPrimitive.Action <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} {...props}
/> />
); );
} });
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
function AlertDialogCancel({ const AlertDialogCancel = React.forwardRef<
className, React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
...props React.ComponentProps<typeof AlertDialogPrimitive.Cancel>
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { >(({ className, ...props }, ref) => {
return ( return (
<AlertDialogPrimitive.Cancel <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} {...props}
/> />
); );
} });
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export { export {
AlertDialog, AlertDialog,

View File

@@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -12,11 +12,20 @@ function Dialog({
return <DialogPrimitive.Root data-slot="dialog" {...props} />; return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ const DialogTrigger = React.forwardRef<
...props React.ComponentRef<typeof DialogPrimitive.Trigger>,
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { React.ComponentProps<typeof DialogPrimitive.Trigger>
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; >(({ className, ...props }, ref) => {
} return (
<DialogPrimitive.Trigger
ref={ref}
data-slot="dialog-trigger"
className={className}
{...props}
/>
);
});
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
function DialogPortal({ function DialogPortal({
...props ...props
@@ -33,96 +42,98 @@ function DialogClose({
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>, React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentProps<typeof DialogPrimitive.Overlay> React.ComponentProps<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => (
return ( <DialogPrimitive.Overlay
<DialogPrimitive.Overlay ref={ref}
ref={ref} data-slot="dialog-overlay"
data-slot="dialog-overlay" className={cn(
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",
"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,
className, )}
)} {...props}
{...props} />
/> ));
); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
});
DialogOverlay.displayName = "DialogOverlay";
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>, React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentProps<typeof DialogPrimitive.Content> React.ComponentProps<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => (
return ( <DialogPortal>
<DialogPortal data-slot="dialog-portal"> <DialogOverlay />
<DialogOverlay /> <DialogPrimitive.Content
<DialogPrimitive.Content ref={ref}
ref={ref} data-slot="dialog-content"
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={cn( 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, className,
)} )}
{...props} {...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({ const DialogHeader = React.forwardRef<
className, HTMLDivElement,
...props React.ComponentProps<"div">
}: React.ComponentProps<typeof DialogPrimitive.Title>) { >(({ className, ...props }, ref) => (
return ( <div
<DialogPrimitive.Title ref={ref}
data-slot="dialog-title" data-slot="dialog-header"
className={cn("text-lg leading-none font-semibold", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
); ));
} DialogHeader.displayName = "DialogHeader";
function DialogDescription({ const DialogFooter = React.forwardRef<
className, HTMLDivElement,
...props React.ComponentProps<"div">
}: React.ComponentProps<typeof DialogPrimitive.Description>) { >(({ className, ...props }, ref) => (
return ( <div
<DialogPrimitive.Description ref={ref}
data-slot="dialog-description" data-slot="dialog-footer"
className={cn("text-muted-foreground text-sm", className)} className={cn(
{...props} "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 { export {
Dialog, Dialog,

View File

@@ -82,9 +82,9 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( 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" && 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, className,
)} )}
position={position} position={position}
@@ -95,7 +95,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && 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} {children}

View File

@@ -15,7 +15,7 @@ import {
User, User,
ChevronDown ChevronDown
} from "lucide-react"; } from "lucide-react";
import { Toaster } from "sonner"; import { toast, Toaster } from "sonner";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Link, usePage } from "@inertiajs/react"; import { Link, usePage } from "@inertiajs/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -139,6 +139,20 @@ export default function AuthenticatedLayout({
localStorage.setItem("sidebar-collapsed", String(isCollapsed)); localStorage.setItem("sidebar-collapsed", String(isCollapsed));
}, [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) => { const toggleExpand = (itemId: string) => {
if (isCollapsed) { if (isCollapsed) {
setIsCollapsed(false); setIsCollapsed(false);

View File

@@ -12,6 +12,7 @@ import { Plus, Search, X } from "lucide-react";
import ProductTable from "@/Components/Product/ProductTable"; import ProductTable from "@/Components/Product/ProductTable";
import ProductDialog from "@/Components/Product/ProductDialog"; import ProductDialog from "@/Components/Product/ProductDialog";
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog"; import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router } from "@inertiajs/react"; import { Head, router } from "@inertiajs/react";
import { debounce } from "lodash"; import { debounce } from "lodash";
@@ -31,10 +32,13 @@ export interface Product {
category?: Category; category?: Category;
brand?: string; brand?: string;
specification?: string; specification?: string;
base_unit: string; base_unit_id: number;
large_unit?: string; baseUnit?: Unit;
large_unit_id?: number;
largeUnit?: Unit;
conversion_rate?: number; conversion_rate?: number;
purchase_unit?: string; purchase_unit_id?: number;
purchaseUnit?: Unit;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -46,6 +50,7 @@ interface PageProps {
from: number; from: number;
}; };
categories: Category[]; categories: Category[];
units: Unit[];
filters: { filters: {
search?: string; search?: string;
category_id?: 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 [searchTerm, setSearchTerm] = useState(filters.search || "");
const [typeFilter, setTypeFilter] = useState<string>(filters.category_id || "all"); const [typeFilter, setTypeFilter] = useState<string>(filters.category_id || "all");
const [perPage, setPerPage] = useState<string>(filters.per_page || "10"); 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 [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(filters.sort_direction as "asc" | "desc" || null);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false); const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false);
const [isUnitDialogOpen, setIsUnitDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null); const [editingProduct, setEditingProduct] = useState<Product | null>(null);
// Sync state with props when they change (e.g. navigation) // 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) => { const handleDeleteProduct = (id: number) => {
if (confirm("確定要刪除嗎?")) { router.delete(route('products.destroy', id), {
router.delete(route('products.destroy', id), { onSuccess: () => {
onSuccess: () => { // Toast handled by flash message
// Toast handled by flash message usually, or add here if needed }
} });
});
}
}; };
return ( return (
@@ -226,6 +230,13 @@ export default function ProductManagement({ products, categories, filters }: Pag
> >
</Button> </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"> <Button onClick={handleAddProduct} className="flex-1 md:flex-none button-filled-primary">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@@ -270,6 +281,7 @@ export default function ProductManagement({ products, categories, filters }: Pag
onOpenChange={setIsDialogOpen} onOpenChange={setIsDialogOpen}
product={editingProduct} product={editingProduct}
categories={categories} categories={categories}
units={units}
/> />
<CategoryManagerDialog <CategoryManagerDialog
@@ -277,6 +289,12 @@ export default function ProductManagement({ products, categories, filters }: Pag
onOpenChange={setIsCategoryDialogOpen} onOpenChange={setIsCategoryDialogOpen}
categories={categories} categories={categories}
/> />
<UnitManagerDialog
open={isUnitDialogOpen}
onOpenChange={setIsUnitDialogOpen}
units={units}
/>
</div> </div>
</AuthenticatedLayout> </AuthenticatedLayout>
); );

View File

@@ -13,6 +13,7 @@ use App\Http\Controllers\WarehouseController;
use App\Http\Controllers\InventoryController; use App\Http\Controllers\InventoryController;
use App\Http\Controllers\SafetyStockController; use App\Http\Controllers\SafetyStockController;
use App\Http\Controllers\TransferOrderController; use App\Http\Controllers\TransferOrderController;
use App\Http\Controllers\UnitController;
Route::get('/login', [LoginController::class, 'show'])->name('login'); Route::get('/login', [LoginController::class, 'show'])->name('login');
Route::post('/login', [LoginController::class, 'store']); 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::put('/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy'); 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::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::post('/products', [ProductController::class, 'store'])->name('products.store'); Route::post('/products', [ProductController::class, 'store'])->name('products.store');
Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update'); 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'); Route::get('/vendors', [VendorController::class, 'index'])->name('vendors.index');