diff --git a/src/app/pages/seller-offerings/offerings/seller-product-spec/create-product-spec/create-product-spec.component.ts b/src/app/pages/seller-offerings/offerings/seller-product-spec/create-product-spec/create-product-spec.component.ts index eb7b63d3..f64e28b0 100644 --- a/src/app/pages/seller-offerings/offerings/seller-product-spec/create-product-spec/create-product-spec.component.ts +++ b/src/app/pages/seller-offerings/offerings/seller-product-spec/create-product-spec/create-product-spec.component.ts @@ -1134,6 +1134,17 @@ export class CreateProductSpecComponent implements OnInit, OnDestroy { this.numberValue=''; }else if(this.rangeCharSelected){ console.log('range') + // Validate that fromValue < toValue + const fromVal = Number(this.fromValue); + const toVal = Number(this.toValue); + if (fromVal >= toVal) { + console.log('range validation error: valueFrom >= valueTo') + this.errorMessage = 'Invalid range: "From" value must be less than "To" value'; + this.showError = true; + setTimeout(() => {this.showError = false}, 3000); + return; + } + if(this.creatingChars.length==0){ this.creatingChars.push({ isDefault:true, @@ -1147,22 +1158,10 @@ export class CreateProductSpecComponent implements OnInit, OnDestroy { valueFrom:this.fromValue as any, valueTo:this.toValue as any, unitOfMeasure:this.rangeUnit}) - } - } else { - console.log('boolean') - if(this.creatingChars.length==0){ - this.creatingChars.push({ - isDefault:true, - value:this.booleanValue as any - }) - } else{ - this.creatingChars.push({ - isDefault:false, - value:this.booleanValue as any - }) } + } else { + console.log('nothing') } - this.booleanValue=false; } removeCharValue(char:any,idx:any){ @@ -1183,6 +1182,17 @@ export class CreateProductSpecComponent implements OnInit, OnDestroy { saveChar(){ if(this.charsForm.value.name!=null){ + + // In showFinish() only takes the first ocurrence in name for sending to proxy + // I validate the duplication here to prevent confusion in client when suddenly a characteristic with the same name dissapeared + if (this.prodChars.find((char)=> char.name === this.charsForm.value.name)){ + console.log('name duplicated error') + this.errorMessage = 'Cannot save duplicated name in characteristics'; + this.showError = true; + setTimeout(() => {this.showError = false}, 3000); + return + } + // Create the main characteristic this.prodChars.push({ id: 'urn:ngsi-ld:characteristic:'+uuidv4(), diff --git a/src/app/pages/seller-offerings/offerings/seller-product-spec/update-product-spec/update-product-spec.component.ts b/src/app/pages/seller-offerings/offerings/seller-product-spec/update-product-spec/update-product-spec.component.ts index aa9fe196..08a0c3b8 100644 --- a/src/app/pages/seller-offerings/offerings/seller-product-spec/update-product-spec/update-product-spec.component.ts +++ b/src/app/pages/seller-offerings/offerings/seller-product-spec/update-product-spec/update-product-spec.component.ts @@ -1342,6 +1342,17 @@ export class UpdateProductSpecComponent implements OnInit, OnDestroy { this.numberValue=''; }else if(this.rangeCharSelected){ console.log('range') + // Validate that fromValue < toValue + const fromVal = Number(this.fromValue); + const toVal = Number(this.toValue); + if (fromVal >= toVal) { + console.log('range validation error: valueFrom >= valueTo') + this.errorMessage = 'Invalid range: "From" value must be less than "To" value'; + this.showError = true; + setTimeout(() => {this.showError = false}, 3000); + return; + } + if(this.creatingChars.length==0){ this.creatingChars.push({ isDefault:true, diff --git a/src/app/shared/forms/offer/price-plans/price-component-drawer/price-component-drawer.component.html b/src/app/shared/forms/offer/price-plans/price-component-drawer/price-component-drawer.component.html index cc0ea62e..7a6df66c 100644 --- a/src/app/shared/forms/offer/price-plans/price-component-drawer/price-component-drawer.component.html +++ b/src/app/shared/forms/offer/price-plans/price-component-drawer/price-component-drawer.component.html @@ -106,7 +106,7 @@

- - @if(showValueSelect){ - - } + + @if(showValueSelect){ + + }
+ + @if(selectedCharacteristic && selectedCharacteristic.productSpecCharacteristicValue?.[0] && + hasKey(selectedCharacteristic.productSpecCharacteristicValue[0], 'valueFrom') && + hasKey(selectedCharacteristic.productSpecCharacteristicValue[0], 'valueTo')){ +
+ +
+
+ +
+
+ + {{ selectedCharacteristic.productSpecCharacteristicValue[0].valueFrom }} + +
+ +
+
+ + {{ selectedCharacteristic.productSpecCharacteristicValue[0].valueTo }} + +
+
+
+ } } diff --git a/src/app/shared/forms/offer/price-plans/price-component-drawer/price-component-drawer.component.ts b/src/app/shared/forms/offer/price-plans/price-component-drawer/price-component-drawer.component.ts index 4997f1dd..1de6d9bc 100644 --- a/src/app/shared/forms/offer/price-plans/price-component-drawer/price-component-drawer.component.ts +++ b/src/app/shared/forms/offer/price-plans/price-component-drawer/price-component-drawer.component.ts @@ -179,7 +179,7 @@ export class PriceComponentDrawerComponent implements OnInit { } this.priceComponentForm.patchValue({ - selectedCharacteristic: this.mapChars(this.selectedCharacteristicVal) + selectedCharacteristic: [this.mapChars(this.selectedCharacteristicVal)] }); } @@ -191,12 +191,19 @@ export class PriceComponentDrawerComponent implements OnInit { description: this.selectedCharacteristic.description || '', } - // Add the productSpecCharacteristicValue only if needed - // Range chars not include a value + // Add the productSpecCharacteristicValue if (this.showValueSelect) { + // For non-range characteristics (with specific values) char.productSpecCharacteristicValue = [this.selectedCharacteristic.productSpecCharacteristicValue.find((opt: any) => { return String(opt.value) === String(charValue); })]; + } else if (this.selectedCharacteristic.productSpecCharacteristicValue?.[0]) { + // For range characteristics, include the full range + char.productSpecCharacteristicValue = [{ + valueFrom: this.selectedCharacteristic.productSpecCharacteristicValue[0].valueFrom, + valueTo: this.selectedCharacteristic.productSpecCharacteristicValue[0].valueTo, + isDefault: true + }]; } return char @@ -205,7 +212,7 @@ export class PriceComponentDrawerComponent implements OnInit { changePriceComponentCharValue(event: any){ this.selectedCharacteristicVal = event.target.value; this.priceComponentForm.patchValue({ - selectedCharacteristic: this.mapChars(event.target.value) + selectedCharacteristic: [this.mapChars(event.target.value)] }); } diff --git a/src/app/shared/forms/offer/price-plans/price-plan-drawer/price-plan-drawer.component.html b/src/app/shared/forms/offer/price-plans/price-plan-drawer/price-plan-drawer.component.html index 43e34a20..e837c074 100644 --- a/src/app/shared/forms/offer/price-plans/price-plan-drawer/price-plan-drawer.component.html +++ b/src/app/shared/forms/offer/price-plans/price-plan-drawer/price-plan-drawer.component.html @@ -3,7 +3,7 @@ [ngClass]="{'opacity-100': isOpen, 'opacity-0 pointer-events-none': !isOpen}"> -
@@ -200,9 +200,9 @@

(delete)="deletePriceComponent($event)"> -
+
+ +
} + + @if(rangeValidationError) { + + } +
diff --git a/src/app/shared/forms/offer/price-plans/price-plan-drawer/price-plan-drawer.component.ts b/src/app/shared/forms/offer/price-plans/price-plan-drawer/price-plan-drawer.component.ts index 07ea6a3c..1942e105 100644 --- a/src/app/shared/forms/offer/price-plans/price-plan-drawer/price-plan-drawer.component.ts +++ b/src/app/shared/forms/offer/price-plans/price-plan-drawer/price-plan-drawer.component.ts @@ -9,6 +9,7 @@ import {currencies} from "currencies.json"; import { ConfigurationProfileDrawerComponent } from "../configuration-profile-drawer/configuration-profile-drawer.component"; +import {TierPricingDrawerComponent} from "../tier-pricing-drawer/tier-pricing-drawer.component"; import {Subject} from "rxjs"; import { takeUntil } from 'rxjs/operators'; @@ -26,6 +27,7 @@ import { takeUntil } from 'rxjs/operators'; PriceComponentDrawerComponent, PriceComponentsTableComponent, ConfigurationProfileDrawerComponent, + TierPricingDrawerComponent, NgForOf ], styleUrl: './price-plan-drawer.component.css' @@ -42,11 +44,13 @@ export class PricePlanDrawerComponent implements OnInit, OnDestroy { initialized = false; showPriceComponentDrawer = false; showConfigurationDrawer = false; + showTierPricingDrawer = false; editingComponent: any = null; //protected readonly currencies = currencies; //Only allowing EUR for the moment protected readonly currencies=[currencies[2]]; private destroy$ = new Subject(); + rangeValidationError: string | null = null; constructor(private fb: FormBuilder) {} @@ -98,10 +102,126 @@ export class PricePlanDrawerComponent implements OnInit, OnDestroy { savePricePlan() { if (this.formGroup.invalid) return; + + // Validate range characteristics coverage + if (!this.validateRangeCharacteristicsCoverage()) { + console.log('Range characteristics validation failed'); + return; + } + + // Clear any previous validation errors + this.rangeValidationError = null; + this.save.emit(this.formGroup.getRawValue()); this.closeDrawer(); } + private validateRangeCharacteristicsCoverage(): boolean { + const antiCollision: any = {}; + const rangeCharMap: any = {}; // To store characteristic names by ID + + // Step 1: Initialize antiCollision with range characteristics from prodSpec + const rangeCharacteristics = this.getRangeCharacteristics(); + + for (const rangeChar of rangeCharacteristics) { + const specValue = rangeChar.productSpecCharacteristicValue[0]; + antiCollision[rangeChar.id] = { + totalRange: { + from: Number(specValue.valueFrom), + to: Number(specValue.valueTo) + } + }; + rangeCharMap[rangeChar.id] = rangeChar.name; + } + + // Step 2: Add price components to the antiCollision map + const priceComponents = this.formGroup.get('priceComponents')?.value || []; + + for (const pc of priceComponents) { + const selectedChar = pc.selectedCharacteristic?.[0]; + if (!selectedChar) continue; + + // Check if this price component is for a range characteristic + if (antiCollision[selectedChar.id]) { + const charValue = selectedChar.productSpecCharacteristicValue?.[0]; + if (charValue && 'valueFrom' in charValue && 'valueTo' in charValue) { + const from = Number(charValue.valueFrom); + const to = Number(charValue.valueTo); + + // Initialize array if not exists + if (!antiCollision[selectedChar.id][from]) { + antiCollision[selectedChar.id][from] = []; + } + antiCollision[selectedChar.id][from].push(to); + } + } + } + + // Step 3: Validate coverage for each range characteristic + for (const [charId, rangeData] of Object.entries(antiCollision)) { + const data = rangeData as any; + const keys = Object.keys(data); + + // If only totalRange exists, no price components → valid + if (keys.length === 1 && keys[0] === 'totalRange') { + continue; + } + + // If there are price components, check if they cover the full range + const totalRange = data.totalRange; + if (!this.hasCompletePathDFS(data, totalRange.from, totalRange.to)) { + const charName = rangeCharMap[charId] || charId; + this.rangeValidationError = `The range characteristic "${charName}" does not have complete coverage from ${totalRange.from} to ${totalRange.to}. Please ensure all ranges are continuous and cover the full specification range.`; + console.log(`Range characteristic with ID "${charId}" does not have complete coverage`); + console.log(' Range data:', data); + return false; + } + } + + return true; + } + + private getRangeCharacteristics(): any[] { + if (!this.prodSpec?.productSpecCharacteristic) return []; + + return this.prodSpec.productSpecCharacteristic.filter((char: any) => { + const firstValue = char.productSpecCharacteristicValue?.[0]; + return firstValue && 'valueFrom' in firstValue && 'valueTo' in firstValue; + }); + } + + private hasCompletePathDFS(rangeData: any, start: number, end: number): boolean { + const visited = new Set(); + + const dfs = (currentFrom: number): boolean => { + // Get all possible valueTo from current valueFrom + const possibleTos = rangeData[currentFrom]; + if (!possibleTos || possibleTos.length === 0) return false; + + // Try each possible valueTo + for (const valueTo of possibleTos) { + // If valueTo equals the end of totalRange, we found a complete path + if (valueTo === end) return true; + + // The next range should start at valueTo + 1 (continuity) + const nextFrom = valueTo + 1; + + // Avoid infinite loops + if (visited.has(nextFrom)) continue; + visited.add(nextFrom); + + // Check if there's a key for the next valueFrom + if (rangeData[nextFrom]) { + if (dfs(nextFrom)) return true; + } + } + + return false; + }; + + return dfs(start); + } + openPriceComponentDrawer(component: any = null) { this.editingComponent = component; this.showPriceComponentDrawer = true; @@ -271,6 +391,29 @@ export class PricePlanDrawerComponent implements OnInit, OnDestroy { } } + openTierPricingDrawer() { + this.showTierPricingDrawer = true; + } + closeTierPricingDrawer(priceComponents: any[] | null) { + if (priceComponents && Array.isArray(priceComponents)) { + // Add all tier pricing components to the price components array + const components = this.formGroup.get('priceComponents')?.value || []; + components.push(...priceComponents); + this.formGroup.get('priceComponents')?.setValue(components); + console.log('Tier pricing components added:', priceComponents); + } + this.showTierPricingDrawer = false; + } + + hasRangeCharacteristics(): boolean { + // Check if there are any range characteristics available + if (!this.prodSpec?.productSpecCharacteristic) return false; + + return this.prodSpec.productSpecCharacteristic.some((char: any) => { + const firstValue = char.productSpecCharacteristicValue?.[0]; + return firstValue && 'valueFrom' in firstValue && 'valueTo' in firstValue; + }); + } } diff --git a/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.css b/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.css new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.css @@ -0,0 +1 @@ + diff --git a/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.html b/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.html new file mode 100644 index 00000000..551837e1 --- /dev/null +++ b/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.html @@ -0,0 +1,397 @@ + +
+ + +
+ +
+ +
+ +

+ Create Tier Pricing +

+ +
+ + +
+ + +
+ + +
+ Please select a range characteristic to start creating tier pricing. +
+ + +
+
+ +

+ Add markers to divide the range into subranges. Each subrange will become a separate price component. +

+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {{ rangeMin }} + +
+ + +
+
+
+
+ + {{ marker }} + +
+
+ + +
+
+ + {{ rangeMax }} + +
+
+ + +
+ +
+
+ Marker {{ i + 1 }}: + + +
+
+
+ + +
+ +
+
+ +
+
+
+ + + + + + + + + + Tier {{ i + 1 }}: {{ subRange.name }} + +
+ + Range: {{ subRange.valueFrom }} - {{ subRange.valueTo }} + + + +
+ Price: {{ subRange.priceComponent.price }} | Type: {{ subRange.priceComponent.priceType }} +
+
+ + + +
+ + +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+
+
+
+ + +
+
+ Validation Errors: +
    +
  • {{ error }}
  • +
+
+
+
+ + +
+ + +
+
+
+
+
diff --git a/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.ts b/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.ts new file mode 100644 index 00000000..88bfd8c7 --- /dev/null +++ b/src/app/shared/forms/offer/price-plans/tier-pricing-drawer/tier-pricing-drawer.component.ts @@ -0,0 +1,422 @@ +import {Component, EventEmitter, HostListener, Input, OnInit, Output, ChangeDetectorRef} from '@angular/core'; +import {FormsModule, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {TranslateModule} from "@ngx-translate/core"; +import {NgClass, NgForOf, NgIf} from "@angular/common"; +import { UsageServiceService } from 'src/app/services/usage-service.service'; +import { LocalStorageService } from 'src/app/services/local-storage.service'; +import { LoginInfo } from 'src/app/models/interfaces'; +import * as moment from 'moment'; + +interface SubRange { + id: string; + valueFrom: number; + valueTo: number; + name: string; + priceComponent: any | null; // Stores the configured price component for this subrange + isEditing?: boolean; // Track if this subrange form is open +} + +@Component({ + selector: 'app-tier-pricing-drawer', + standalone: true, + templateUrl: './tier-pricing-drawer.component.html', + imports: [ + FormsModule, + ReactiveFormsModule, + TranslateModule, + NgClass, + NgIf, + NgForOf + ], + styleUrl: './tier-pricing-drawer.component.css' +}) +export class TierPricingDrawerComponent implements OnInit { + @Input() prodChars: any[] | [] = []; + @Output() close = new EventEmitter(); + @Output() saveTierPricing = new EventEmitter(); // Emits array of configured price components + + isOpen = false; + initialized = false; + + // Range characteristics (filtered) + rangeCharacteristics: any[] = []; + selectedCharacteristic: any = null; + + // Slider and subranges + rangeMin: number = 0; + rangeMax: number = 100; + sliderMarkers: number[] = []; // Points where the user cuts the range + subRanges: SubRange[] = []; + + // Inline price form + editingSubRangeIndex: number | null = null; + priceForm!: FormGroup; + showDiscount: boolean = false; + + // Usage specs for usage pricing + usageSpecs: any[] = []; + selectedUsageSpec: any = null; + selectedMetric: any = null; + showMetricSelect: boolean = false; + partyId: any = ''; + + // Validation errors + validationErrors: string[] = []; + + constructor( + private cdr: ChangeDetectorRef, + private fb: FormBuilder, + private usageService: UsageServiceService, + private localStorage: LocalStorageService + ) {} + + ngOnInit() { + this.initialized = false; + setTimeout(() => { + this.isOpen = true; + this.initialized = true; + document.body.style.overflow = 'hidden'; + }, 50); + + // Filter only range characteristics (those with valueFrom/valueTo) + this.rangeCharacteristics = this.prodChars.filter(char => { + const firstValue = char.productSpecCharacteristicValue?.[0]; + return firstValue && 'valueFrom' in firstValue && 'valueTo' in firstValue; + }); + + // Initialize party info and get usage specs + this.initPartyInfo(); + this.usageService.getAllUsageSpecs(this.partyId).then(data => { + this.usageSpecs = data; + }); + } + + initPartyInfo() { + let aux = this.localStorage.getObject('login_items') as LoginInfo; + if(JSON.stringify(aux) != '{}' && (((aux.expire - moment().unix())-4) > 0)) { + if(aux.logged_as == aux.id) { + this.partyId = aux.partyId; + } else { + let loggedOrg = aux.organizations.find((element: { id: any; }) => element.id == aux.logged_as); + this.partyId = loggedOrg.partyId; + } + } + } + + onCharacteristicChange(event: any) { + const charId = event.target.value; + if (!charId) { + this.selectedCharacteristic = null; + this.resetSlider(); + return; + } + + this.selectedCharacteristic = this.rangeCharacteristics.find( + (char: { id: any; }) => char.id === charId + ); + + if (this.selectedCharacteristic) { + const firstValue = this.selectedCharacteristic.productSpecCharacteristicValue[0]; + this.rangeMin = parseInt(firstValue.valueFrom, 10); + this.rangeMax = parseInt(firstValue.valueTo, 10); + this.resetSlider(); + } + } + + resetSlider() { + this.sliderMarkers = []; + this.subRanges = []; + this.validationErrors = []; + } + + addMarker() { + // Add a marker in the middle of the range if none exist, or between existing markers + if (this.sliderMarkers.length === 0) { + const middle = Math.floor((this.rangeMin + this.rangeMax) / 2); + this.sliderMarkers.push(middle); + } else { + // Find the largest gap and add a marker there + const sortedMarkers = [this.rangeMin, ...this.sliderMarkers.sort((a, b) => a - b), this.rangeMax]; + let maxGap = 0; + let gapIndex = 0; + + for (let i = 0; i < sortedMarkers.length - 1; i++) { + const gap = sortedMarkers[i + 1] - sortedMarkers[i]; + if (gap > maxGap) { + maxGap = gap; + gapIndex = i; + } + } + + const newMarker = Math.floor((sortedMarkers[gapIndex] + sortedMarkers[gapIndex + 1]) / 2); + this.sliderMarkers.push(newMarker); + } + + this.updateSubRanges(); + } + + removeMarker(index: number) { + this.sliderMarkers.splice(index, 1); + this.updateSubRanges(); + } + + onMarkerChange(index: number, event: any) { + const value = parseInt(event.target.value, 10); + this.sliderMarkers[index] = value; + this.updateSubRanges(); + } + + updateSubRanges() { + if (!this.selectedCharacteristic) return; + + // Sort markers + const sortedMarkers = [this.rangeMin, ...this.sliderMarkers.sort((a, b) => a - b), this.rangeMax]; + + // Create subranges, preserving existing priceComponents if they exist + const newSubRanges: SubRange[] = []; + for (let i = 0; i < sortedMarkers.length - 1; i++) { + const valueFrom = sortedMarkers[i]; + const valueTo = sortedMarkers[i + 1]; + + // Adjust for contiguous ranges (valueTo of one should be valueFrom - 1 of next) + const adjustedValueTo = (i < sortedMarkers.length - 2) ? valueTo - 1 : valueTo; + + // Check if we already have a subrange with the same valueFrom/valueTo + const existingSubRange = this.subRanges.find( + sr => sr.valueFrom === valueFrom && sr.valueTo === adjustedValueTo + ); + + newSubRanges.push({ + id: `temp-tier-${i}`, + valueFrom: valueFrom, + valueTo: adjustedValueTo, + name: `${this.selectedCharacteristic.name} ${valueFrom}-${adjustedValueTo}`, + priceComponent: existingSubRange?.priceComponent || null + }); + } + + this.subRanges = newSubRanges; + this.validateSubRanges(); + this.cdr.detectChanges(); + } + + validateSubRanges() { + this.validationErrors = []; + + if (this.subRanges.length < 2) { + this.validationErrors.push('At least 2 subranges are required for tier pricing'); + return; + } + + // Check if all subranges have a configured price component + const unconfiguredSubRanges = this.subRanges.filter(sr => !sr.priceComponent); + if (unconfiguredSubRanges.length > 0) { + this.validationErrors.push(`${unconfiguredSubRanges.length} subrange(s) need price configuration`); + } + + // Check if subranges are contiguous + for (let i = 0; i < this.subRanges.length - 1; i++) { + const current = this.subRanges[i]; + const next = this.subRanges[i + 1]; + + if (current.valueTo + 1 !== next.valueFrom) { + this.validationErrors.push(`Subranges must be contiguous: ${current.name} ends at ${current.valueTo}, next starts at ${next.valueFrom}`); + } + } + + // Check if subranges cover the entire range + const firstSubRange = this.subRanges[0]; + const lastSubRange = this.subRanges[this.subRanges.length - 1]; + + if (firstSubRange.valueFrom !== this.rangeMin) { + this.validationErrors.push(`First subrange must start at ${this.rangeMin}`); + } + + if (lastSubRange.valueTo !== this.rangeMax) { + this.validationErrors.push(`Last subrange must end at ${this.rangeMax}`); + } + + // Check for overlaps (should not happen with our logic, but just in case) + for (let i = 0; i < this.subRanges.length - 1; i++) { + const current = this.subRanges[i]; + const next = this.subRanges[i + 1]; + + if (current.valueTo >= next.valueFrom) { + this.validationErrors.push(`Subranges cannot overlap: ${current.name} and ${next.name}`); + } + } + } + + openPriceForm(subRangeIndex: number) { + // Close any other editing forms + this.subRanges.forEach(sr => sr.isEditing = false); + + const subRange = this.subRanges[subRangeIndex]; + this.editingSubRangeIndex = subRangeIndex; + subRange.isEditing = true; + + // Initialize or reset the form with existing values if editing + const existingComponent = subRange.priceComponent; + + this.priceForm = this.fb.group({ + name: [existingComponent?.name || subRange.name, Validators.required], + price: [existingComponent?.price || '', [Validators.required, Validators.min(0.01)]], + description: [existingComponent?.description || ''], + priceType: [existingComponent?.priceType || 'one time', Validators.required], + recurringPeriod: [existingComponent?.recurringPeriod || 'month'], + usageUnit: [existingComponent?.usageUnit || ''], + usageSpecId: [existingComponent?.usageSpecId || ''], + discountValue: [existingComponent?.discountValue || null, [Validators.min(0), Validators.max(100)]], + discountUnit: [existingComponent?.discountUnit || 'percentage'], + discountDuration: [existingComponent?.discountDuration || null, [Validators.min(1)]], + discountDurationUnit: [existingComponent?.discountDurationUnit || 'days'] + }); + + this.showDiscount = existingComponent?.discountValue != null; + + // If editing and has usage spec, pre-populate + if (existingComponent?.usageSpecId) { + this.selectedUsageSpec = this.usageSpecs.find((element: { id: any; }) => element.id == existingComponent.usageSpecId); + this.selectedMetric = existingComponent.usageUnit; + this.showMetricSelect = true; + } else { + this.selectedUsageSpec = null; + this.selectedMetric = null; + this.showMetricSelect = false; + } + + this.cdr.detectChanges(); + } + + cancelPriceForm() { + if (this.editingSubRangeIndex !== null) { + this.subRanges[this.editingSubRangeIndex].isEditing = false; + this.editingSubRangeIndex = null; + } + this.selectedUsageSpec = null; + this.selectedMetric = null; + this.showMetricSelect = false; + this.cdr.detectChanges(); + } + + changePriceComponentUsageSpec(event: any) { + if(event.target.value == '') { + this.showMetricSelect = false; + return; + } + this.selectedUsageSpec = this.usageSpecs.find((element: { id: any; }) => element.id == event.target.value); + if(this.selectedUsageSpec.specCharacteristic.length > 0) { + this.selectedMetric = this.selectedUsageSpec.specCharacteristic[0].name; + } else { + this.selectedMetric = ''; + } + this.priceForm.patchValue({ + usageUnit: this.selectedMetric, + usageSpecId: this.selectedUsageSpec.id + }); + this.showMetricSelect = true; + } + + changePriceComponentMetric(event: any) { + this.selectedMetric = event.target.value; + this.priceForm.patchValue({ + usageUnit: this.selectedMetric + }); + } + + savePriceForm() { + if (!this.priceForm.valid || this.editingSubRangeIndex === null) { + return; + } + + const subRange = this.subRanges[this.editingSubRangeIndex]; + const formValue = this.priceForm.value; + + // Create price component with correct structure including range characteristic + const priceComponent: any = { + id: subRange.priceComponent?.id || `temp-tier-${Date.now()}-${this.editingSubRangeIndex}`, + name: formValue.name, + price: formValue.price, + description: formValue.description, + priceType: formValue.priceType, + recurringPeriod: formValue.recurringPeriod, + discountValue: this.showDiscount ? formValue.discountValue : null, + discountUnit: this.showDiscount ? formValue.discountUnit : null, + discountDuration: this.showDiscount ? formValue.discountDuration : null, + discountDurationUnit: this.showDiscount ? formValue.discountDurationUnit : null, + selectedCharacteristic: [{ + id: this.selectedCharacteristic.id, + name: this.selectedCharacteristic.name, + description: this.selectedCharacteristic.description || '', + productSpecCharacteristicValue: [{ + valueFrom: subRange.valueFrom, + valueTo: subRange.valueTo, + isDefault: true + }] + }] + }; + + // Add usage fields if priceType is usage + if (formValue.priceType === 'usage') { + priceComponent.usageUnit = formValue.usageUnit; + priceComponent.usageSpecId = formValue.usageSpecId; + } + + // Save the configured price component to the subrange + this.subRanges[this.editingSubRangeIndex].priceComponent = priceComponent; + this.subRanges[this.editingSubRangeIndex].isEditing = false; + + // Re-validate after configuration + this.validateSubRanges(); + this.editingSubRangeIndex = null; + this.selectedUsageSpec = null; + this.selectedMetric = null; + this.showMetricSelect = false; + this.cdr.detectChanges(); + } + + confirmTierPricing() { + this.validateSubRanges(); + + if (this.validationErrors.length > 0) { + return; // Don't proceed if there are validation errors + } + + // Extract all configured price components + const priceComponents = this.subRanges + .filter(sr => sr.priceComponent !== null) + .map(sr => sr.priceComponent); + + // Emit the array of price components to parent + this.saveTierPricing.emit(priceComponents); + + this.closeDrawer(); + } + + closeDrawer() { + this.isOpen = false; + document.body.style.overflow = ''; + setTimeout(() => this.close.emit(), 500); + } + + @HostListener('document:keydown.escape', ['$event']) + handleEscapeKey(event: KeyboardEvent) { + event.stopPropagation(); + this.closeDrawer(); + } + + get canConfirm(): boolean { + return this.validationErrors.length === 0 && + this.subRanges.length >= 2 && + this.subRanges.every(sr => sr.priceComponent !== null); + } + + // TrackBy functions to prevent unnecessary DOM re-rendering + trackByIndex(index: number): number { + return index; + } + + trackBySubRangeId(_index: number, subRange: SubRange): string { + return subRange.id; + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 67389307..4efd7964 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1137,6 +1137,7 @@ "_config_profile": "Configuration Profile", "_price_components": "Price components", "_add_price_component": "Add a new Price Component", + "_add_tier_pricing": "Add Tier Pricing", "_currency": "Set the currency that must be used in all the price components of this price plan", "_profile_desc": "If you want this price plan to apply exclusively to a specific product configuration, select that configuration here.", "_price_comp_with_chars": "If you want this price plan to apply exclusively to a specific product configuration, remove the selected characteristics from the created price components.", diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index 5782d397..ce0ab11b 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -1135,6 +1135,7 @@ "_config_profile": "Configuration Profile", "_price_components": "Price components", "_add_price_component": "Add a new Price Component", + "_add_tier_pricing": "Add Tier Pricing", "_currency": "Set the currency that must be used in all the price components of this price plan", "_profile_desc": "If you want this price plan to apply exclusively to a specific product configuration, select that configuration here.", "_price_comp_with_chars": "If you want this price plan to apply exclusively to a specific product configuration, remove the selected characteristics from the created price components.",