diff --git a/app/Http/Controllers/frontend/CartController.php b/app/Http/Controllers/frontend/CartController.php index a5aa1d8..5a7747a 100644 --- a/app/Http/Controllers/frontend/CartController.php +++ b/app/Http/Controllers/frontend/CartController.php @@ -14,7 +14,10 @@ use App\Models\InvoiceItem; use App\Models\ModuleHosting; use App\Models\ModuleVmware; use App\Models\Order; +use App\Models\OrderItem; use App\Models\Os; +use App\Models\Product; +use App\Models\ProductPricings; use App\Models\VPS; use Exception; use Illuminate\Http\Request; @@ -36,36 +39,85 @@ class CartController extends Controller public function addCart(Request $request) { $userId = customer()?->id; - $months = $request->input('months'); - $productId = $request->product_id; - $typeId = $request->type_id; if (!$userId) { return back()->with('error', 'Bạn cần đăng nhập để thêm sản phẩm vào giỏ hàng.'); } - // Validate input data - if (!$productId || !$typeId || !$months) { - return back()->with('error', 'Dữ liệu không hợp lệ.'); + // Get request data + $productId = $request->product_id; + $typeId = $request->type_id; // Can be null for new Product system + $months = $request->input('months'); + $billingCycle = $request->input('billing_cycle'); // New: billing cycle + $productPrice = $request->input('product_price'); // Price from form + + // Validate basic input + if (!$productId) { + return back()->with('error', 'Sản phẩm không hợp lệ.'); } - // Use updateOrCreate instead of delete + create for better performance try { - CartModel::updateOrCreate( - ['customer_id' => $userId], // Only one cart item per user - [ - 'product_id' => $productId, - 'type_product' => $typeId, - 'months' => $months, - ] - ); + // Check if this is new Product system or legacy (Hosting/VPS) + $product = Product::find($productId); - return redirect()->route('cart.index')->with('success', 'Sản phẩm đã được thêm vào giỏ hàng!'); + if ($product) { + // NEW FLOW: Using Product table + $pricingId = null; + $finalPrice = $productPrice; + + // Find the pricing based on billing_cycle + if ($billingCycle) { + $pricing = ProductPricings::where('product_id', $productId) + ->where('billing_cycle', $billingCycle) + ->where('is_active', 1) + ->first(); + + if ($pricing) { + $pricingId = $pricing->id; + $finalPrice = $pricing->price_sale ?: $pricing->price; + } + } + + // Add to cart + CartModel::updateOrCreate( + ['customer_id' => $userId], + [ + 'product_id' => $productId, + 'type_product' => null, // New system doesn't use type_product + 'months' => $months, + 'billing_cycle' => $billingCycle, + 'product_pricing_id' => $pricingId, + 'price' => $finalPrice, + 'quantity' => 1, + ] + ); + + return redirect()->route('cart.index')->with('success', 'Sản phẩm đã được thêm vào giỏ hàng!'); + } else { + // LEGACY FLOW: Using old Hosting/VPS system + if (!$typeId || !$months) { + return back()->with('error', 'Dữ liệu không hợp lệ.'); + } + + CartModel::updateOrCreate( + ['customer_id' => $userId], + [ + 'product_id' => $productId, + 'type_product' => $typeId, + 'months' => $months, + 'price' => $productPrice, + ] + ); + + return redirect()->route('cart.index')->with('success', 'Sản phẩm đã được thêm vào giỏ hàng!'); + } } catch (\Exception $e) { Log::error('Cart add error: ' . $e->getMessage(), [ 'user_id' => $userId, 'product_id' => $productId, - 'type_id' => $typeId + 'type_id' => $typeId, + 'billing_cycle' => $billingCycle ?? null, + 'trace' => $e->getTraceAsString() ]); return back()->with('error', 'Có lỗi xảy ra khi thêm sản phẩm vào giỏ hàng.'); } diff --git a/app/Http/Controllers/frontend/ShopController.php b/app/Http/Controllers/frontend/ShopController.php index b19ba19..1e62ccc 100644 --- a/app/Http/Controllers/frontend/ShopController.php +++ b/app/Http/Controllers/frontend/ShopController.php @@ -11,6 +11,7 @@ use App\Models\Hosting; use App\Models\Group; use App\Models\Type; use App\Models\VPS; +use App\Models\ProductCategories; use App\Repositories\Addon\AddonRepositoryInterface; use App\Repositories\Domain\DomainRepositoryInterface; use App\Repositories\Group\GroupRepositoryInterface; @@ -18,7 +19,6 @@ use App\Repositories\Hosting\HostingRepositoryInterface; use App\Repositories\Type\TypeRepositoryInterface; use App\Repositories\VPS\VPSRepositoryInterface; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; class ShopController extends Controller { @@ -34,24 +34,22 @@ class ShopController extends Controller } /** - * Display the shop index page with optimized queries + * Display the shop index page */ public function index(Request $request) { $title = "Dịch vụ của chúng tôi"; - - // Cache types with groups for better performance - $type = Cache::remember('shop_types_with_groups', 3600, function () { - return Type::with(['group' => function ($query) { - $query->where('status', 1); // Only active groups - }]) + + // Get types with groups + $type = Type::with(['group' => function ($query) { + $query->where('status', 1); // Only active groups + }]) ->whereNotIn('id', [1, 2]) ->where('status', 1) // Only active types ->latest('id') ->get(); - }); - // Handle product filtering with optimized queries + // Handle product filtering if ($request->type && $request->group) { $product = $this->getProductsByTypeAndGroup($request->type, $request->group); $group = $this->getGroupsByType($request->type); @@ -84,76 +82,67 @@ class ShopController extends Controller } /** - * Display products by group slug with pagination and optimization + * Display products by category slug (ProductCategories) */ public function show(Request $request, string $slug) { - // Cache group data for better performance - $page = (int) $request->get('page', 1); - $cacheKey = "group_products_{$slug}_page_{$page}"; - - $data = Cache::remember($cacheKey, 1800, function () use ($slug, $page) { - // Find group with optimized query - $group = Group::with(['type']) - ->where('slug', $slug) - ->where('status', 1) // Only active groups - ->first(); + // Find category by slug + $category = ProductCategories::with(['parent', 'children']) + ->where('slug', $slug) + ->where('is_active', 1) + ->first(); - if (!$group) { - return null; - } - - // Get products with pagination based on group type - $products = $this->getPaginatedProducts($group, $page); - - return [ - 'group' => $group, - 'products' => $products, - ]; - }); - - if (!$data) { - abort(404, 'Nhóm sản phẩm không tồn tại'); + if (!$category) { + abort(404, 'Danh mục sản phẩm không tồn tại'); } + // Get products with pagination and pricing + $products = $category->products() + ->with([ + 'pricings' => function ($query) { + $query->where('is_active', 1)->orderBy('price'); + }, + 'osVersions.osGroup', + 'controlPanels' + ]) + ->where('is_active', 1) + ->orderBy('created_at', 'desc') + ->paginate(12); + return view('frontend.service.index', [ - 'group' => $data['group'], - 'products' => $data['products'], - 'breadcrumbs' => $this->crums->add(__('Dashboard'))->add('Sản phẩm thuộc ' . $data['group']->name), + 'category' => $category, + 'products' => $products, + 'breadcrumbs' => $this->crums->add(__('Dashboard'))->add('Sản phẩm thuộc ' . $category->name), ]); } /** - * Get products by type and group with optimization + * Get products by type and group */ private function getProductsByTypeAndGroup(int $typeId, int $groupId) { - return Cache::remember("products_type_{$typeId}_group_{$groupId}", 1800, function () use ($typeId, $groupId) { - return match ($typeId) { - 3 => Hosting::where('group_id', $groupId) - ->where('status', 1) - ->with(['group', 'type']) - ->get(), - 4 => VPS::where('group_id', $groupId) - ->where('status', 1) - ->with(['group', 'type', 'operatingSystems.osGroup']) - ->get(), - default => collect() - }; - }); + return match ($typeId) { + 3 => Hosting::where('group_id', $groupId) + ->where('status', 1) + ->with(['group', 'type']) + ->get(), + 4 => VPS::where('group_id', $groupId) + ->where('status', 1) + ->with(['group', 'type', 'operatingSystems.osGroup']) + ->get(), + default => collect() + }; } /** - * Get groups by type with optimization + * Get groups by type */ private function getGroupsByType(int $typeId) { - return Cache::remember("groups_type_{$typeId}", 3600, function () use ($typeId) { - return Group::with('type') - ->where('type_id', $typeId) - ->where('status', 1) - ->get(); - }); + return Group::with('type') + ->where('type_id', $typeId) + ->where('status', 1) + ->get(); } /** @@ -171,34 +160,4 @@ class ShopController extends Controller default => null }; } - - /** - * Get paginated products based on group type - */ - private function getPaginatedProducts(Group $group, int $page = 1, int $perPage = 12) - { - $query = null; - - // Determine which model to query based on group type - switch ($group->type_id) { - case 3: // Hosting - $query = Hosting::where('group_id', $group->id) - ->where('status', 1) - ->with(['group', 'type']) - ->orderBy('created_at', 'desc'); - break; - - case 4: // VPS - $query = VPS::where('group_id', $group->id) - ->where('status', 1) - ->with(['group', 'type']) - ->orderBy('created_at', 'desc'); - break; - - default: - return collect()->paginate($perPage); - } - - return $query->paginate($perPage, ['*'], 'page', $page); - } } diff --git a/app/Models/Cart.php b/app/Models/Cart.php index 1d87fdb..43194aa 100644 --- a/app/Models/Cart.php +++ b/app/Models/Cart.php @@ -16,14 +16,52 @@ class Cart extends Model 'price', 'quantity', 'months', + 'billing_cycle', // Add new field for billing cycle + 'product_pricing_id', // Add new field to link to ProductPricings ]; + /** + * Get product - support both new and legacy system + */ public function product() { + // New flow: Check if product exists in Product table first + if ($this->product_id) { + $product = Product::find($this->product_id); + if ($product) { + return $product; + } + } + + // Legacy flow: fallback to old system based on type_product return match ($this->type_product) { 3 => $this->belongsTo(Hosting::class, 'product_id'), 4 => $this->belongsTo(VPS::class, 'product_id'), default => null, }; } + + /** + * Relationship: Cart belongs to Product (new system) + */ + public function productNew() + { + return $this->belongsTo(Product::class, 'product_id'); + } + + /** + * Relationship: Cart belongs to Customer + */ + public function customer() + { + return $this->belongsTo(Customer::class, 'customer_id'); + } + + /** + * Relationship: Cart has pricing info + */ + public function pricing() + { + return $this->belongsTo(ProductPricings::class, 'product_pricing_id'); + } } diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index 8bca3f9..d6bdb16 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -25,6 +25,15 @@ class OrderItem extends Model public function getProductAttribute() { + // New flow: Check if product exists in Product table first + if ($this->product_id) { + $product = Product::find($this->product_id); + if ($product) { + return $product; + } + } + + // Legacy flow: fallback to old system return match ($this->product_type) { 3 => Hosting::find($this->product_id), 4 => Vps::find($this->product_id), @@ -32,6 +41,22 @@ class OrderItem extends Model }; } + /** + * Relationship: OrderItem belongs to Product (new system) + */ + public function product() + { + return $this->belongsTo(Product::class, 'product_id'); + } + + /** + * Relationship: OrderItem has pricing info + */ + public function pricing() + { + return $this->belongsTo(ProductPricings::class, 'product_pricing_id'); + } + public function os() diff --git a/app/Models/Os.php b/app/Models/Os.php index 5db5ac7..791965b 100644 --- a/app/Models/Os.php +++ b/app/Models/Os.php @@ -39,4 +39,24 @@ class Os extends Model ->withTimestamps() ->withPivot('product_type'); } + + /** + * Many-to-many relationship với Products (new table: os_product) + */ + public function products() + { + return $this->belongsToMany(\App\Models\Product::class, 'os_product', 'os_id', 'product_id') + ->withTimestamps(); + } + + /** + * Get display name with OS Group + */ + public function getNameAttribute() + { + if ($this->osGroup) { + return $this->osGroup->name . ' ' . $this->version; + } + return $this->version; + } } diff --git a/app/Models/Product.php b/app/Models/Product.php index ac11732..f22b449 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -79,6 +79,22 @@ class Product extends Model return $this->hasMany(ProductPricings::class, 'product_id'); } + /** + * Relationship: Product has many order items + */ + public function orderItems() + { + return $this->hasMany(OrderItem::class, 'product_id'); + } + + /** + * Relationship: Product has many orders through order items + */ + public function orders() + { + return $this->hasManyThrough(Order::class, OrderItem::class, 'product_id', 'id', 'id', 'order_id'); + } + /** * Legacy: Many-to-many categories (if still needed) */ @@ -86,4 +102,31 @@ class Product extends Model { return $this->belongsToMany(ProductCategories::class, 'product_product_category', 'product_id', 'product_category_id'); } + + /** + * Get monthly pricing (or first available pricing) + */ + public function getMonthlyPricingAttribute() + { + return $this->pricings() + ->where('is_active', 1) + ->where('billing_cycle', 'monthly') + ->first() + ?? $this->pricings()->where('is_active', 1)->orderBy('price')->first(); + } + + /** + * Get display price (sale price if available, otherwise regular price) + */ + public function getPriceAttribute() + { + $pricing = $this->monthlyPricing; + return $pricing ? $pricing->price : 0; + } + + public function getPriceSaleAttribute() + { + $pricing = $this->monthlyPricing; + return $pricing ? $pricing->price_sale : null; + } } diff --git a/app/Models/ProductCategories.php b/app/Models/ProductCategories.php index 512f258..35d90b9 100644 --- a/app/Models/ProductCategories.php +++ b/app/Models/ProductCategories.php @@ -10,6 +10,8 @@ class ProductCategories extends Model { use HasFactory, SoftDeletes; + protected $table = 'product_categories'; + protected $fillable = [ 'name', 'slug', @@ -30,6 +32,6 @@ class ProductCategories extends Model public function products() { - return $this->belongsToMany(Product::class, 'product_product_category', 'product_category_id', 'product_id'); + return $this->hasMany(Product::class, 'product_categories_id'); } } diff --git a/database/migrations/2026_02_08_210451_add_product_support_to_carts_and_order_items_tables.php b/database/migrations/2026_02_08_210451_add_product_support_to_carts_and_order_items_tables.php new file mode 100644 index 0000000..e0545ca --- /dev/null +++ b/database/migrations/2026_02_08_210451_add_product_support_to_carts_and_order_items_tables.php @@ -0,0 +1,60 @@ +string('billing_cycle', 50)->nullable()->after('months')->comment('Billing cycle: monthly, semi_annually, annually, etc.'); + } + if (!Schema::hasColumn('carts', 'product_pricing_id')) { + $table->unsignedBigInteger('product_pricing_id')->nullable()->after('billing_cycle')->comment('Link to product_pricings table'); + $table->index('product_pricing_id'); + } + + // Make type_product nullable for new Product system + $table->integer('type_product')->nullable()->change(); + }); + + // Add support for new Product system to order__order_item table + Schema::table('order__order_item', function (Blueprint $table) { + if (!Schema::hasColumn('order__order_item', 'billing_cycle')) { + $table->string('billing_cycle', 50)->nullable()->after('months')->comment('Billing cycle: monthly, semi_annually, annually, etc.'); + } + if (!Schema::hasColumn('order__order_item', 'product_pricing_id')) { + $table->unsignedBigInteger('product_pricing_id')->nullable()->after('billing_cycle')->comment('Link to product_pricings table'); + $table->index('product_pricing_id'); + } + + // Make product_type nullable for new Product system + if (Schema::hasColumn('order__order_item', 'product_type')) { + $table->integer('product_type')->nullable()->change(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('carts', function (Blueprint $table) { + $table->dropColumn(['billing_cycle', 'product_pricing_id']); + $table->dropIndex(['product_pricing_id']); + }); + + Schema::table('order__order_item', function (Blueprint $table) { + $table->dropColumn(['billing_cycle', 'product_pricing_id']); + $table->dropIndex(['product_pricing_id']); + }); + } +}; diff --git a/resources/js/service/vminfo.js b/resources/js/service/vminfo.js index 1d849c1..c2ccc81 100644 --- a/resources/js/service/vminfo.js +++ b/resources/js/service/vminfo.js @@ -27,15 +27,22 @@ $(document).ready(function () { $(document).on("click", ".dropdown-option", function (e) { e.stopPropagation(); const $option = $(this); - const $dropdown = $option.closest(".custom-dropdown"); - const $selected = $dropdown.find(".dropdown-selected"); - const $options = $dropdown.find(".dropdown-options"); - const $selectedText = $selected.find(".selected-text"); + + // Ignore disabled options + if ($option.hasClass('disabled')) { + return; + } + + const $dropdown = $option.closest('.custom-dropdown'); + const $selected = $dropdown.find('.dropdown-selected'); + const $options = $dropdown.find('.dropdown-options'); + const $selectedText = $selected.find('.selected-text'); // Get data from selected option - const value = $option.data("value"); - const months = $option.data("months"); - const price = $option.data("price"); + const value = $option.data('value'); + const months = $option.data('months'); + const price = $option.data('price'); + const cycle = $option.data('cycle'); // Get billing cycle // Get text from the first span element to avoid getting badge text const text = $option.find('span').first().text(); @@ -43,19 +50,21 @@ $(document).ready(function () { $selectedText.text(text); // Update hidden inputs - $dropdown.closest("form").find(".hiddenMonths").val(months); - $dropdown.closest("form").find(".hiddenTotalPrice").val(price); + const $form = $dropdown.closest('form'); + $form.find('.hiddenMonths').val(months); + $form.find('.hiddenProductPrice').val(price); // Update product price + $form.find('.hiddenBillingCycle').val(cycle); // Update billing cycle // Update selected state - $options.find(".dropdown-option").removeClass("selected"); - $option.addClass("selected"); + $options.find('.dropdown-option').removeClass('selected'); + $option.addClass('selected'); // Close dropdown - $options.removeClass("show"); - $selected.removeClass("active"); + $options.removeClass('show'); + $selected.removeClass('active'); // Trigger custom event for other components - $dropdown.trigger("dropdown:changed", { + $dropdown.trigger('dropdown:changed', { value: value, months: months, price: price, @@ -66,19 +75,19 @@ $(document).ready(function () { const $productContainer = $dropdown.closest('[data-product-id]'); const basePrice = parseFloat($productContainer.data('base-price')); const selectedMonths = parseInt(months); - + // Calculate prices const originalPrice = basePrice * selectedMonths; const savings = selectedMonths >= 12 ? Math.round(originalPrice * 0.1) : 0; const finalPrice = originalPrice - savings; - + // Update display $productContainer.find('.total-price-display').text('₫' + finalPrice.toLocaleString()); $productContainer.find('.months-display').text(selectedMonths + ' tháng'); $productContainer.find('.price-per-month').text('₫' + basePrice.toLocaleString() + '/tháng'); $productContainer.find('.original-price').text('₫' + originalPrice.toLocaleString()); $productContainer.find('.savings-amount').text('₫' + savings.toLocaleString()); - + // Show/hide savings info if (savings > 0) { $productContainer.find('.savings-info').show(); diff --git a/resources/views/administrator/pages/block/ModuleVCenter.blade.php b/resources/views/administrator/pages/block/ModuleVCenter.blade.php index 479be26..09bd1e6 100644 --- a/resources/views/administrator/pages/block/ModuleVCenter.blade.php +++ b/resources/views/administrator/pages/block/ModuleVCenter.blade.php @@ -1,12 +1,12 @@ -Add +Add Server
@csrf - Import + Import From Excel -File Import +File Import Demo \ No newline at end of file diff --git a/resources/views/administrator/pages/module/hosting/index.blade.php b/resources/views/administrator/pages/module/hosting/index.blade.php index 512567a..58ff378 100644 --- a/resources/views/administrator/pages/module/hosting/index.blade.php +++ b/resources/views/administrator/pages/module/hosting/index.blade.php @@ -44,7 +44,7 @@ @section('content') @if (auth()->guard('admins')->user()->can('manager module hosting'))
-
+
@include('administrator.pages.block.ModuleHosting')
diff --git a/resources/views/administrator/pages/module/vcenter/datatable/action.blade.php b/resources/views/administrator/pages/module/vcenter/datatable/action.blade.php index ea3085e..2bbee98 100644 --- a/resources/views/administrator/pages/module/vcenter/datatable/action.blade.php +++ b/resources/views/administrator/pages/module/vcenter/datatable/action.blade.php @@ -1,2 +1,7 @@ -Edit -Delete \ No newline at end of file +
+ + Edit + Delete +
\ No newline at end of file diff --git a/resources/views/administrator/pages/product/datatable/action.blade.php b/resources/views/administrator/pages/product/datatable/action.blade.php index c671bc0..47b56d0 100644 --- a/resources/views/administrator/pages/product/datatable/action.blade.php +++ b/resources/views/administrator/pages/product/datatable/action.blade.php @@ -1,2 +1,6 @@ -Chỉnh sửa -Xóa \ No newline at end of file +
+ Chỉnh sửa + Xóa +
\ No newline at end of file diff --git a/resources/views/frontend/service/index.blade.php b/resources/views/frontend/service/index.blade.php index c95cb31..befd0af 100644 --- a/resources/views/frontend/service/index.blade.php +++ b/resources/views/frontend/service/index.blade.php @@ -1,42 +1,65 @@ @extends('frontend.layouts.master') @section('content') - @if (!empty($group)) + @if (!empty($category)) -
+
@forelse ($products as $index => $item)
@csrf - - + {{-- Remove type_id for new Product system --}} + @php + $defaultPricingForCart = + $item->pricings->firstWhere('billing_cycle', 'semi_annually') ?? $item->pricings->first(); + $cartPrice = $defaultPricingForCart + ? floatval($defaultPricingForCart->price_sale ?: $defaultPricingForCart->price) + : 0; + @endphp + -
-
-

+
+

{{ $item->name }}

@php - $basePrice = floatval($item->price_sale ?: $item->price); + // Get pricing for different billing cycles + // Note: If billing_cycle is 'onetime', treat it as monthly + $monthlyPricing = + $item->pricings->firstWhere('billing_cycle', 'monthly') ?? + $item->pricings->firstWhere('billing_cycle', 'onetime'); + $semiAnnuallyPricing = $item->pricings->firstWhere('billing_cycle', 'semi_annually'); + $annuallyPricing = $item->pricings->firstWhere('billing_cycle', 'annually'); + $bienniallyPricing = $item->pricings->firstWhere('billing_cycle', 'biennially'); + $trienniallyPricing = $item->pricings->firstWhere('billing_cycle', 'triennially'); + + // Use monthly or first available pricing for display + $displayPricing = $monthlyPricing ?? $item->pricings->first(); + $basePrice = $displayPricing + ? floatval($displayPricing->price_sale ?: $displayPricing->price) + : 0; @endphp
-
- - ≈ {{ format_price($item->price_sale ?: $item->price) }} + class="mb-3 inline-flex w-full items-center justify-center gap-2 rounded-md bg-orange-50 p-2"> + + ≈ + {{ $displayPricing ? format_price($displayPricing->price_sale ?: $displayPricing->price) : 'Liên hệ' }} - / tháng + @if ($displayPricing) + / tháng + @endif
@@ -44,143 +67,243 @@
+ @php + // Default to semi_annually (6 months) if available, otherwise first available + $defaultPricing = $semiAnnuallyPricing ?? $item->pricings->first(); + $defaultPrice = $defaultPricing + ? floatval($defaultPricing->price_sale ?: $defaultPricing->price) + : 0; + $defaultMonths = match ($defaultPricing?->billing_cycle) { + 'semi_annually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + default => 1, + }; + @endphp