From 3f3b9da9929dd9cff16d9b8387c7aa1a7e6c6325 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 20 Jun 2025 08:57:05 +0200 Subject: [PATCH] improved behaviour --- hub/services/static/js/price-calculator.js | 143 ++++++++++++++++----- hub/services/views/pricelist.py | 61 +++++++-- 2 files changed, 164 insertions(+), 40 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index 30a581a..377c097 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -335,9 +335,18 @@ Please contact me with next steps for ordering this configuration.`; this.cpuValue.textContent = selectedPlan.vcpus; this.memoryValue.textContent = selectedPlan.ram; + // Fade out CPU and Memory sliders since plan is manually selected + this.fadeOutSliders(['cpu', 'memory']); + + // Update addons for the new configuration this.updateAddons(); + // Update pricing with the selected plan this.updatePricingWithPlan(selectedPlan); } else { + // Auto-select mode - fade sliders back in + this.fadeInSliders(['cpu', 'memory']); + + // Auto-select mode - update addons and recalculate this.updateAddons(); this.updatePricing(); } @@ -555,6 +564,8 @@ Please contact me with next steps for ordering this configuration.`; if (!addon.is_mandatory) { const checkbox = addonElement.querySelector('.addon-checkbox'); checkbox.addEventListener('change', () => { + // Update addon prices and recalculate total + this.updateAddonPrices(); this.updatePricing(); }); } @@ -571,7 +582,7 @@ Please contact me with next steps for ordering this configuration.`; const storage = parseInt(this.storageRange?.value || 20); const instances = parseInt(this.instancesRange?.value || 1); - // Find the current plan data to get variable_unit + // Find the current plan data to get variable_unit for addon calculations const matchedPlan = this.getCurrentPlan(); const variableUnit = matchedPlan?.variable_unit || 'CPU'; const units = variableUnit === 'CPU' ? cpus : memory; @@ -583,17 +594,22 @@ Please contact me with next steps for ordering this configuration.`; const priceElement = checkbox.parentElement.querySelector('.addon-price-value'); let calculatedPrice = 0; + + // Calculate addon price based on type if (addon.addon_type === 'BASE_FEE') { + // Base fee: price per instance calculatedPrice = parseFloat(addon.price || 0) * instances; } else if (addon.addon_type === 'UNIT_RATE') { + // Unit rate: price per unit (CPU or memory) across all instances calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits; } + // Update the display price if (priceElement) { priceElement.textContent = calculatedPrice.toFixed(2); } - // Update the checkbox data for later calculation + // Store the calculated price for later use in total calculations checkbox.dataset.calculatedPrice = calculatedPrice.toString(); }); } @@ -665,7 +681,7 @@ Please contact me with next steps for ordering this configuration.`; updatePricing() { if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; - // Update addon prices first + // Update addon prices first to ensure they're current this.updateAddonPrices(); // Reset plan selection if in auto-select mode @@ -690,7 +706,13 @@ Please contact me with next steps for ordering this configuration.`; } else { // Plan is directly selected, update storage pricing const selectedPlan = JSON.parse(this.planSelect.value); - this.updatePricingWithPlan(selectedPlan); + const storage = parseInt(this.storageRange.value); + const instances = parseInt(this.instancesRange.value); + + // Update addon prices for current configuration + this.updateAddonPrices(); + this.showPlanDetails(selectedPlan, storage, instances); + this.updateStatusMessage('Plan selected directly!', 'success'); } } @@ -715,13 +737,18 @@ Please contact me with next steps for ordering this configuration.`; if (this.planInstances) this.planInstances.textContent = instances; if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel; + // Ensure addon prices are calculated with current configuration + this.updateAddonPrices(); + // Calculate pricing using final price from plan data (which already includes mandatory addons) // plan.final_price = compute_plan_price + sla_price (where sla_price includes mandatory addons) const managedServicePricePerInstance = parseFloat(plan.final_price); - // Collect mandatory addons for display (but don't add to price since they're already included) + // Collect addon information for display and calculation let mandatoryAddonTotal = 0; + let optionalAddonTotal = 0; const mandatoryAddons = []; + const selectedOptionalAddons = []; if (this.addonsContainer) { const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox'); @@ -730,11 +757,19 @@ Please contact me with next steps for ordering this configuration.`; const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); if (addon.is_mandatory) { - // Don't add to mandatoryAddonTotal since it's already in plan.final_price + // Mandatory addons are already included in plan.final_price + // We collect them for display purposes only mandatoryAddons.push({ name: addon.name, price: calculatedPrice.toFixed(2) }); + } else if (checkbox.checked) { + // Only count checked optional addons + optionalAddonTotal += calculatedPrice; + selectedOptionalAddons.push({ + name: addon.name, + price: calculatedPrice.toFixed(2) + }); } }); } @@ -745,26 +780,7 @@ Please contact me with next steps for ordering this configuration.`; const storageUnitPrice = plan.storage_price !== undefined ? parseFloat(plan.storage_price) : this.storagePrice; const storagePriceValue = storage * storageUnitPrice * instances; - // Calculate optional addon total - let optionalAddonTotal = 0; - const selectedOptionalAddons = []; - - if (this.addonsContainer) { - const addonCheckboxes = this.addonsContainer.querySelectorAll('.addon-checkbox:checked'); - addonCheckboxes.forEach(checkbox => { - const addon = JSON.parse(checkbox.dataset.addon); - const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0); - - if (!addon.is_mandatory) { - optionalAddonTotal += calculatedPrice; - selectedOptionalAddons.push({ - name: addon.name, - price: calculatedPrice.toFixed(2) - }); - } - }); - } - + // Total price = managed service price (includes mandatory addons) + storage + optional addons const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal; // Update pricing display @@ -797,20 +813,33 @@ Please contact me with next steps for ordering this configuration.`; // Clear existing addon pricing display this.addonPricingContainer.innerHTML = ''; - // Add mandatory addons to pricing breakdown + // Add mandatory addons to pricing breakdown (for informational purposes only) if (mandatoryAddons && mandatoryAddons.length > 0) { + // Add a note explaining mandatory addons are included + const mandatoryNote = document.createElement('div'); + mandatoryNote.className = 'text-muted small mb-2'; + mandatoryNote.innerHTML = 'Required add-ons (included in managed service price):'; + this.addonPricingContainer.appendChild(mandatoryNote); + mandatoryAddons.forEach(addon => { const addonRow = document.createElement('div'); - addonRow.className = 'd-flex justify-content-between mb-2'; + addonRow.className = 'd-flex justify-content-between mb-1 ps-3'; addonRow.innerHTML = ` - Add-on: ${addon.name} (Required) - CHF ${addon.price} + ${addon.name} + CHF ${addon.price} `; this.addonPricingContainer.appendChild(addonRow); }); + + // Add separator if there are also optional addons + if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { + const separator = document.createElement('hr'); + separator.className = 'my-2'; + this.addonPricingContainer.appendChild(separator); + } } - // Add optional addons to pricing breakdown + // Add optional addons to pricing breakdown (these are added to total) if (selectedOptionalAddons && selectedOptionalAddons.length > 0) { selectedOptionalAddons.forEach(addon => { const addonRow = document.createElement('div'); @@ -852,6 +881,58 @@ Please contact me with next steps for ordering this configuration.`; this.planMatchStatus.style.display = 'block'; } } + + // Fade out specified sliders when plan is manually selected + fadeOutSliders(sliderTypes) { + sliderTypes.forEach(type => { + const sliderContainer = this.getSliderContainer(type); + if (sliderContainer) { + sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; + sliderContainer.style.opacity = '0.3'; + sliderContainer.style.pointerEvents = 'none'; + + // Add visual indicator that sliders are disabled + const slider = sliderContainer.querySelector('.form-range'); + if (slider) { + slider.style.cursor = 'not-allowed'; + } + } + }); + } + + // Fade in specified sliders when auto-select mode is chosen + fadeInSliders(sliderTypes) { + sliderTypes.forEach(type => { + const sliderContainer = this.getSliderContainer(type); + if (sliderContainer) { + sliderContainer.style.transition = 'opacity 0.3s ease-in-out'; + sliderContainer.style.opacity = '1'; + sliderContainer.style.pointerEvents = 'auto'; + + // Remove visual indicator + const slider = sliderContainer.querySelector('.form-range'); + if (slider) { + slider.style.cursor = 'pointer'; + } + } + }); + } + + // Get slider container element by type + getSliderContainer(type) { + switch (type) { + case 'cpu': + return this.cpuRange?.closest('.mb-4'); + case 'memory': + return this.memoryRange?.closest('.mb-4'); + case 'storage': + return this.storageRange?.closest('.mb-4'); + case 'instances': + return this.instancesRange?.closest('.mb-4'); + default: + return null; + } + } } // Initialize calculator when DOM is loaded diff --git a/hub/services/views/pricelist.py b/hub/services/views/pricelist.py index 2616522..b392f6b 100644 --- a/hub/services/views/pricelist.py +++ b/hub/services/views/pricelist.py @@ -203,13 +203,56 @@ def pricelist(request): discount_savings = 0 discount_percentage = 0 - # Get addon information - addons = appcat_price.addons.filter(active=True) + # Calculate final price using the model method to ensure consistency + price_calculation = appcat_price.calculate_final_price( + currency_code=currency, + service_level=service_level, + number_of_units=total_units, + addon_ids=None, # This will include only mandatory addons + ) + + if price_calculation is None: + continue + + # Calculate base service price (without addons) for display purposes + base_sla_price = base_fee + (total_units * unit_rate) + + # Apply discount if available + discount_breakdown = None + if ( + appcat_price.discount_model + and appcat_price.discount_model.active + ): + discounted_price = ( + appcat_price.discount_model.calculate_discount( + unit_rate, total_units + ) + ) + sla_price = base_fee + discounted_price + discount_savings = base_sla_price - sla_price + discount_percentage = ( + (discount_savings / base_sla_price) * 100 + if base_sla_price > 0 + else 0 + ) + discount_breakdown = ( + appcat_price.discount_model.get_discount_breakdown( + unit_rate, total_units + ) + ) + else: + sla_price = base_sla_price + discounted_price = total_units * unit_rate + discount_savings = 0 + discount_percentage = 0 + + # Extract addon information from the calculation mandatory_addons = [] optional_addons = [] - # Group addons by mandatory vs optional - for addon in addons: + # Get all addons to separate mandatory from optional + all_addons = appcat_price.addons.filter(active=True) + for addon in all_addons: addon_price = None if addon.addon_type == "BF": # Base Fee @@ -232,12 +275,12 @@ def pricelist(request): if addon.mandatory: mandatory_addons.append(addon_info) - if addon_price: - sla_price += addon_price else: optional_addons.append(addon_info) - final_price = compute_plan_price + sla_price + # Use the calculated total price which includes mandatory addons + service_price_with_addons = price_calculation["total_price"] + final_price = compute_plan_price + service_price_with_addons service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[ service_level ] @@ -309,8 +352,8 @@ def pricelist(request): "service_level": service_level_display, "sla_base": base_fee, "sla_per_unit": unit_rate, - "sla_price": sla_price, - "standard_sla_price": standard_sla_price, + "sla_price": service_price_with_addons, + "standard_sla_price": base_sla_price, "discounted_sla_price": ( base_fee + discounted_price if appcat_price.discount_model