新增單位管理以及一些功能修正
This commit is contained in:
@@ -71,4 +71,7 @@ Routes: kebab-case (小寫橫線分隔)
|
|||||||
|
|
||||||
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||||
|
|
||||||
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||||
|
|
||||||
|
7.運行機制
|
||||||
|
因為是運行在docker上 所以要執行php的話 要執行docker exce
|
||||||
@@ -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);
|
||||||
|
|||||||
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' => [
|
'auth' => [
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
|
'flash' => [
|
||||||
|
'success' => $request->session()->get('success'),
|
||||||
|
'error' => $request->session()->get('error'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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"), {
|
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("刪除失敗,請確認該分類下無商品");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
|
|||||||
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} />;
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user