diff --git a/app/Http/Controllers/admin/product/ProductCategoryController.php b/app/Http/Controllers/admin/product/ProductCategoryController.php index df3d707..b8f1846 100644 --- a/app/Http/Controllers/admin/product/ProductCategoryController.php +++ b/app/Http/Controllers/admin/product/ProductCategoryController.php @@ -56,7 +56,7 @@ class ProductCategoryController extends Controller public function destroy(int $id) { $this->productCategoryService->delete($id); - return response()->json(['success' => true]); + return redirect()->route('products.group.index')->with('success', 'Delete Group Success'); } public function datatable(Request $request) diff --git a/app/Http/Controllers/admin/product/ProductController.php b/app/Http/Controllers/admin/product/ProductController.php index 3c05ff1..015fd82 100644 --- a/app/Http/Controllers/admin/product/ProductController.php +++ b/app/Http/Controllers/admin/product/ProductController.php @@ -48,8 +48,11 @@ class ProductController extends Controller // Fetch OS versions with their group for the OS selector component $osVersions = \App\Models\Os::with('osGroup')->where('status', 1)->get(); + // Fetch Control Panels for Hosting products + $controlPanels = \App\Models\ControlPanel::active()->ordered()->get(); + if ($step === 1) { - return view("administrator.pages.product.create", compact('group', 'type', 'title', 'osVersions')); + return view("administrator.pages.product.create", compact('group', 'type', 'title', 'osVersions', 'controlPanels')); } if (!$request->has(['step', 'type'])) { @@ -97,7 +100,10 @@ class ProductController extends Controller $view = "administrator.pages.product.edit"; - $data = compact('product', 'title', 'group', 'type', 'osVersions'); + // Fetch Control Panels for Hosting products + $controlPanels = \App\Models\ControlPanel::active()->ordered()->get(); + + $data = compact('product', 'title', 'group', 'type', 'osVersions', 'controlPanels'); return view($view, $data); } diff --git a/app/Http/Requests/Product/ProductRequest.php b/app/Http/Requests/Product/ProductRequest.php index 86b94c3..7692c52 100644 --- a/app/Http/Requests/Product/ProductRequest.php +++ b/app/Http/Requests/Product/ProductRequest.php @@ -49,6 +49,9 @@ class ProductRequest extends FormRequest ], 'short_description' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], + 'thumbnail' => ['nullable', 'string', 'max:255'], + 'gallery' => ['nullable', 'array'], + 'gallery.*' => ['nullable', 'string', 'max:255'], 'stock' => ['nullable', 'integer', 'min:0'], 'is_unlimited' => ['boolean'], 'cpu' => ['nullable', 'string', 'max:255'], @@ -64,11 +67,23 @@ class ProductRequest extends FormRequest 'is_active' => ['boolean'], 'sort_order' => ['nullable', 'integer'], 'published_at' => ['nullable', 'date'], - - // OS versions (pivot table os_product) + + // OS versions (pivot table os_product) - for VPS 'os_ids' => ['nullable', 'array'], 'os_ids.*' => ['exists:os,id'], - + + // Control Panels (pivot table product_control_panel) - for Hosting + 'control_panel_ids' => ['nullable', 'array'], + 'control_panel_ids.*' => ['exists:control_panels,id'], + 'control_panel_price_modifiers' => ['nullable', 'array'], + 'control_panel_price_modifiers.*' => ['nullable', 'numeric', 'min:0'], + 'control_panel_default' => ['nullable', 'exists:control_panels,id'], + + // Images + 'thumbnail' => ['nullable', 'string', 'max:255'], + 'gallery' => ['nullable', 'array'], + 'gallery.*' => ['nullable', 'string', 'max:255'], + // Pricing cycles (table product_pricings) 'pricing' => ['nullable', 'array'], ]; @@ -127,7 +142,14 @@ class ProductRequest extends FormRequest 'short_description.max' => 'Mô tả ngắn không được vượt quá 255 ký tự.', 'description.string' => 'Mô tả phải là chuỗi ký tự.', - + + 'thumbnail.string' => 'Đường dẫn ảnh đại diện phải là chuỗi ký tự.', + 'thumbnail.max' => 'Đường dẫn ảnh đại diện không được vượt quá 255 ký tự.', + + 'gallery.array' => 'Thư viện ảnh không hợp lệ.', + 'gallery.*.string' => 'Đường dẫn ảnh trong thư viện phải là chuỗi ký tự.', + 'gallery.*.max' => 'Đường dẫn ảnh trong thư viện không được vượt quá 255 ký tự.', + 'stock.integer' => 'Số lượng tồn kho phải là số nguyên.', 'stock.min' => 'Số lượng tồn kho không được âm.', @@ -168,7 +190,14 @@ class ProductRequest extends FormRequest 'os_ids.array' => 'Danh sách hệ điều hành không hợp lệ.', 'os_ids.*.exists' => 'Hệ điều hành được chọn không tồn tại.', - + + 'control_panel_ids.array' => 'Danh sách Control Panel không hợp lệ.', + 'control_panel_ids.*.exists' => 'Control Panel được chọn không tồn tại.', + 'control_panel_price_modifiers.array' => 'Danh sách phụ phí Control Panel không hợp lệ.', + 'control_panel_price_modifiers.*.numeric' => 'Phụ phí Control Panel phải là số.', + 'control_panel_price_modifiers.*.min' => 'Phụ phí Control Panel không được âm.', + 'control_panel_default.exists' => 'Control Panel mặc định không tồn tại.', + 'pricing.array' => 'Thông tin giá không hợp lệ.', 'pricing.*.price.numeric' => 'Giá phải là số.', 'pricing.*.price.min' => 'Giá không được âm.', diff --git a/app/Http/Services/ProductService.php b/app/Http/Services/ProductService.php index c3d547f..ffd218a 100644 --- a/app/Http/Services/ProductService.php +++ b/app/Http/Services/ProductService.php @@ -24,11 +24,16 @@ class ProductService // Create product $product = $this->repository->create($productData); - // Sync OS versions if provided + // Sync OS versions if provided (for VPS) if (!empty($data['os_ids'])) { $product->osVersions()->sync($data['os_ids']); } + // Sync Control Panels if provided (for Hosting) + if (!empty($data['control_panel_ids'])) { + $this->saveControlPanels($product, $data); + } + // Save pricing cycles if provided if (!empty($data['pricing'])) { $this->savePricings($product, $data['pricing'], $data['currency'] ?? 'USD'); @@ -48,11 +53,16 @@ class ProductService // Get the product for relationships $product = $this->repository->find($id); - // Sync OS versions if provided + // Sync OS versions if provided (for VPS) if (isset($data['os_ids'])) { $product->osVersions()->sync($data['os_ids']); } + // Sync Control Panels if provided (for Hosting) + if (isset($data['control_panel_ids'])) { + $this->saveControlPanels($product, $data); + } + // Save pricing cycles if provided if (!empty($data['pricing'])) { $this->savePricings($product, $data['pricing'], $data['currency'] ?? 'USD'); @@ -95,6 +105,26 @@ class ProductService } } + /** + * Save Control Panels for product (for Hosting) + */ + private function saveControlPanels($product, array $data): void + { + $syncData = []; + + foreach ($data['control_panel_ids'] as $panelId) { + $priceModifier = $data['control_panel_price_modifiers'][$panelId] ?? 0; + $isDefault = isset($data['control_panel_default']) && $data['control_panel_default'] == $panelId; + + $syncData[$panelId] = [ + 'price_modifier' => $priceModifier, + 'is_default' => $isDefault, + ]; + } + + $product->controlPanels()->sync($syncData); + } + /** * Prepare data for create/update */ diff --git a/app/Models/ControlPanel.php b/app/Models/ControlPanel.php new file mode 100644 index 0000000..a4dc380 --- /dev/null +++ b/app/Models/ControlPanel.php @@ -0,0 +1,50 @@ + 'boolean', + ]; + + /** + * Relationship: Control Panel có nhiều Products (Hosting) + */ + public function products() + { + return $this->belongsToMany(Product::class, 'product_control_panel') + ->withPivot(['price_modifier', 'is_default']) + ->withTimestamps(); + } + + /** + * Scope: Chỉ lấy active panels + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: Sắp xếp theo sort_order + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order', 'asc'); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index 16f5f04..ac11732 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -19,6 +19,8 @@ class Product extends Model 'slug', 'short_description', 'description', + 'thumbnail', + 'gallery', 'setup_fee', 'trial_days', 'cpu', @@ -39,6 +41,7 @@ class Product extends Model 'is_active' => 'boolean', 'is_unlimited' => 'boolean', 'published_at' => 'date', + 'gallery' => 'array', ]; /** @@ -58,6 +61,16 @@ class Product extends Model ->withTimestamps(); } + /** + * Relationship: Product has many Control Panels (for Hosting) + */ + public function controlPanels() + { + return $this->belongsToMany(ControlPanel::class, 'product_control_panel') + ->withPivot(['price_modifier', 'is_default']) + ->withTimestamps(); + } + /** * Relationship: Product has many pricings */ diff --git a/database/migrations/2026_01_27_172907_create_products_table.php b/database/migrations/2026_01_27_172907_create_products_table.php index a77ada3..147d9f9 100644 --- a/database/migrations/2026_01_27_172907_create_products_table.php +++ b/database/migrations/2026_01_27_172907_create_products_table.php @@ -20,6 +20,8 @@ return new class extends Migration { $table->string('slug')->unique(); $table->string('short_description')->nullable(); $table->text('description')->nullable(); + $table->string('thumbnail')->nullable(); + $table->json('gallery')->nullable(); // Trial / stock $table->integer('stock')->nullable(); diff --git a/database/migrations/2026_02_06_100001_create_control_panels_table.php b/database/migrations/2026_02_06_100001_create_control_panels_table.php new file mode 100644 index 0000000..f169ee9 --- /dev/null +++ b/database/migrations/2026_02_06_100001_create_control_panels_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('logo')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('control_panels'); + } +}; diff --git a/database/migrations/2026_02_06_100002_create_product_control_panel_table.php b/database/migrations/2026_02_06_100002_create_product_control_panel_table.php new file mode 100644 index 0000000..46c7197 --- /dev/null +++ b/database/migrations/2026_02_06_100002_create_product_control_panel_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('product_id'); + $table->unsignedBigInteger('control_panel_id'); + $table->decimal('price_modifier', 15, 2)->default(0)->comment('Phụ phí cho control panel này'); + $table->boolean('is_default')->default(false)->comment('Panel mặc định cho sản phẩm'); + $table->timestamps(); + + $table->foreign('product_id') + ->references('id') + ->on('products') + ->onDelete('cascade'); + + $table->foreign('control_panel_id') + ->references('id') + ->on('control_panels') + ->onDelete('cascade'); + + $table->unique(['product_id', 'control_panel_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_control_panel'); + } +}; diff --git a/database/seeders/ControlPanelSeeder.php b/database/seeders/ControlPanelSeeder.php new file mode 100644 index 0000000..1c7ba95 --- /dev/null +++ b/database/seeders/ControlPanelSeeder.php @@ -0,0 +1,82 @@ + 'Plesk Panel', + 'slug' => 'plesk', + 'logo' => '/images/control-panels/plesk.png', + 'description' => 'Plesk là control panel thương mại hàng đầu với giao diện thân thiện, hỗ trợ cả Windows và Linux.', + 'is_active' => true, + 'sort_order' => 1, + ], + [ + 'name' => 'DirectAdmin', + 'slug' => 'directadmin', + 'logo' => '/images/control-panels/directadmin.png', + 'description' => 'DirectAdmin là control panel nhẹ, nhanh và ổn định, phù hợp cho các server có cấu hình thấp.', + 'is_active' => true, + 'sort_order' => 2, + ], + [ + 'name' => 'aaPanel', + 'slug' => 'aapanel', + 'logo' => '/images/control-panels/aapanel.png', + 'description' => 'aaPanel là control panel miễn phí với giao diện đơn giản, dễ sử dụng, hỗ trợ LNMP/LAMP.', + 'is_active' => true, + 'sort_order' => 3, + ], + [ + 'name' => 'cPanel', + 'slug' => 'cpanel', + 'logo' => '/images/control-panels/cpanel.png', + 'description' => 'cPanel là control panel phổ biến nhất thế giới với đầy đủ tính năng quản lý hosting.', + 'is_active' => true, + 'sort_order' => 4, + ], + [ + 'name' => 'CyberPanel', + 'slug' => 'cyberpanel', + 'logo' => '/images/control-panels/cyberpanel.png', + 'description' => 'CyberPanel là control panel miễn phí sử dụng OpenLiteSpeed, tối ưu hiệu suất website.', + 'is_active' => true, + 'sort_order' => 5, + ], + [ + 'name' => 'HestiaCP', + 'slug' => 'hestiacp', + 'logo' => '/images/control-panels/hestiacp.png', + 'description' => 'HestiaCP là control panel mã nguồn mở, nhẹ và miễn phí, fork từ VestaCP.', + 'is_active' => true, + 'sort_order' => 6, + ], + [ + 'name' => 'Không có Panel', + 'slug' => 'no-panel', + 'logo' => null, + 'description' => 'Không cài đặt control panel, quản lý server thủ công qua SSH.', + 'is_active' => true, + 'sort_order' => 99, + ], + ]; + + foreach ($panels as $panel) { + ControlPanel::updateOrCreate( + ['slug' => $panel['slug']], + $panel + ); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index eca5c49..87b7869 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -30,5 +30,6 @@ class DatabaseSeeder extends Seeder $this->call(TemplateSeeder::class); $this->call(Setting::class); $this->call(MenuSeeder::class); + $this->call(ControlPanelSeeder::class); } } diff --git a/resources/views/administrator/pages/package/version/datatable/modal.blade.php b/resources/views/administrator/pages/package/version/datatable/modal.blade.php index 49355e3..532e016 100644 --- a/resources/views/administrator/pages/package/version/datatable/modal.blade.php +++ b/resources/views/administrator/pages/package/version/datatable/modal.blade.php @@ -21,11 +21,13 @@ @@ -34,10 +36,10 @@ @push('custom-scripts') -@endpush \ No newline at end of file +@endpush diff --git a/resources/views/administrator/pages/product/categories/datatable/action.blade.php b/resources/views/administrator/pages/product/categories/datatable/action.blade.php index f7d4e22..270a9ad 100644 --- a/resources/views/administrator/pages/product/categories/datatable/action.blade.php +++ b/resources/views/administrator/pages/product/categories/datatable/action.blade.php @@ -1,2 +1,13 @@ -Edit -Delete \ No newline at end of file +
+ Edit + Delete +
+ +@include('administrator.pages.product.categories.datatable.modal', [ + 'deleteRoute' => 'products.group.destroy', + 'itemLabel' => 'Product Group', + 'itemId' => $row->id, + 'itemName' => $row->name, + 'modalId' => 'deleteModal-' . $row->id +]) + diff --git a/resources/views/administrator/pages/product/categories/datatable/modal.blade.php b/resources/views/administrator/pages/product/categories/datatable/modal.blade.php new file mode 100644 index 0000000..e9b75bb --- /dev/null +++ b/resources/views/administrator/pages/product/categories/datatable/modal.blade.php @@ -0,0 +1,34 @@ + + + diff --git a/resources/views/administrator/pages/product/categories/index.blade.php b/resources/views/administrator/pages/product/categories/index.blade.php index d66070d..cfabfc0 100644 --- a/resources/views/administrator/pages/product/categories/index.blade.php +++ b/resources/views/administrator/pages/product/categories/index.blade.php @@ -59,4 +59,40 @@ @foreach (config('assets.datatable.js') as $datatable) @endforeach -@endpush \ No newline at end of file +@endpush +@push('custom-scripts') + +@endpush diff --git a/resources/views/administrator/pages/product/create.blade.php b/resources/views/administrator/pages/product/create.blade.php index 8747fef..582f7fd 100644 --- a/resources/views/administrator/pages/product/create.blade.php +++ b/resources/views/administrator/pages/product/create.blade.php @@ -136,8 +136,7 @@
$ + name="pricing[{{ $cycle->value }}][price_sale]" placeholder="0.00">
@@ -221,20 +220,6 @@ @endforeach - - {{-- Operating System --}} -
- - - Select multiple OS versions supported by this product -
- {{-- Product Type --}}
@@ -248,6 +233,59 @@ @endforeach
+ {{-- Operating System (for VPS) --}} +
+ + + Select multiple OS versions supported by this product +
+ + {{-- Control Panels (for Hosting) --}} +
+ + + Select control panels available for this hosting product + + {{-- Price modifiers for each panel --}} +
+ @foreach ($controlPanels as $panel) + + @endforeach +
+
{{-- Status --}}
@@ -316,6 +354,51 @@ if ($(".select2").length) { $(".select2").select2(); } + + // Toggle OS/Control Panel sections based on Product Type + function toggleProductSections() { + const productType = $('#product_type').val(); + + if (productType === 'hosting') { + $('#os-section').hide(); + $('#control-panel-section').show(); + } else if (productType === 'vps') { + $('#os-section').show(); + $('#control-panel-section').hide(); + } else { + // For addon, domain, or empty + $('#os-section').hide(); + $('#control-panel-section').hide(); + } + } + + // Show/hide price modifier inputs when panels are selected + $('#control_panel_ids').on('change', function() { + const selectedPanels = $(this).val() || []; + + $('.panel-price-row').hide(); + selectedPanels.forEach(function(panelId) { + $('#panel-price-' + panelId).show(); + }); + + // Clear unselected panels + $('.panel-price-row').each(function() { + const panelId = $(this).attr('id').replace('panel-price-', ''); + if (!selectedPanels.includes(panelId)) { + $(this).find('input[type="number"]').val(0); + $(this).find('input[type="radio"]').prop('checked', false); + } + }); + }); + + // Initial toggle on page load + toggleProductSections(); + + // Toggle when product type changes + $('#product_type').on('change', toggleProductSections); + + // Trigger initial panel price display + $('#control_panel_ids').trigger('change'); }); diff --git a/resources/views/administrator/pages/product/edit.blade.php b/resources/views/administrator/pages/product/edit.blade.php index 94743e3..f8bf352 100644 --- a/resources/views/administrator/pages/product/edit.blade.php +++ b/resources/views/administrator/pages/product/edit.blade.php @@ -220,8 +220,8 @@
- {{-- Operating System --}} -
+ {{-- Operating System (for VPS) --}} +
@php $selectedOsIds = old('os_ids', $product->osVersions->pluck('id')->toArray()); @@ -237,6 +237,54 @@ Select multiple OS versions supported by this product
+ + {{-- Control Panels (for Hosting) --}} + {{-- Product Type --}}
@@ -320,6 +368,51 @@ if ($(".select2").length) { $(".select2").select2(); } + + // Toggle OS/Control Panel sections based on Product Type + function toggleProductSections() { + const productType = $('#product_type').val(); + + if (productType === 'hosting') { + $('#os-section').hide(); + $('#control-panel-section').show(); + } else if (productType === 'vps') { + $('#os-section').show(); + $('#control-panel-section').hide(); + } else { + // For addon, domain, or empty + $('#os-section').hide(); + $('#control-panel-section').hide(); + } + } + + // Show/hide price modifier inputs when panels are selected + $('#control_panel_ids').on('change', function() { + const selectedPanels = $(this).val() || []; + + $('.panel-price-row').hide(); + selectedPanels.forEach(function(panelId) { + $('#panel-price-' + panelId).show(); + }); + + // Clear unselected panels + $('.panel-price-row').each(function() { + const panelId = $(this).attr('id').replace('panel-price-', ''); + if (!selectedPanels.includes(panelId)) { + $(this).find('input[type="number"]').val(0); + $(this).find('input[type="radio"]').prop('checked', false); + } + }); + }); + + // Initial toggle on page load + toggleProductSections(); + + // Toggle when product type changes + $('#product_type').on('change', toggleProductSections); + + // Trigger initial panel price display + $('#control_panel_ids').trigger('change'); });