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')){
+
+
+
+
+ }
}
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) {
+
+ {{ 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 @@
+
+
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.",