From 8ec5473ec73131238ac7166d32727eada2a74233 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 26 Mar 2026 17:32:15 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E5=95=86=E5=93=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=A8=A1=E7=B5=84=E9=87=8D=E6=A7=8B=E3=80=81UI=20=E6=B8=85?= =?UTF-8?q?=E6=99=B0=E5=BA=A6=E5=84=AA=E5=8C=96=E8=88=87=E5=A4=9A=E8=AA=9E?= =?UTF-8?q?=E7=B3=BB=E6=A8=99=E7=B1=A4=E5=AD=97=E9=AB=94=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Admin/ProductController.php | 236 ++++++++ app/Models/Product/Product.php | 21 +- app/Models/Product/ProductCategory.php | 9 + app/Models/System/Company.php | 2 + ...fields_to_products_and_companies_table.php | 50 ++ lang/en.json | 32 +- lang/ja.json | 32 +- lang/zh_TW.json | 54 +- .../admin/products/create-modal.blade.php | 201 +++++++ .../views/admin/products/index.blade.php | 520 ++++++++++++++++++ .../layouts/partials/sidebar-menu.blade.php | 2 +- routes/web.php | 2 +- 12 files changed, 1152 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/Admin/ProductController.php create mode 100644 database/migrations/2026_03_26_163012_add_fields_to_products_and_companies_table.php create mode 100644 resources/views/admin/products/create-modal.blade.php create mode 100644 resources/views/admin/products/index.blade.php diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php new file mode 100644 index 0000000..a915a76 --- /dev/null +++ b/app/Http/Controllers/Admin/ProductController.php @@ -0,0 +1,236 @@ +user(); + $query = Product::with(['category', 'translations', 'company']); + + // 租戶隔離由 Global Scope (TenantScoped) 處理,但系統管理員可額外篩選 + if ($user->isSystemAdmin() && $request->filled('company_id')) { + $query->where('company_id', $request->company_id); + } + + // 搜尋 + if ($request->filled('search')) { + $search = $request->search; + $query->where(function($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('barcode', 'like', "%{$search}%") + ->orWhere('spec', 'like', "%{$search}%"); + }); + } + + // 分類篩選 + if ($request->filled('category_id')) { + $query->where('category_id', $request->category_id); + } + + $per_page = $request->input('per_page', 10); + $products = $query->latest()->paginate($per_page)->withQueryString(); + $categories = ProductCategory::all(); + $companies = $user->isSystemAdmin() ? Company::all() : collect(); + + $companySettings = $user->company ? ($user->company->settings ?? []) : []; + $routeName = 'admin.data-config.products.index'; + + return view('admin.products.index', [ + 'products' => $products, + 'categories' => $categories, + 'companies' => $companies, + 'companySettings' => $companySettings, + 'routeName' => $routeName + ]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'names.zh_TW' => 'required|string|max:255', + 'names.en' => 'nullable|string|max:255', + 'names.ja' => 'nullable|string|max:255', + 'barcode' => 'nullable|string|max:100', + 'spec' => 'nullable|string|max:255', + 'category_id' => 'nullable|exists:product_categories,id', + 'manufacturer' => 'nullable|string|max:255', + 'track_limit' => 'required|integer|min:1', + 'spring_limit' => 'required|integer|min:1', + 'price' => 'required|numeric|min:0', + 'cost' => 'required|numeric|min:0', + 'member_price' => 'required|numeric|min:0', + 'metadata' => 'nullable|array', + 'is_active' => 'nullable|boolean', + ]); + + try { + DB::beginTransaction(); + + $dictKey = (string) Str::uuid(); + $company_id = auth()->user()->company_id; + + // Store translations + foreach ($request->names as $locale => $name) { + if (empty($name)) continue; + Translation::create([ + 'group' => 'product', + 'key' => $dictKey, + 'locale' => $locale, + 'text' => $name, + 'company_id' => $company_id, + ]); + } + + $product = Product::create([ + 'company_id' => $company_id, + 'category_id' => $request->category_id, + 'name' => $request->names['zh_TW'], // Default name uses zh_TW + 'name_dictionary_key' => $dictKey, + 'barcode' => $request->barcode, + 'spec' => $request->spec, + 'manufacturer' => $request->manufacturer, + 'track_limit' => $request->track_limit, + 'spring_limit' => $request->spring_limit, + 'price' => $request->price, + 'cost' => $request->cost, + 'member_price' => $request->member_price, + 'metadata' => $request->metadata ?? [], + 'is_active' => $request->boolean('is_active', true), + ]); + + DB::commit(); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => __('Product created successfully'), + 'data' => $product + ]); + } + + return redirect()->back()->with('success', __('Product created successfully')); + + } catch (\Exception $e) { + DB::rollBack(); + if ($request->wantsJson()) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 500); + } + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function update(Request $request, $id) + { + $product = Product::findOrFail($id); + + $validated = $request->validate([ + 'names.zh_TW' => 'required|string|max:255', + 'names.en' => 'nullable|string|max:255', + 'names.ja' => 'nullable|string|max:255', + 'barcode' => 'nullable|string|max:100', + 'spec' => 'nullable|string|max:255', + 'category_id' => 'nullable|exists:product_categories,id', + 'manufacturer' => 'nullable|string|max:255', + 'track_limit' => 'required|integer|min:1', + 'spring_limit' => 'required|integer|min:1', + 'price' => 'required|numeric|min:0', + 'cost' => 'required|numeric|min:0', + 'member_price' => 'required|numeric|min:0', + 'metadata' => 'nullable|array', + 'is_active' => 'nullable|boolean', + ]); + + try { + DB::beginTransaction(); + + $dictKey = $product->name_dictionary_key; + $company_id = auth()->user()->company_id; + + // Update or Create translations + foreach ($request->names as $locale => $name) { + if (empty($name)) { + Translation::where([ + 'group' => 'product', + 'key' => $dictKey, + 'locale' => $locale + ])->delete(); + continue; + } + + Translation::updateOrCreate( + [ + 'group' => 'product', + 'key' => $dictKey, + 'locale' => $locale, + ], + [ + 'text' => $name, + 'company_id' => $company_id, + ] + ); + } + + $product->update([ + 'category_id' => $request->category_id, + 'name' => $request->names['zh_TW'], + 'barcode' => $request->barcode, + 'spec' => $request->spec, + 'manufacturer' => $request->manufacturer, + 'track_limit' => $request->track_limit, + 'spring_limit' => $request->spring_limit, + 'price' => $request->price, + 'cost' => $request->cost, + 'member_price' => $request->member_price, + 'metadata' => $request->metadata ?? [], + 'is_active' => $request->boolean('is_active', true), + ]); + + DB::commit(); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => __('Product updated successfully'), + 'data' => $product + ]); + } + + return redirect()->back()->with('success', __('Product updated successfully')); + + } catch (\Exception $e) { + DB::rollBack(); + if ($request->wantsJson()) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 500); + } + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function destroy($id) + { + try { + $product = Product::findOrFail($id); + + // Delete translations associated with this product + if ($product->name_dictionary_key) { + Translation::where('key', $product->name_dictionary_key)->delete(); + } + + $product->delete(); + + return redirect()->back()->with('success', __('Product deleted successfully')); + } catch (\Exception $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } +} diff --git a/app/Models/Product/Product.php b/app/Models/Product/Product.php index 2b33bd8..a34a2de 100644 --- a/app/Models/Product/Product.php +++ b/app/Models/Product/Product.php @@ -15,21 +15,31 @@ class Product extends Model 'company_id', 'category_id', 'name', + 'name_dictionary_key', 'sku', 'barcode', + 'spec', + 'manufacturer', 'description', 'price', + 'member_price', 'cost', + 'track_limit', + 'spring_limit', 'type', 'image_url', 'status', - 'name_dictionary_key', + 'is_active', 'metadata', ]; protected $casts = [ 'price' => 'decimal:2', + 'member_price' => 'decimal:2', 'cost' => 'decimal:2', + 'track_limit' => 'integer', + 'spring_limit' => 'integer', + 'is_active' => 'boolean', 'metadata' => 'array', ]; @@ -37,4 +47,13 @@ class Product extends Model { return $this->belongsTo(ProductCategory::class, 'category_id'); } + + /** + * Get the translations for the product name. + */ + public function translations() + { + return $this->hasMany(\App\Models\System\Translation::class, 'key', 'name_dictionary_key') + ->where('group', 'product'); + } } diff --git a/app/Models/Product/ProductCategory.php b/app/Models/Product/ProductCategory.php index 0141415..2e46a2e 100644 --- a/app/Models/Product/ProductCategory.php +++ b/app/Models/Product/ProductCategory.php @@ -21,4 +21,13 @@ class ProductCategory extends Model { return $this->hasMany(Product::class, 'category_id'); } + + /** + * Get the translations for the category name. + */ + public function translations() + { + return $this->hasMany(\App\Models\System\Translation::class, 'key', 'name_dictionary_key') + ->where('group', 'category'); + } } diff --git a/app/Models/System/Company.php b/app/Models/System/Company.php index ba50c2b..d6f82cb 100644 --- a/app/Models/System/Company.php +++ b/app/Models/System/Company.php @@ -23,11 +23,13 @@ class Company extends Model 'status', 'valid_until', 'note', + 'settings', ]; protected $casts = [ 'valid_until' => 'date', 'status' => 'integer', + 'settings' => 'array', ]; /** diff --git a/database/migrations/2026_03_26_163012_add_fields_to_products_and_companies_table.php b/database/migrations/2026_03_26_163012_add_fields_to_products_and_companies_table.php new file mode 100644 index 0000000..311edba --- /dev/null +++ b/database/migrations/2026_03_26_163012_add_fields_to_products_and_companies_table.php @@ -0,0 +1,50 @@ +string('spec')->nullable()->after('name')->comment('規格'); + $table->string('manufacturer')->nullable()->after('barcode')->comment('生產公司'); + $table->integer('track_limit')->default(0)->after('manufacturer')->comment('履帶貨道上限'); + $table->integer('spring_limit')->default(0)->after('track_limit')->comment('彈簧貨道上限'); + $table->decimal('member_price', 10, 2)->default(0)->after('price')->comment('會員價'); + $table->json('metadata')->nullable()->after('is_active')->comment('進階 Metadata (點數、物料代碼等)'); + }); + + // 擴充 companies 表 + Schema::table('companies', function (Blueprint $table) { + $table->json('settings')->nullable()->after('note')->comment('客戶功能設定 (Feature Toggles)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn([ + 'spec', + 'manufacturer', + 'track_limit', + 'spring_limit', + 'member_price', + 'metadata' + ]); + }); + + Schema::table('companies', function (Blueprint $table) { + $table->dropColumn('settings'); + }); + } +}; diff --git a/lang/en.json b/lang/en.json index 98e1de2..24b6b35 100644 --- a/lang/en.json +++ b/lang/en.json @@ -708,5 +708,35 @@ "Yes, regenerate": "Yes, regenerate", "Regenerating the token will disconnect the physical machine until it is updated. Continue?": "Regenerating the token will disconnect the physical machine until it is updated. Continue?", "Error processing request": "Error processing request", - "API Token regenerated successfully.": "API Token regenerated successfully." + "API Token regenerated successfully.": "API Token regenerated successfully.", + "Product Name (Multilingual)": "Product Name (Multilingual)", + "Material Code": "Material Code", + "Full Points": "Full Points", + "Half Points": "Half Points", + "Half Points Amount": "Half Points Amount", + "Name in Traditional Chinese": "Name in Traditional Chinese", + "Name in English": "Name in English", + "Name in Japanese": "Name in Japanese", + "Track Limit": "Track Limit", + "Spring Limit": "Spring Limit", + "Inventory Limits Configuration": "Inventory Limits Configuration", + "Manage your catalog, prices, and multilingual details.": "Manage your catalog, prices, and multilingual details.", + "Product created successfully": "Product created successfully", + "Product updated successfully": "Product updated successfully", + "Product deleted successfully": "Product deleted successfully", + "Fill in the product details below": "Fill in the product details below", + "Uncategorized": "Uncategorized", + "Track Channel Limit": "Track Channel Limit", + "Spring Channel Limit": "Spring Channel Limit", + "Product Details": "Product Details", + "Production Company": "Production Company", + "Barcode": "Barcode", + "Specifications": "Specifications", + "Cost": "Cost", + "Member Price": "Member Price", + "Sale Price": "Sale Price", + "Update Product": "Update Product", + "Edit Product": "Edit Product", + "Create Product": "Create Product", + "Are you sure you want to delete this product?": "Are you sure you want to delete this product?" } \ No newline at end of file diff --git a/lang/ja.json b/lang/ja.json index 116a409..f9fc476 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -709,5 +709,35 @@ "Yes, regenerate": "はい、再生成します", "Regenerating the token will disconnect the physical machine until it is updated. Continue?": "トークンを再生成すると、更新されるまで物理マシンの接続が切断されます。続行しますか?", "Error processing request": "リクエストの処理中にエラーが発生しました", - "API Token regenerated successfully.": "APIトークンが正常に再生成されました。" + "API Token regenerated successfully.": "APIトークンが正常に再生成されました。", + "Product Name (Multilingual)": "商品名 (多言語)", + "Material Code": "物料コード", + "Full Points": "フルポイント", + "Half Points": "ハーフポイント", + "Half Points Amount": "ハーフポイント金額", + "Name in Traditional Chinese": "繁体字中国語名", + "Name in English": "英語名", + "Name in Japanese": "日本語名", + "Track Limit": "ベルトコンベア上限", + "Spring Limit": "スプリング上限", + "Inventory Limits Configuration": "在庫上限設定", + "Manage your catalog, prices, and multilingual details.": "カタログ、価格、多言語詳細を管理します。", + "Product created successfully": "商品が正常に作成されました", + "Product updated successfully": "商品が正常に更新されました", + "Product deleted successfully": "商品が正常に削除されました", + "Fill in the product details below": "以下に商品の詳細を入力してください", + "Uncategorized": "未分類", + "Track Channel Limit": "ベルトコンベア上限", + "Spring Channel Limit": "スプリング上限", + "Product Details": "商品詳細", + "Production Company": "製造会社", + "Barcode": "バーコード", + "Specifications": "規格", + "Cost": "原価", + "Member Price": "会員価格", + "Sale Price": "販売価格", + "Update Product": "商品を更新", + "Edit Product": "商品を編集", + "Create Product": "商品を作成", + "Are you sure you want to delete this product?": "この商品を削除してもよろしいですか?" } \ No newline at end of file diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 82f436a..d8e75d4 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -41,8 +41,8 @@ "Alerts Pending": "待處理告警", "All": "全部", "All Affiliations": "全部單位", - "All Categories": "所有類別", - "All Companies": "全部單位", + "All Categories": "所有分類", + "All Companies": "所有公司", "All Levels": "所有層級", "All Machines": "所有機台", "All Times System Timezone": "所有時間為系統時區", @@ -97,7 +97,7 @@ "Click here to re-send the verification email.": "點擊此處重新發送驗證郵件。", "Click to upload": "點擊上傳", "Close Panel": "關閉控制面板", - "Company": "所屬單位", + "Company": "所屬公司", "Company Code": "公司代碼", "Company Information": "公司資訊", "Company Level": "客戶層級", @@ -390,6 +390,7 @@ "PARTNER_KEY": "PARTNER_KEY", "PI_MERCHANT_ID": "Pi 拍錢包 商店代號", "PS_MERCHANT_ID": "全盈+Pay 商店代號", + "PS_LEVEL": "PS_LEVEL", "Parameters": "參數設定", "Pass Code": "通行碼", "Pass Codes": "通行碼", @@ -707,5 +708,50 @@ "Yes, regenerate": "確認重新產生", "Regenerating the token will disconnect the physical machine until it is updated. Continue?": "重新產生金鑰將導致實體機台暫時失去連線,必須於機台端更新此新金鑰才能恢復。確定繼續嗎?", "Error processing request": "處理請求時發生錯誤", - "API Token regenerated successfully.": "API 金鑰重新產生成功。" + "API Token regenerated successfully.": "API 金鑰重新產生成功。", + "Product Name (Multilingual)": "商品名稱 (多語系)", + "Material Code": "物料代碼", + "Full Points": "全點數", + "Half Points": "半點數", + "Half Points Amount": "半點數金額", + "Name in Traditional Chinese": "繁體中文名稱", + "Name in English": "英文名稱", + "Name in Japanese": "日文名稱", + "Track Limit": "履帶貨道上限", + "Spring Limit": "彈簧貨道上限", + "Inventory Limits Configuration": "庫存上限配置", + "Manage your catalog, prices, and multilingual details.": "管理您的商品型錄、價格及多語系詳情。", + "Product created successfully": "商品已成功建立", + "Product updated successfully": "商品已成功更新", + "Product deleted successfully": "商品已成功刪除", + "Fill in the product details below": "請在下方填寫商品的詳細資訊", + "Uncategorized": "未分類", + "Track Channel Limit": "履帶貨道上限", + "Spring Channel Limit": "彈簧貨道上限", + "Product Details": "商品詳情", + "Production Company": "生產公司", + "Barcode": "條碼", + "Specifications": "規格", + "Cost": "成本", + "Member Price": "會員價", + "Retail Price": "零售價", + "Sale Price": "售價", + "Update Product": "更新商品", + "Edit Product": "編輯商品", + "Create Product": "建立商品", + "Are you sure you want to delete this product?": "您確定要刪除此商品嗎?", + "Limits (Track/Spring)": "庫存上限 (履帶/彈簧)", + "Member": "會員價", + "Search products...": "搜尋商品...", + "Product Info": "商品資訊", + "Price / Member": "售價 / 會員價", + "Add Product": "新增商品", + "Delete Product": "刪除商品", + "Specification": "規格", + "e.g. 500ml / 300g": "例如:500ml / 300g", + "Active Status": "啟用狀態", + "Traditional Chinese": "繁體中文", + "English": "英文", + "Japanese": "日文", + "Select Category": "選擇類別" } \ No newline at end of file diff --git a/resources/views/admin/products/create-modal.blade.php b/resources/views/admin/products/create-modal.blade.php new file mode 100644 index 0000000..94e0c23 --- /dev/null +++ b/resources/views/admin/products/create-modal.blade.php @@ -0,0 +1,201 @@ +
+ +
+

+ {{ __('Create New Product') }} +

+

+ {{ __('Add a new item to your product collection') }} +

+
+ + +
+
1
+
+
2
+
+ +
+ +
+
+ +
+ +
+
+ ZH + +
+
+ EN + +
+
+ JA + +
+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+ + + +
+ + + + +
+ +
+ + +
+
+
+
+
diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php new file mode 100644 index 0000000..33c655f --- /dev/null +++ b/resources/views/admin/products/index.blade.php @@ -0,0 +1,520 @@ +@extends('layouts.admin') + +@php +$routeName = request()->route()->getName(); +$baseRoute = 'admin.data-config.products'; + +$roleSelectConfig = [ + "placeholder" => __('Select Category'), + "hasSearch" => true, + "searchPlaceholder" => __('Search Category...'), + "toggleClasses" => "hs-select-toggle luxury-select-toggle", + "dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl mt-2 z-[100]", + "optionClasses" => "hs-select-option py-2.5 px-3 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-between", +]; +@endphp + +@section('content') +
+ + +
+
+

{{ __('Product Management') }}

+

+ {{ __('Manage your catalog, prices, and multilingual details.') }} +

+
+
+ +
+
+ + + +
+ +
+
+ + + + + + +
+ + @if(auth()->user()->isSystemAdmin()) +
+ +
+ @endif +
+ + +
+ + + + + @if(auth()->user()->isSystemAdmin()) + + @endif + + + + + + + + @forelse($products as $product) + + + @if(auth()->user()->isSystemAdmin()) + + @endif + + + + + + @empty + + + + @endforelse + +
{{ __('Product Info') }}{{ __('Company') }}{{ __('Price / Member') }}{{ __('Limits (Track/Spring)') }}{{ __('Status') }}{{ __('Actions') }}
+
+
+ @if($product->image_url) + + @else + + @endif +
+
+ {{ $product->name }} +
+ @php + $catName = $product->category->name ?? __('Uncategorized'); + if ($product->category && $product->category->name_dictionary_key) { + $translatedCat = __($product->category->name_dictionary_key); + if ($translatedCat !== $product->category->name_dictionary_key) { + $catName = $translatedCat; + } + } + @endphp + {{ $catName }} + {{ $product->barcode }} +
+
+
+
+ {{ $product->company->name ?? '-' }} + + ${{ number_format($product->price, 0) }} + / + ${{ number_format($product->member_price, 0) }} + + {{ $product->track_limit }} + / + {{ $product->spring_limit }} + + @if($product->is_active) + {{ __('Active') }} + @else + {{ __('Disabled') }} + @endif + +
+ + +
+
{{ __('No products found matching your criteria.') }}
+
+ + +
+ {{ $products->links('vendor.pagination.luxury') }} +
+
+ + +
+
+
+ +
+ +
+
+

+ +

+
+ +
+ +
+ @csrf + + + +
+
+ +
+
+
+ {{ __('Traditional Chinese') }} +
+ +
+
+
+ {{ __('English') }} +
+ +
+
+
+ {{ __('Japanese') }} +
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + + + @foreach($categories as $category) + @php + $catName = $category->name; + if ($category->name_dictionary_key) { + $catName = __($category->name_dictionary_key); + if ($catName === $category->name_dictionary_key) { + $catName = $category->name; + } + } + @endphp + + @endforeach + +
+
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+ + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+
+ + + + +
+ + +
+
+
+
+
+
+ + + + + +
+@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/layouts/partials/sidebar-menu.blade.php b/resources/views/layouts/partials/sidebar-menu.blade.php index 0e61acc..b38df14 100644 --- a/resources/views/layouts/partials/sidebar-menu.blade.php +++ b/resources/views/layouts/partials/sidebar-menu.blade.php @@ -200,7 +200,7 @@