new screens added for Plan, Db connections, and code refactor
This commit is contained in:
+7
-7
@@ -12,9 +12,9 @@ import {
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import { CityDto, CityModalMode, CreateCityRequest, UpdateCityRequest } from '../../models/city.model';
|
||||
import { CountryLookupDto } from '../../../countries/models/country.model';
|
||||
@@ -47,7 +47,7 @@ export class CityFormModalComponent {
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<CityModalMode>('create');
|
||||
@@ -152,7 +152,7 @@ export class CityFormModalComponent {
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load city details.');
|
||||
this.notification.error('Unable to load city details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
@@ -182,7 +182,7 @@ export class CityFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('City created successfully.');
|
||||
this.notification.success('City created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -205,7 +205,7 @@ export class CityFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('City updated successfully.');
|
||||
this.notification.success('City updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -221,9 +221,9 @@ export class CityFormModalComponent {
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A city with this code already exists in this state.');
|
||||
this.notification.error('A city with this code already exists in this state.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} city. Please try again.`);
|
||||
this.notification.error(`Unable to ${action} city. Please try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import { CityDto, UpdateCityRequest } from '../../models/city.model';
|
||||
import { CountryLookupDto } from '../../../countries/models/country.model';
|
||||
@@ -67,7 +67,7 @@ export class CityList implements OnInit {
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
readonly tableStore = inject(DataTableStore<CityDto, CityTableRow>);
|
||||
|
||||
readonly selectedCountry = signal<CountryLookupDto | null>(null);
|
||||
@@ -216,7 +216,7 @@ export class CityList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('City deleted successfully.');
|
||||
this.notification.success('City deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -228,7 +228,7 @@ export class CityList implements OnInit {
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
this.notification.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -250,12 +250,12 @@ export class CityList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`City ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.notification.success(`City ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} city.`;
|
||||
this.toastr.error(msg);
|
||||
this.notification.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-7
@@ -12,9 +12,9 @@ import {
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import {
|
||||
CountryDto,
|
||||
@@ -45,7 +45,7 @@ export class CountryFormModalComponent {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<CountryModalMode>('create');
|
||||
@@ -131,7 +131,7 @@ export class CountryFormModalComponent {
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load country details.');
|
||||
this.notification.error('Unable to load country details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
@@ -162,7 +162,7 @@ export class CountryFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Country created successfully.');
|
||||
this.notification.success('Country created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -187,7 +187,7 @@ export class CountryFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Country updated successfully.');
|
||||
this.notification.success('Country updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -203,9 +203,9 @@ export class CountryFormModalComponent {
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A country with this ISO code already exists.');
|
||||
this.notification.error('A country with this ISO code already exists.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} country. Please try again.`);
|
||||
this.notification.error(`Unable to ${action} country. Please try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import { CountryDto, UpdateCountryRequest } from '../../models/country.model';
|
||||
import { CountryService } from '../../data-access/country.service';
|
||||
@@ -41,7 +41,7 @@ interface CountryTableRow extends DataTableRecord {
|
||||
export class CountryList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
readonly tableStore = inject(DataTableStore<CountryDto, CountryTableRow>);
|
||||
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
@@ -114,7 +114,7 @@ export class CountryList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Country deleted successfully.');
|
||||
this.notification.success('Country deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -126,7 +126,7 @@ export class CountryList implements OnInit {
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
this.notification.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -160,12 +160,12 @@ export class CountryList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`Country ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.notification.success(`Country ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} country.`;
|
||||
this.toastr.error(msg);
|
||||
this.notification.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-7
@@ -12,8 +12,8 @@ import {
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import {
|
||||
CreateCurrencyRequest,
|
||||
@@ -37,7 +37,7 @@ export class CurrencyFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<CurrencyModalMode>('create');
|
||||
@@ -110,7 +110,7 @@ export class CurrencyFormModalComponent {
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load currency details.');
|
||||
this.notification.error('Unable to load currency details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
@@ -141,7 +141,7 @@ export class CurrencyFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Currency created successfully.');
|
||||
this.notification.success('Currency created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -166,7 +166,7 @@ export class CurrencyFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Currency updated successfully.');
|
||||
this.notification.success('Currency updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -182,9 +182,9 @@ export class CurrencyFormModalComponent {
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A currency with this ISO code already exists.');
|
||||
this.notification.error('A currency with this ISO code already exists.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} currency. Please try again.`);
|
||||
this.notification.error(`Unable to ${action} currency. Please try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
import {
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
@@ -62,7 +62,7 @@ interface CurrencyTableRow extends DataTableRecord {
|
||||
export class CurrencyList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
readonly tableStore = inject(DataTableStore<CurrencyDto, CurrencyTableRow>);
|
||||
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
@@ -142,7 +142,7 @@ export class CurrencyList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Currency deleted successfully.');
|
||||
this.notification.success('Currency deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -154,7 +154,7 @@ export class CurrencyList implements OnInit {
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
this.notification.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -176,12 +176,12 @@ export class CurrencyList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`Currency ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.notification.success(`Currency ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} currency.`;
|
||||
this.toastr.error(msg);
|
||||
this.notification.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
<modal
|
||||
[open]="open()"
|
||||
[title]="modalTitle()"
|
||||
size="lg"
|
||||
[submitAction]="mode() === 'create' ? 'save' : 'update'"
|
||||
[submitLabel]="mode() === 'create' ? 'Save Rate' : 'Update Rate'"
|
||||
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
|
||||
[loading]="saving() || modalLoading()"
|
||||
[showSubmitButton]="!isViewMode()"
|
||||
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||
(closed)="closeModal()"
|
||||
(submitted)="saveRate()"
|
||||
>
|
||||
@if (modalLoading()) {
|
||||
<div class="flex min-h-32 items-center justify-center">
|
||||
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
|
||||
<span class="ms-2">Loading exchange rate details...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="form" (ngSubmit)="saveRate()" autocomplete="off" class="grid grid-cols-12 gap-4 pt-2">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="fromCurrencyId"
|
||||
inputId="from-currency"
|
||||
variant="floating"
|
||||
label="From Currency"
|
||||
placeholder="Select Base Currency..."
|
||||
[required]="!isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[searchFn]="searchCurrencies"
|
||||
[displayWith]="displayCurrency"
|
||||
[valueWith]="currencyValue"
|
||||
[resolveValueFn]="resolveCurrency"
|
||||
[selectedItem]="selectedFromCurrency()"
|
||||
[minSearchLength]="0"
|
||||
[debounceTime]="300"
|
||||
[limit]="20"
|
||||
[clearable]="!isViewMode()"
|
||||
[readonly]="isViewMode()"
|
||||
[validationMessages]="{ required: 'From currency is required.' }"
|
||||
wrapperClass="w-full"
|
||||
(itemSelected)="onFromCurrencySelected($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="toCurrencyId"
|
||||
inputId="to-currency"
|
||||
variant="floating"
|
||||
label="To Currency"
|
||||
placeholder="Select Target Currency..."
|
||||
[required]="!isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[searchFn]="searchCurrencies"
|
||||
[displayWith]="displayCurrency"
|
||||
[valueWith]="currencyValue"
|
||||
[resolveValueFn]="resolveCurrency"
|
||||
[selectedItem]="selectedToCurrency()"
|
||||
[minSearchLength]="0"
|
||||
[debounceTime]="300"
|
||||
[limit]="20"
|
||||
[clearable]="!isViewMode()"
|
||||
[readonly]="isViewMode()"
|
||||
[validationMessages]="{ required: 'To currency is required.' }"
|
||||
wrapperClass="w-full"
|
||||
(itemSelected)="onToCurrencySelected($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="rate"
|
||||
inputId="rate-input"
|
||||
variant="floating"
|
||||
type="number"
|
||||
label="Exchange Rate"
|
||||
placeholder="e.g. 83.1500"
|
||||
[required]="!isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[step]="0.0001"
|
||||
[min]="0.000001"
|
||||
[readonly]="isViewMode()"
|
||||
[validationMessages]="{ required: 'Exchange rate is required.', min: 'Exchange rate must be greater than 0.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="rateType"
|
||||
inputId="rate-type-input"
|
||||
variant="floating"
|
||||
type="text"
|
||||
label="Rate Type"
|
||||
placeholder="e.g. spot, forward, custom"
|
||||
[required]="!isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[readonly]="isViewMode()"
|
||||
[validationMessages]="{ required: 'Rate type is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="source"
|
||||
inputId="source-input"
|
||||
variant="floating"
|
||||
type="text"
|
||||
label="Source"
|
||||
placeholder="e.g. rbi, manual"
|
||||
[required]="!isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[readonly]="isViewMode()"
|
||||
[validationMessages]="{ required: 'Source is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-date-picker
|
||||
formControlName="effectiveFrom"
|
||||
inputId="effective-from-picker"
|
||||
variant="floating"
|
||||
label="Effective From"
|
||||
placeholder="Select Start Date"
|
||||
[required]="!isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[readonly]="isViewMode()"
|
||||
[validationMessages]="{ required: 'Effective from date is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-date-picker
|
||||
formControlName="effectiveTo"
|
||||
inputId="effective-to-picker"
|
||||
variant="floating"
|
||||
label="Effective To (Optional)"
|
||||
placeholder="Leave empty for open period"
|
||||
[readonly]="isViewMode()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import {
|
||||
CreateExchangeRateRequest,
|
||||
ExchangeRateDto,
|
||||
ExchangeRateModalMode,
|
||||
UpdateExchangeRateRequest
|
||||
} from '../../models/exchange-rate.model';
|
||||
import { ExchangeRateService } from '../../data-access/exchange-rate.service';
|
||||
import { CurrencyService } from '../../../currencies/data-access/currency.service';
|
||||
import { CurrencyLookupDto } from '../../../currencies/models/currency.model';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteResolveValueFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { FormDatePicker } from '../../../../../shared/components/form/form-date-picker/form-date-picker';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exchange-rate-form-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
Modal,
|
||||
ReactiveFormsModule,
|
||||
FormInput,
|
||||
FormDatePicker,
|
||||
Autocomplete
|
||||
],
|
||||
templateUrl: './exchange-rate-form-modal.html',
|
||||
styleUrl: './exchange-rate-form-modal.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ExchangeRateFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly exchangeRateApi = inject(ExchangeRateService);
|
||||
private readonly currencyService = inject(CurrencyService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<ExchangeRateModalMode>('create');
|
||||
readonly exchangeRateId = input<string | null>(null);
|
||||
|
||||
readonly saved = output<void>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly selectedRate = signal<ExchangeRateDto | null>(null);
|
||||
|
||||
readonly selectedFromCurrency = signal<CurrencyLookupDto | null>(null);
|
||||
readonly selectedToCurrency = signal<CurrencyLookupDto | null>(null);
|
||||
|
||||
readonly form = this.formBuilder.nonNullable.group({
|
||||
fromCurrencyId: ['', [Validators.required]],
|
||||
toCurrencyId: ['', [Validators.required]],
|
||||
rate: [0, [Validators.required, Validators.min(0.000001)]],
|
||||
rateType: ['spot', [Validators.required, Validators.maxLength(50)]],
|
||||
source: ['manual', [Validators.required, Validators.maxLength(50)]],
|
||||
effectiveFrom: ['', [Validators.required]],
|
||||
effectiveTo: ['' as string | null]
|
||||
});
|
||||
|
||||
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.mode()) {
|
||||
case 'create': return 'Add Exchange Rate (ROE)';
|
||||
case 'edit': return 'Edit Exchange Rate';
|
||||
case 'view': return 'View Exchange Rate Details';
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.open()) {
|
||||
this.prepareModal(this.exchangeRateId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) => {
|
||||
return this.currencyService.autocomplete(term, limit || 20);
|
||||
};
|
||||
|
||||
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = c => c ? `${c.code} - ${c.name}` : '';
|
||||
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = c => c?.id ?? '';
|
||||
|
||||
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = (id: string) => {
|
||||
if (!id) return of(null);
|
||||
return this.currencyService.getCurrencyById(id).pipe(
|
||||
map(c => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
symbol: c.symbol ?? ''
|
||||
})),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
};
|
||||
|
||||
onFromCurrencySelected(c: CurrencyLookupDto | null): void {
|
||||
if (this.isViewMode()) return;
|
||||
this.selectedFromCurrency.set(c);
|
||||
this.form.patchValue({ fromCurrencyId: c?.id ?? '' });
|
||||
}
|
||||
|
||||
onToCurrencySelected(c: CurrencyLookupDto | null): void {
|
||||
if (this.isViewMode()) return;
|
||||
this.selectedToCurrency.set(c);
|
||||
this.form.patchValue({ toCurrencyId: c?.id ?? '' });
|
||||
}
|
||||
|
||||
prepareModal(id: string | null): void {
|
||||
this.submitAttempted.set(false);
|
||||
this.selectedFromCurrency.set(null);
|
||||
this.selectedToCurrency.set(null);
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
this.form.enable();
|
||||
this.form.reset({
|
||||
fromCurrencyId: '',
|
||||
toCurrencyId: '',
|
||||
rate: 1.0,
|
||||
rateType: 'spot',
|
||||
source: 'manual',
|
||||
effectiveFrom: todayStr,
|
||||
effectiveTo: null
|
||||
});
|
||||
|
||||
if (!id || this.mode() === 'create') {
|
||||
this.selectedRate.set(null);
|
||||
this.modalLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalLoading.set(true);
|
||||
this.exchangeRateApi.getExchangeRateById(id).pipe(
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: rate => {
|
||||
this.selectedRate.set(rate);
|
||||
|
||||
let effFrom = '';
|
||||
if (rate.effectiveFrom) {
|
||||
effFrom = String(rate.effectiveFrom).split(/[T\s]/)[0];
|
||||
}
|
||||
|
||||
let effTo: string | null = null;
|
||||
if (rate.effectiveTo) {
|
||||
effTo = String(rate.effectiveTo).split(/[T\s]/)[0];
|
||||
}
|
||||
|
||||
this.form.patchValue({
|
||||
fromCurrencyId: rate.fromCurrencyId,
|
||||
toCurrencyId: rate.toCurrencyId,
|
||||
rate: rate.rate,
|
||||
rateType: rate.rateType,
|
||||
source: rate.source,
|
||||
effectiveFrom: effFrom,
|
||||
effectiveTo: effTo
|
||||
});
|
||||
|
||||
if (rate.fromCurrencyId) {
|
||||
if (rate.fromCurrencyCode) {
|
||||
this.selectedFromCurrency.set({
|
||||
id: rate.fromCurrencyId,
|
||||
code: rate.fromCurrencyCode,
|
||||
name: rate.fromCurrencyName ?? rate.fromCurrencyCode,
|
||||
symbol: ''
|
||||
});
|
||||
} else {
|
||||
this.currencyService.getCurrencyById(rate.fromCurrencyId).pipe(
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: currency => {
|
||||
if (currency) {
|
||||
this.selectedFromCurrency.set({
|
||||
id: currency.id,
|
||||
code: currency.code,
|
||||
name: currency.name,
|
||||
symbol: currency.symbol ?? ''
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rate.toCurrencyId) {
|
||||
if (rate.toCurrencyCode) {
|
||||
this.selectedToCurrency.set({
|
||||
id: rate.toCurrencyId,
|
||||
code: rate.toCurrencyCode,
|
||||
name: rate.toCurrencyName ?? rate.toCurrencyCode,
|
||||
symbol: ''
|
||||
});
|
||||
} else {
|
||||
this.currencyService.getCurrencyById(rate.toCurrencyId).pipe(
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: currency => {
|
||||
if (currency) {
|
||||
this.selectedToCurrency.set({
|
||||
id: currency.id,
|
||||
code: currency.code,
|
||||
name: currency.name,
|
||||
symbol: currency.symbol ?? ''
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Unable to load exchange rate details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveRate(): void {
|
||||
if (this.isViewMode()) {
|
||||
this.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitAttempted.set(true);
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
|
||||
this.saving.set(true);
|
||||
const formVal = this.form.getRawValue();
|
||||
|
||||
if (this.mode() === 'create') {
|
||||
const request: CreateExchangeRateRequest = {
|
||||
fromCurrencyId: formVal.fromCurrencyId,
|
||||
toCurrencyId: formVal.toCurrencyId,
|
||||
rate: Number(formVal.rate),
|
||||
rateType: formVal.rateType.trim(),
|
||||
source: formVal.source.trim(),
|
||||
effectiveFrom: typeof formVal.effectiveFrom === 'string' ? formVal.effectiveFrom : '',
|
||||
effectiveTo: formVal.effectiveTo ? String(formVal.effectiveTo) : null
|
||||
};
|
||||
|
||||
this.exchangeRateApi.createExchangeRate(request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.notification.success('Exchange Rate created successfully. Open period auto-closed if applicable.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'create')
|
||||
});
|
||||
} else {
|
||||
const id = this.exchangeRateId();
|
||||
if (!id) return;
|
||||
|
||||
const request: UpdateExchangeRateRequest = {
|
||||
fromCurrencyId: formVal.fromCurrencyId,
|
||||
toCurrencyId: formVal.toCurrencyId,
|
||||
rate: Number(formVal.rate),
|
||||
rateType: formVal.rateType.trim(),
|
||||
source: formVal.source.trim(),
|
||||
effectiveFrom: typeof formVal.effectiveFrom === 'string' ? formVal.effectiveFrom : '',
|
||||
effectiveTo: formVal.effectiveTo ? String(formVal.effectiveTo) : null,
|
||||
isActive: this.selectedRate()?.isActive ?? true
|
||||
};
|
||||
|
||||
this.exchangeRateApi.updateExchangeRate(id, request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.notification.success('Exchange Rate updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'update')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
if (this.saving()) return;
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.notification.error('An overlapping exchange rate already exists for this currency pair and effective date range.');
|
||||
return;
|
||||
}
|
||||
this.notification.error(`Unable to ${action} exchange rate. Please try again.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { buildApiUrl } from '../../../../core/config/api-url.util';
|
||||
|
||||
export const EXCHANGE_RATE_ENDPOINTS = {
|
||||
dataTable: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/exchange-rates/datatable'
|
||||
),
|
||||
|
||||
create: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/exchange-rates'
|
||||
),
|
||||
|
||||
getById: (id: string) =>
|
||||
buildApiUrl(
|
||||
'masterAdmin',
|
||||
`/v1/exchange-rates/${encodeURIComponent(id)}`
|
||||
),
|
||||
|
||||
update: (id: string) =>
|
||||
buildApiUrl(
|
||||
'masterAdmin',
|
||||
`/v1/exchange-rates/${encodeURIComponent(id)}`
|
||||
),
|
||||
|
||||
delete: (id: string) =>
|
||||
buildApiUrl(
|
||||
'masterAdmin',
|
||||
`/v1/exchange-rates/${encodeURIComponent(id)}`
|
||||
),
|
||||
|
||||
changeStatus: (id: string) =>
|
||||
buildApiUrl(
|
||||
'masterAdmin',
|
||||
`/v1/exchange-rates/${encodeURIComponent(id)}/status`
|
||||
),
|
||||
|
||||
autocomplete: buildApiUrl(
|
||||
'masterAdmin',
|
||||
'/v1/exchange-rates/autocomplete'
|
||||
),
|
||||
} as const;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { DataTableQuery, DataTableResult } from '../../../../shared/components/data-table/data-table.types';
|
||||
import {
|
||||
CreateExchangeRateRequest,
|
||||
ExchangeRateDto,
|
||||
ExchangeRateLookupDto,
|
||||
UpdateExchangeRateRequest,
|
||||
UpdateExchangeRateStatusRequest
|
||||
} from '../models/exchange-rate.model';
|
||||
import { EXCHANGE_RATE_ENDPOINTS } from './exchange-rate.endpoints';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ExchangeRateService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getExchangeRateDataTable(
|
||||
query: DataTableQuery & Record<string, unknown>
|
||||
): Observable<DataTableResult<ExchangeRateDto>> {
|
||||
return this.http.post<DataTableResult<ExchangeRateDto>>(EXCHANGE_RATE_ENDPOINTS.dataTable, query);
|
||||
}
|
||||
|
||||
createExchangeRate(request: CreateExchangeRateRequest): Observable<ExchangeRateDto> {
|
||||
return this.http.post<ExchangeRateDto>(EXCHANGE_RATE_ENDPOINTS.create, request);
|
||||
}
|
||||
|
||||
updateExchangeRate(id: string, request: UpdateExchangeRateRequest): Observable<ExchangeRateDto> {
|
||||
return this.http.put<ExchangeRateDto>(EXCHANGE_RATE_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
updateStatus(id: string, request: UpdateExchangeRateStatusRequest): Observable<ExchangeRateDto> {
|
||||
return this.http.patch<ExchangeRateDto>(EXCHANGE_RATE_ENDPOINTS.changeStatus(id), request);
|
||||
}
|
||||
|
||||
deleteExchangeRate(id: string): Observable<void> {
|
||||
return this.http.delete<void>(EXCHANGE_RATE_ENDPOINTS.delete(id));
|
||||
}
|
||||
|
||||
getExchangeRateById(id: string): Observable<ExchangeRateDto> {
|
||||
return this.http.get<ExchangeRateDto>(EXCHANGE_RATE_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
autocomplete(term: string | null, limit = 10): Observable<readonly ExchangeRateLookupDto[]> {
|
||||
let params = new HttpParams().set('limit', limit);
|
||||
const normalizedTerm = term?.trim();
|
||||
|
||||
if (normalizedTerm) {
|
||||
params = params.set('term', normalizedTerm);
|
||||
}
|
||||
|
||||
return this.http.get<readonly ExchangeRateLookupDto[]>(EXCHANGE_RATE_ENDPOINTS.autocomplete, { params });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface ExchangeRateDto {
|
||||
id: string;
|
||||
fromCurrencyId: string;
|
||||
fromCurrencyCode?: string;
|
||||
fromCurrencyName?: string;
|
||||
toCurrencyId: string;
|
||||
toCurrencyCode?: string;
|
||||
toCurrencyName?: string;
|
||||
rate: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string | null;
|
||||
rateType: string;
|
||||
source: string;
|
||||
isActive: boolean;
|
||||
status?: string;
|
||||
createdOn?: string;
|
||||
modifiedOn?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateExchangeRateRequest {
|
||||
fromCurrencyId: string;
|
||||
toCurrencyId: string;
|
||||
rate: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string | null;
|
||||
rateType: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface UpdateExchangeRateRequest {
|
||||
fromCurrencyId: string;
|
||||
toCurrencyId: string;
|
||||
rate: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string | null;
|
||||
rateType: string;
|
||||
source: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateExchangeRateStatusRequest {
|
||||
isActive: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ExchangeRateLookupDto {
|
||||
id: string;
|
||||
pair: string;
|
||||
rate: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string | null;
|
||||
}
|
||||
|
||||
export interface ExchangeRateFilterParams {
|
||||
organizationId?: string | null;
|
||||
rateType?: string | null;
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
export type ExchangeRateModalMode = 'create' | 'edit' | 'view';
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
|
||||
[totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
|
||||
[pageSize]="tableStore.queryState.pageSize()" tableTitle="Exchange Rates (ROE)" buttonTitle="Add Exchange Rate"
|
||||
[showSearch]="true" [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()"
|
||||
searchPlaceholder="Search pair..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()"
|
||||
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
|
||||
(sortChanged)="tableStore.onSortChange($event)" (actionClicked)="onActionClick($event)"
|
||||
(filterClicked)="onToggleFilters()">
|
||||
<!-- Built-in Datatable Toolbar Filter Form -->
|
||||
<ng-template appDataTableToolbar>
|
||||
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter()" autocomplete="off"
|
||||
class="flex flex-wrap items-end gap-3 w-full">
|
||||
<div class="w-64 min-w-[200px]">
|
||||
<app-autocomplete formControlName="organizationId" inputId="exchange-rate-org-filter" variant="floating"
|
||||
size="sm" label="Organization" placeholder="Search organization" [searchFn]="searchOrganizations"
|
||||
[displayWith]="displayOrg" [valueWith]="orgValue" [selectedItem]="selectedOrgLookup()" [minSearchLength]="0"
|
||||
[debounceTime]="300" [limit]="20" [clearable]="true" [hideValidation]="true" wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onOrgSelected($event)" />
|
||||
</div>
|
||||
|
||||
<div class="w-64 min-w-[200px]">
|
||||
<app-form-input formControlName="rateType" inputId="exchange-rate-type-filter" variant="floating" type="text"
|
||||
label="Rate Type" placeholder="e.g. spot, forward" [hideValidation]="true" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<app-button action="custom" label="Apply" icon="ti ti-filter" variant="primary-full" type="submit" size="sm"
|
||||
className="!rounded-full shadow-sm !mb-0 min-h-8" />
|
||||
<app-button action="custom" label="Reset" icon="ti ti-refresh" variant="outline-primary" type="button" size="sm"
|
||||
className="!rounded-full shadow-sm !mb-0 min-h-8" (buttonClicked)="onResetFilter()" />
|
||||
</div>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<!-- Custom From Currency Cell -->
|
||||
<ng-template appDataTableCell="fromCurrencyCode" let-row let-value="value">
|
||||
<span class="font-bold text-gray-800 dark:text-gray-200 tracking-wide">
|
||||
{{ getCurrencyDisplay(row.fromCurrencyId, value) }}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- Custom To Currency Cell -->
|
||||
<ng-template appDataTableCell="toCurrencyCode" let-row let-value="value">
|
||||
<span class="font-bold text-gray-800 dark:text-gray-200 tracking-wide">
|
||||
{{ getCurrencyDisplay(row.toCurrencyId, value) }}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- Custom Rate Cell -->
|
||||
<ng-template appDataTableCell="rate" let-row let-value="value">
|
||||
<span class="font-mono font-semibold text-primary dark:text-primary-light">
|
||||
{{ value | number:'1.4-4' }}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- Custom Type Cell (High contrast badge) -->
|
||||
<ng-template appDataTableCell="rateType" let-row let-value="value">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-bold bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light uppercase tracking-wider">
|
||||
{{ value }}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- Custom Source Cell -->
|
||||
<ng-template appDataTableCell="source" let-row let-value="value">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-bold bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200 uppercase tracking-wider">
|
||||
{{ value }}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- Custom Effective From Cell -->
|
||||
<ng-template appDataTableCell="effectiveFrom" let-row let-value="value">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ value ? (value | date:'dd-MM-yyyy') : '-' }}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- Custom Effective To Cell (Soft Green Badge for Open-ended / Current periods) -->
|
||||
<ng-template appDataTableCell="effectiveTo" let-row let-value="value">
|
||||
@if (!value) {
|
||||
<span
|
||||
class="badge bg-success/10 text-success font-semibold px-2.5 py-1 rounded text-xs inline-flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success"></span>
|
||||
Current
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ value | date:'dd-MM-yyyy' }}
|
||||
</span>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!-- Custom Status Cell -->
|
||||
<ng-template appDataTableCell="isActive" let-row let-value="value">
|
||||
@if (value) {
|
||||
<span class="badge bg-success/10 text-success font-semibold px-2.5 py-1 rounded text-xs">Active</span>
|
||||
} @else {
|
||||
<span class="badge bg-danger/10 text-danger font-semibold px-2.5 py-1 rounded text-xs">Closed</span>
|
||||
}
|
||||
</ng-template>
|
||||
</app-data-table>
|
||||
|
||||
|
||||
|
||||
<!-- Close Period Confirm Dialog -->
|
||||
<app-confirm-dialog #closeDialog title="Close Rate Period"
|
||||
text="Are you sure you want to close this exchange rate period? This will set its effective to date to active period end."
|
||||
confirmButtonText="Close Period" cancelButtonText="Cancel" (confirmed)="onCloseConfirmed()" />
|
||||
|
||||
<!-- Delete Confirm Dialog -->
|
||||
<app-confirm-dialog #deleteDialog title="Delete Exchange Rate"
|
||||
text="Do you really want to delete this exchange rate record?" confirmButtonText="Delete" cancelButtonText="Cancel"
|
||||
(confirmed)="onDeleteConfirmed()" />
|
||||
|
||||
<!-- Form Modal (Add / Edit / View) -->
|
||||
<app-exchange-rate-form-modal [open]="tableStore.showModal()" [mode]="tableStore.modalMode()"
|
||||
[exchangeRateId]="tableStore.selectedItem()?.id ?? null" (saved)="tableStore.refresh()"
|
||||
(closed)="tableStore.closeModal()" />
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
import { ExchangeRateDto, ExchangeRateFilterParams } from '../../models/exchange-rate.model';
|
||||
import { ExchangeRateService } from '../../data-access/exchange-rate.service';
|
||||
import { CurrencyService } from '../../../currencies/data-access/currency.service';
|
||||
import { CurrencyLookupDto } from '../../../currencies/models/currency.model';
|
||||
import { OrganizationService } from '../../../../organizations/pages/organization-list/data-access/organization.service';
|
||||
import { OrganizationLookupDto } from '../../../../organizations/pages/organization-list/models/organization.model';
|
||||
|
||||
import { DataTable, DataTableToolbarDirective, DataTableCellDirective } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTableRecord
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Button } from '../../../../../shared/components/button/button';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { ExchangeRateFormModalComponent } from '../../components/exchange-rate-form-modal/exchange-rate-form-modal';
|
||||
|
||||
export interface ExchangeRateTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
fromCurrencyId: string;
|
||||
fromCurrencyCode?: string;
|
||||
fromCurrencyName?: string;
|
||||
toCurrencyId: string;
|
||||
toCurrencyCode?: string;
|
||||
toCurrencyName?: string;
|
||||
rate: number;
|
||||
rateType: string;
|
||||
source: string;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string | null;
|
||||
isActive: boolean;
|
||||
status?: string;
|
||||
createdOn?: string;
|
||||
modifiedOn?: string | null;
|
||||
serialNumber: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-exchange-rate-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
DataTable,
|
||||
DataTableToolbarDirective,
|
||||
DataTableCellDirective,
|
||||
Autocomplete,
|
||||
FormInput,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
ExchangeRateFormModalComponent,
|
||||
DatePipe,
|
||||
DecimalPipe
|
||||
],
|
||||
providers: [DataTableStore],
|
||||
templateUrl: './exchange-rate-list.html',
|
||||
styleUrl: './exchange-rate-list.scss'
|
||||
})
|
||||
export class ExchangeRateList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly exchangeRateApi = inject(ExchangeRateService);
|
||||
private readonly currencyService = inject(CurrencyService);
|
||||
private readonly orgService = inject(OrganizationService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
readonly tableStore = inject(DataTableStore<ExchangeRateDto, ExchangeRateTableRow>);
|
||||
|
||||
readonly closingId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly pendingDeleteRate = signal<ExchangeRateTableRow | null>(null);
|
||||
readonly pendingCloseRate = signal<ExchangeRateTableRow | null>(null);
|
||||
|
||||
readonly deleteConfirmDialog = viewChild('deleteDialog', { read: ConfirmDialog });
|
||||
readonly closeConfirmDialog = viewChild('closeDialog', { read: ConfirmDialog });
|
||||
|
||||
readonly showFilters = signal(false);
|
||||
readonly selectedOrgLookup = signal<OrganizationLookupDto | null>(null);
|
||||
readonly currencyCodeMap = signal<Record<string, string>>({});
|
||||
private readonly pendingCurrencyFetchIds = new Set<string>();
|
||||
|
||||
readonly filterForm = this.formBuilder.group({
|
||||
organizationId: [''],
|
||||
rateType: ['']
|
||||
});
|
||||
|
||||
readonly columns = signal<DataTableColumn<ExchangeRateTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' },
|
||||
{ key: 'fromCurrencyCode', label: 'From', header: 'From', sortable: true, headerAlign: 'center', align: 'center', width:'200px'},
|
||||
{ key: 'toCurrencyCode', label: 'To', header: 'To', sortable: true, headerAlign: 'center', align: 'center', width:'200px'},
|
||||
{ key: 'rate', label: 'Rate', header: 'Rate', sortable: true, headerAlign: 'right', align: 'right' },
|
||||
{ key: 'rateType', label: 'Type', header: 'Type', sortable: true, headerAlign: 'center', align: 'center' },
|
||||
{ key: 'source', label: 'Source', header: 'Source', sortable: true, headerAlign: 'center', align: 'center' },
|
||||
{ key: 'effectiveFrom', label: 'Effective From', header: 'Effective From', sortable: true, headerAlign: 'center', align: 'center'},
|
||||
{ key: 'effectiveTo', label: 'Effective To', header: 'Effective To', sortable: true, headerAlign: 'center', align: 'center' },
|
||||
{ key: 'isActive', label: 'Status', header: 'Status', sortable: true, headerAlign: 'center', align: 'center' }
|
||||
]);
|
||||
|
||||
readonly actions = signal<DataTableAction<ExchangeRateTableRow>[]>([
|
||||
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ti ti-trash',
|
||||
className: 'text-danger',
|
||||
disabled: row => this.closingId() === row.id || this.deletingId() === row.id
|
||||
}
|
||||
]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCurrencyLookup();
|
||||
|
||||
this.tableStore.initialize({
|
||||
fetcher: query => {
|
||||
const orgId = this.filterForm.controls.organizationId.value;
|
||||
const rType = this.filterForm.controls.rateType.value;
|
||||
const fullQuery = {
|
||||
...query,
|
||||
...(orgId ? { organizationId: orgId } : {}),
|
||||
...(rType ? { rateType: rType } : {})
|
||||
};
|
||||
return this.exchangeRateApi.getExchangeRateDataTable(fullQuery);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadCurrencyLookup(): void {
|
||||
this.currencyService.autocomplete('', 100).pipe(
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: (list: readonly CurrencyLookupDto[]) => {
|
||||
const map: Record<string, string> = { ...this.currencyCodeMap() };
|
||||
for (const c of list) {
|
||||
if (c.id && c.code) {
|
||||
map[c.id] = c.code;
|
||||
}
|
||||
}
|
||||
this.currencyCodeMap.set(map);
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
getCurrencyDisplay(id: string, explicitCode?: string): string {
|
||||
if (explicitCode) return explicitCode;
|
||||
if (!id) return '-';
|
||||
|
||||
const map = this.currencyCodeMap();
|
||||
if (map[id]) return map[id];
|
||||
|
||||
if (!this.pendingCurrencyFetchIds.has(id)) {
|
||||
this.pendingCurrencyFetchIds.add(id);
|
||||
this.currencyService.getCurrencyById(id).pipe(
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: currency => {
|
||||
if (currency?.id && currency?.code) {
|
||||
this.currencyCodeMap.update(m => ({ ...m, [currency.id]: currency.code }));
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
return id ? id.substring(0, 8) + '...' : '-';
|
||||
}
|
||||
|
||||
readonly searchOrganizations: AutocompleteSearchFn<OrganizationLookupDto> = (term, limit) => {
|
||||
return this.orgService.autocomplete(term, limit || 20);
|
||||
};
|
||||
|
||||
readonly displayOrg: AutocompleteDisplayFn<OrganizationLookupDto> = org => org?.name ?? '';
|
||||
readonly orgValue: AutocompleteValueFn<OrganizationLookupDto, string> = org => org?.id ?? '';
|
||||
|
||||
onOrgSelected(org: OrganizationLookupDto | null): void {
|
||||
this.selectedOrgLookup.set(org);
|
||||
this.filterForm.patchValue({ organizationId: org?.id ?? '' });
|
||||
}
|
||||
|
||||
onToggleFilters(): void {
|
||||
this.showFilters.update(v => !v);
|
||||
}
|
||||
|
||||
onApplyFilter(): void {
|
||||
this.tableStore.refresh();
|
||||
}
|
||||
|
||||
onResetFilter(): void {
|
||||
this.selectedOrgLookup.set(null);
|
||||
this.filterForm.reset({
|
||||
organizationId: '',
|
||||
rateType: ''
|
||||
});
|
||||
this.tableStore.refresh();
|
||||
}
|
||||
|
||||
onAddRate(): void {
|
||||
this.tableStore.openCreateModal();
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<ExchangeRateTableRow>): void {
|
||||
if (event.action.type === 'view') this.tableStore.openViewModal(event.row as ExchangeRateDto);
|
||||
if (event.action.type === 'edit') this.tableStore.openEditModal(event.row as ExchangeRateDto);
|
||||
if (event.action.type === 'close') this.requestClosePeriod(event.row);
|
||||
if (event.action.type === 'delete') this.requestDeleteRate(event.row);
|
||||
}
|
||||
|
||||
requestClosePeriod(row: ExchangeRateTableRow): void {
|
||||
this.pendingCloseRate.set(row);
|
||||
this.closeConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
onCloseConfirmed(): void {
|
||||
const rate = this.pendingCloseRate();
|
||||
if (!rate) return;
|
||||
this.pendingCloseRate.set(null);
|
||||
this.closingId.set(rate.id);
|
||||
|
||||
this.exchangeRateApi.updateStatus(rate.id, { isActive: false, status: 'Closed' }).pipe(
|
||||
finalize(() => this.closingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Exchange rate period closed successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Unable to close exchange rate period.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
requestDeleteRate(row: ExchangeRateTableRow): void {
|
||||
this.pendingDeleteRate.set(row);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
onDeleteConfirmed(): void {
|
||||
const rate = this.pendingDeleteRate();
|
||||
if (!rate) return;
|
||||
this.pendingDeleteRate.set(null);
|
||||
this.deletingId.set(rate.id);
|
||||
|
||||
this.exchangeRateApi.deleteExchangeRate(rate.id).pipe(
|
||||
finalize(() => this.deletingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Exchange rate deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Unable to delete exchange rate.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './models/exchange-rate.model';
|
||||
export * from './data-access/exchange-rate.service';
|
||||
export * from './pages/exchange-rate-list/exchange-rate-list';
|
||||
@@ -1,34 +1,53 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { superAdminGuard } from '../../core/guards/auth/super-admin.guard';
|
||||
|
||||
export const globalMastersRoutes: Routes = [
|
||||
{
|
||||
path: 'countries',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./countries/pages/country-list/country-list').then((m) => m.CountryList),
|
||||
data: { childTitle: 'Country Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path: 'states',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./states/pages/state-list/state-list').then((m) => m.StateList),
|
||||
data: { childTitle: 'State Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path: 'cities',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./cities/pages/city-list/city-list').then((m) => m.CityList),
|
||||
data: { childTitle: 'City Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path: 'currencies',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./currencies/pages/currency-list/currency-list').then((m) => m.CurrencyList),
|
||||
data: { childTitle: 'Currency Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path: 'exchange-rates',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./exchange-rates/pages/exchange-rate-list/exchange-rate-list').then((m) => m.ExchangeRateList),
|
||||
data: { childTitle: 'Exchange Rates (ROE)', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path: 'languages',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./languages/pages/language-list/language-list').then((m) => m.LanguageList),
|
||||
data: { childTitle: 'Language Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path: 'timezones',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./timezones/pages/timezone-list/timezone-list').then((m) => m.TimezoneList),
|
||||
data: { childTitle: 'Timezone Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path: 'plans',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./plans/pages/plan-list/plan-list').then((m) => m.PlanList),
|
||||
data: { childTitle: 'Plan Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
|
||||
}
|
||||
];
|
||||
|
||||
+7
-8
@@ -12,8 +12,8 @@ import {
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import {
|
||||
CreateLanguageRequest,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from '../../models/language.model';
|
||||
import { LanguageService } from '../../data-access/language.service';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { FormCheckbox } from '../../../../../shared/components/form/form-checkbox/form-checkbox';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
@@ -37,7 +36,7 @@ export class LanguageFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly languageApi = inject(LanguageService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<LanguageModalMode>('create');
|
||||
@@ -100,7 +99,7 @@ export class LanguageFormModalComponent {
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load language details.');
|
||||
this.notification.error('Unable to load language details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
@@ -130,7 +129,7 @@ export class LanguageFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Language created successfully.');
|
||||
this.notification.success('Language created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -154,7 +153,7 @@ export class LanguageFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Language updated successfully.');
|
||||
this.notification.success('Language updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -170,9 +169,9 @@ export class LanguageFormModalComponent {
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A language with this code already exists.');
|
||||
this.notification.error('A language with this code already exists.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} language. Please try again.`);
|
||||
this.notification.error(`Unable to ${action} language. Please try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import { LanguageDto, UpdateLanguageRequest } from '../../models/language.model';
|
||||
import { LanguageService } from '../../data-access/language.service';
|
||||
@@ -39,7 +39,7 @@ interface LanguageTableRow extends DataTableRecord {
|
||||
export class LanguageList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly languageApi = inject(LanguageService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
readonly tableStore = inject(DataTableStore<LanguageDto, LanguageTableRow>);
|
||||
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
@@ -109,7 +109,7 @@ export class LanguageList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Language deleted successfully.');
|
||||
this.notification.success('Language deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -121,7 +121,7 @@ export class LanguageList implements OnInit {
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
this.notification.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -143,12 +143,12 @@ export class LanguageList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`Language ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.notification.success(`Language ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} language.`;
|
||||
this.toastr.error(msg);
|
||||
this.notification.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
<modal
|
||||
[open]="open()"
|
||||
[title]="modalTitle()"
|
||||
size="md"
|
||||
[submitAction]="mode() === 'create' ? 'save' : 'update'"
|
||||
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
|
||||
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
|
||||
[loading]="saving() || modalLoading()"
|
||||
[showSubmitButton]="!isViewMode()"
|
||||
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||
(closed)="closeModal()"
|
||||
(submitted)="savePlan()"
|
||||
>
|
||||
@if (modalLoading()) {
|
||||
<div class="flex min-h-32 items-center justify-center">
|
||||
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
|
||||
<span class="ms-2">Loading plan...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="planForm" (ngSubmit)="savePlan()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="name"
|
||||
inputId="plan-name"
|
||||
variant="floating"
|
||||
label="Plan Name"
|
||||
placeholder="Name"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="150"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Plan name is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="code"
|
||||
inputId="plan-code"
|
||||
variant="floating"
|
||||
label="Plan Code"
|
||||
placeholder="Code"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="32"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Plan code is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="price"
|
||||
inputId="plan-price"
|
||||
variant="floating"
|
||||
label="Price"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
placeholder="Price"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="0"
|
||||
[step]="0.01"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Price is required.', min: 'Price must be 0 or greater.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="currencyId"
|
||||
inputId="plan-currency-id"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="Currency"
|
||||
placeholder="Select currency"
|
||||
[readonly]="isViewMode()"
|
||||
[minSearchLength]="0"
|
||||
[clearable]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[searchFn]="searchCurrencies"
|
||||
[valueWith]="currencyValue"
|
||||
[displayWith]="currencyDisplay"
|
||||
[selectedItem]="selectedCurrency()"
|
||||
(itemSelected)="selectedCurrency.set($event)"
|
||||
(cleared)="selectedCurrency.set(null)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-select
|
||||
formControlName="billingCycle"
|
||||
inputId="plan-billing-cycle"
|
||||
variant="floating"
|
||||
label="Billing Cycle"
|
||||
placeholder="Select billing cycle"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[clearable]="false"
|
||||
[options]="billingCycleOptions()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Billing cycle is required.' }"
|
||||
/>
|
||||
</div>
|
||||
@if (mode() === 'edit') {
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-select
|
||||
formControlName="isActive"
|
||||
inputId="plan-is-active"
|
||||
variant="floating"
|
||||
label="Active Status"
|
||||
placeholder="Select active status"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[clearable]="false"
|
||||
[options]="activeStatusOptions()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Active status is required.' }"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="maxCompanies"
|
||||
inputId="plan-max-companies"
|
||||
variant="floating"
|
||||
label="Max Companies"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Max Companies"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="1"
|
||||
[step]="1"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Max companies is required.', min: 'Max companies must be at least 1.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="maxUsers"
|
||||
inputId="plan-max-users"
|
||||
variant="floating"
|
||||
label="Max Users"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Max Users"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="1"
|
||||
[step]="1"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Max users is required.', min: 'Max users must be at least 1.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="maxStorageGb"
|
||||
inputId="plan-max-storage-gb"
|
||||
variant="floating"
|
||||
label="Max Storage (GB)"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Max Storage (GB)"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="1"
|
||||
[step]="1"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Max storage is required.', min: 'Max storage must be at least 1 GB.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="defaultTrialDays"
|
||||
inputId="plan-default-trial-days"
|
||||
variant="floating"
|
||||
label="Default Trial Days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Default Trial Days"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="0"
|
||||
[step]="1"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Default trial days is required.', min: 'Default trial days must be 0 or greater.' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import {
|
||||
BillingCycle,
|
||||
CreatePlanRequest,
|
||||
PlanDto,
|
||||
PlanModalMode,
|
||||
UpdatePlanRequest
|
||||
} from '../../models/plan.model';
|
||||
import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api';
|
||||
import { PlanService } from '../../data-access/plan.service';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
||||
import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-plan-form-modal',
|
||||
standalone: true,
|
||||
imports: [Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete],
|
||||
templateUrl: './plan-form-modal.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PlanFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly planApi = inject(PlanService);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<PlanModalMode>('create');
|
||||
readonly planId = input<string | null>(null);
|
||||
|
||||
readonly saved = output<void>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly selectedPlan = signal<PlanDto | null>(null);
|
||||
readonly selectedCurrency = signal<CurrencyLookupDto | null>(null);
|
||||
|
||||
readonly planForm = this.formBuilder.group({
|
||||
name: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]),
|
||||
code: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(32), Validators.pattern(/^[A-Za-z0-9_-]+$/)]),
|
||||
price: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(0)]),
|
||||
currencyId: this.formBuilder.control<string | null>(null),
|
||||
billingCycle: this.formBuilder.control<BillingCycle>(BillingCycle.Monthly, [Validators.required]),
|
||||
maxCompanies: this.formBuilder.control<number | null>(1, [Validators.required, Validators.min(1)]),
|
||||
maxUsers: this.formBuilder.control<number | null>(1, [Validators.required, Validators.min(1)]),
|
||||
maxStorageGb: this.formBuilder.control<number | null>(1, [Validators.required, Validators.min(1)]),
|
||||
defaultTrialDays: this.formBuilder.control<number | null>(14, [Validators.required, Validators.min(0)]),
|
||||
isActive: this.formBuilder.control<number>(1, [Validators.required])
|
||||
});
|
||||
|
||||
readonly billingCycleOptions = signal<FormSelectOption<BillingCycle>[]>([
|
||||
{ value: BillingCycle.Monthly, label: 'Monthly' },
|
||||
{ value: BillingCycle.Quarterly, label: 'Quarterly' },
|
||||
{ value: BillingCycle.Annual, label: 'Annual' }
|
||||
]);
|
||||
|
||||
readonly activeStatusOptions = signal<FormSelectOption<number>[]>([
|
||||
{ value: 1, label: 'Active' },
|
||||
{ value: 0, label: 'Inactive' }
|
||||
]);
|
||||
|
||||
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.mode()) {
|
||||
case 'create': return 'Add Plan';
|
||||
case 'edit': return 'Edit Plan';
|
||||
case 'view': return 'View Plan';
|
||||
}
|
||||
});
|
||||
|
||||
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
|
||||
this.currencyApi.autocomplete(term, limit).pipe(catchError(() => of([])));
|
||||
readonly currencyDisplay: AutocompleteDisplayFn<CurrencyLookupDto> = currency => `${currency.code} - ${currency.name}`;
|
||||
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.open()) {
|
||||
this.prepareModal(this.planId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prepareModal(id: string | null): void {
|
||||
this.submitAttempted.set(false);
|
||||
this.planForm.reset({
|
||||
name: '', code: '', price: null, currencyId: null, billingCycle: BillingCycle.Monthly,
|
||||
maxCompanies: 1, maxUsers: 1, maxStorageGb: 1, defaultTrialDays: 14, isActive: 1
|
||||
});
|
||||
this.selectedCurrency.set(null);
|
||||
|
||||
if (!id || this.mode() === 'create') {
|
||||
this.selectedPlan.set(null);
|
||||
this.modalLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalLoading.set(true);
|
||||
this.planApi.getPlanById(id).pipe(
|
||||
switchMap(plan => plan.currencyId
|
||||
? this.currencyApi.getCurrencyById(plan.currencyId).pipe(
|
||||
map(currency => ({ plan, currency })),
|
||||
catchError(() => of({ plan, currency: null }))
|
||||
)
|
||||
: of({ plan, currency: null })),
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: ({ plan, currency }) => {
|
||||
this.selectedPlan.set(plan);
|
||||
|
||||
if (currency) {
|
||||
this.selectedCurrency.set({
|
||||
id: currency.id,
|
||||
code: currency.code,
|
||||
name: currency.name,
|
||||
symbol: currency.symbol
|
||||
});
|
||||
}
|
||||
|
||||
this.planForm.patchValue({
|
||||
name: plan.name,
|
||||
code: plan.code,
|
||||
price: plan.price,
|
||||
currencyId: plan.currencyId,
|
||||
billingCycle: plan.billingCycle,
|
||||
maxCompanies: plan.maxCompanies,
|
||||
maxUsers: plan.maxUsers,
|
||||
maxStorageGb: plan.maxStorageGb,
|
||||
defaultTrialDays: plan.defaultTrialDays,
|
||||
isActive: plan.isActive ? 1 : 0
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Unable to load plan details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
savePlan(): void {
|
||||
if (this.isViewMode()) {
|
||||
this.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitAttempted.set(true);
|
||||
if (this.planForm.invalid || this.saving()) return;
|
||||
|
||||
const val = this.planForm.getRawValue();
|
||||
this.saving.set(true);
|
||||
|
||||
if (this.mode() === 'create') {
|
||||
const request: CreatePlanRequest = {
|
||||
name: val.name.trim(),
|
||||
code: val.code.trim().toUpperCase(),
|
||||
price: val.price!,
|
||||
currencyId: val.currencyId || null,
|
||||
billingCycle: val.billingCycle ?? BillingCycle.Monthly,
|
||||
maxCompanies: val.maxCompanies!,
|
||||
maxUsers: val.maxUsers!,
|
||||
maxStorageGb: val.maxStorageGb!,
|
||||
defaultTrialDays: val.defaultTrialDays!
|
||||
};
|
||||
|
||||
this.planApi.createPlan(request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Plan created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'create')
|
||||
});
|
||||
} else {
|
||||
const id = this.planId();
|
||||
if (!id) return;
|
||||
|
||||
const request: UpdatePlanRequest = {
|
||||
name: val.name.trim(),
|
||||
code: val.code.trim().toUpperCase(),
|
||||
price: val.price!,
|
||||
currencyId: val.currencyId || null,
|
||||
billingCycle: val.billingCycle ?? BillingCycle.Monthly,
|
||||
maxCompanies: val.maxCompanies!,
|
||||
maxUsers: val.maxUsers!,
|
||||
maxStorageGb: val.maxStorageGb!,
|
||||
defaultTrialDays: val.defaultTrialDays!,
|
||||
isActive: val.isActive === 1
|
||||
};
|
||||
|
||||
this.planApi.updatePlan(id, request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Plan updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'update')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
if (this.saving()) return;
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.notification.error('A plan with this code already exists.');
|
||||
return;
|
||||
}
|
||||
this.notification.error(`Unable to ${action} plan. Please try again.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { buildApiUrl } from '../../../../core/config/api-url.util';
|
||||
|
||||
export const PLAN_ENDPOINTS = {
|
||||
dataTable: buildApiUrl('masterAdmin', '/v1/plans/datatable'),
|
||||
create: buildApiUrl('masterAdmin', '/v1/plans'),
|
||||
getById: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}`),
|
||||
update: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}`),
|
||||
delete: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}`),
|
||||
changeStatus: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}/status`),
|
||||
autocomplete: buildApiUrl('masterAdmin', '/v1/plans/autocomplete')
|
||||
} as const;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { DataTableQuery, DataTableResult } from '../../../../shared/components/data-table/data-table.types';
|
||||
import {
|
||||
CreatePlanRequest,
|
||||
PlanDto,
|
||||
PlanLookupDto,
|
||||
UpdatePlanRequest,
|
||||
UpdatePlanStatusRequest
|
||||
} from '../models/plan.model';
|
||||
import { PLAN_ENDPOINTS } from './plan.endpoints';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getPlanDataTable(query: DataTableQuery): Observable<DataTableResult<PlanDto>> {
|
||||
return this.http.post<DataTableResult<PlanDto>>(PLAN_ENDPOINTS.dataTable, query);
|
||||
}
|
||||
|
||||
createPlan(request: CreatePlanRequest): Observable<PlanDto> {
|
||||
return this.http.post<PlanDto>(PLAN_ENDPOINTS.create, request);
|
||||
}
|
||||
|
||||
updatePlan(id: string, request: UpdatePlanRequest): Observable<PlanDto> {
|
||||
return this.http.put<PlanDto>(PLAN_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
updateStatus(id: string, request: UpdatePlanStatusRequest): Observable<PlanDto> {
|
||||
return this.http.patch<PlanDto>(PLAN_ENDPOINTS.changeStatus(id), request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(PLAN_ENDPOINTS.delete(id));
|
||||
}
|
||||
|
||||
getPlanById(id: string): Observable<PlanDto> {
|
||||
return this.http.get<PlanDto>(PLAN_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
autocomplete(term: string | null, limit = 10): Observable<readonly PlanLookupDto[]> {
|
||||
let params = new HttpParams().set('limit', limit);
|
||||
const normalizedTerm = term?.trim();
|
||||
|
||||
if (normalizedTerm) {
|
||||
params = params.set('term', normalizedTerm);
|
||||
}
|
||||
|
||||
return this.http.get<readonly PlanLookupDto[]>(PLAN_ENDPOINTS.autocomplete, { params });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { DataTableRecord } from '../../../../shared/components/data-table/data-table.types';
|
||||
|
||||
export enum BillingCycle {
|
||||
Monthly = 0,
|
||||
Quarterly = 1,
|
||||
Annual = 2
|
||||
}
|
||||
|
||||
export interface PlanDto {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
price: number;
|
||||
currencyId: string | null;
|
||||
billingCycle: BillingCycle;
|
||||
maxCompanies: number;
|
||||
maxUsers: number;
|
||||
maxStorageGb: number;
|
||||
defaultTrialDays: number;
|
||||
isActive: boolean;
|
||||
createdOn?: string;
|
||||
modifiedOn?: string | null;
|
||||
}
|
||||
|
||||
export interface PlanLookupDto {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
export interface CreatePlanRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
price: number;
|
||||
currencyId: string | null;
|
||||
billingCycle: BillingCycle;
|
||||
maxCompanies: number;
|
||||
maxUsers: number;
|
||||
maxStorageGb: number;
|
||||
defaultTrialDays: number;
|
||||
}
|
||||
|
||||
export interface UpdatePlanRequest extends CreatePlanRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePlanStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type PlanModalMode = 'create' | 'edit' | 'view';
|
||||
|
||||
export interface PlanTableRow extends DataTableRecord {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly price: number;
|
||||
readonly currencyId: string | null;
|
||||
readonly billingCycle: BillingCycle;
|
||||
readonly maxCompanies: number;
|
||||
readonly maxUsers: number;
|
||||
readonly maxStorageGb: number;
|
||||
readonly defaultTrialDays: number;
|
||||
readonly isActive: boolean;
|
||||
readonly serialNumber: number;
|
||||
readonly createdOn?: string;
|
||||
readonly modifiedOn?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="tableStore.rows()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="tableStore.totalRecords()"
|
||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||
[pageSize]="tableStore.queryState.pageSize()"
|
||||
tableTitle="Plans"
|
||||
buttonTitle="Add"
|
||||
[showSearch]="true"
|
||||
[showAddButton]="true"
|
||||
searchPlaceholder="Search plans..."
|
||||
[searchDebounceTime]="300"
|
||||
toolTip="Add Plan"
|
||||
(addClicked)="onAddPlan()"
|
||||
(searchChanged)="tableStore.onSearch($event)"
|
||||
(pageChanged)="tableStore.onPageChange($event)"
|
||||
(sortChanged)="tableStore.onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
/>
|
||||
|
||||
<app-confirm-dialog
|
||||
title="Delete Plan"
|
||||
text="Do you really want to delete this plan?"
|
||||
confirmButtonText="Delete"
|
||||
cancelButtonText="Cancel"
|
||||
(confirmed)="onDeleteConfirmed()"
|
||||
(cancelled)="onDeleteCancelled()"
|
||||
/>
|
||||
|
||||
<app-plan-form-modal
|
||||
[open]="tableStore.showModal()"
|
||||
[mode]="tableStore.modalMode()"
|
||||
[planId]="tableStore.selectedItem()?.id ?? null"
|
||||
(saved)="tableStore.refresh()"
|
||||
(closed)="tableStore.closeModal()"
|
||||
/>
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import { BillingCycle, PlanDto, PlanTableRow } from '../../models/plan.model';
|
||||
import { PlanService } from '../../data-access/plan.service';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { PlanFormModalComponent } from '../../components/plan-form-modal/plan-form-modal';
|
||||
|
||||
const BILLING_CYCLE_LABELS: Record<BillingCycle, string> = {
|
||||
[BillingCycle.Monthly]: 'Monthly',
|
||||
[BillingCycle.Quarterly]: 'Quarterly',
|
||||
[BillingCycle.Annual]: 'Annual'
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'plan-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, ConfirmDialog, PlanFormModalComponent],
|
||||
providers: [DataTableStore],
|
||||
templateUrl: './plan-list.html',
|
||||
styleUrl: './plan-list.scss'
|
||||
})
|
||||
export class PlanList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly planApi = inject(PlanService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
readonly tableStore = inject(DataTableStore<PlanDto, PlanTableRow>);
|
||||
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly pendingDeletePlan = signal<PlanTableRow | null>(null);
|
||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||
|
||||
readonly columns = signal<DataTableColumn<PlanTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, badgeClass: () => 'badge bg-primary/10 text-primary' },
|
||||
{
|
||||
key: 'price', label: 'Price', header: 'Price', sortable: true, headerAlign: 'center', align: 'right',
|
||||
formatter: value => Number(value).toFixed(2)
|
||||
},
|
||||
{
|
||||
key: 'billingCycle', label: 'Billing Cycle', header: 'Billing Cycle', sortable: true, headerAlign: 'center', align: 'center',
|
||||
formatter: value => BILLING_CYCLE_LABELS[value as BillingCycle] ?? '—'
|
||||
},
|
||||
{ key: 'maxCompanies', label: 'Max Companies', header: 'Max Companies', sortable: true, headerAlign: 'center', align: 'center' },
|
||||
{ key: 'maxUsers', label: 'Max Users', header: 'Max Users', sortable: true, headerAlign: 'center', align: 'center' },
|
||||
{ key: 'maxStorageGb', label: 'Max Storage (GB)', header: 'Max Storage (GB)', sortable: true, headerAlign: 'center', align: 'center' },
|
||||
{ key: 'defaultTrialDays', label: 'Trial Days', header: 'Trial Days', sortable: true, headerAlign: 'center', align: 'center' },
|
||||
{
|
||||
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
|
||||
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
|
||||
formatter: value => value ? 'Active' : 'Inactive'
|
||||
}
|
||||
]);
|
||||
|
||||
readonly actions = signal<DataTableAction<PlanTableRow>[]>([
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning',
|
||||
visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
},
|
||||
{
|
||||
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
|
||||
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
},
|
||||
{
|
||||
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
}
|
||||
]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.tableStore.initialize({
|
||||
fetcher: query => this.planApi.getPlanDataTable(query),
|
||||
mapRow: (plan, serialNumber) => ({
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
code: plan.code,
|
||||
price: plan.price,
|
||||
currencyId: plan.currencyId,
|
||||
billingCycle: plan.billingCycle,
|
||||
maxCompanies: plan.maxCompanies,
|
||||
maxUsers: plan.maxUsers,
|
||||
maxStorageGb: plan.maxStorageGb,
|
||||
defaultTrialDays: plan.defaultTrialDays,
|
||||
isActive: plan.isActive,
|
||||
serialNumber,
|
||||
createdOn: plan.createdOn,
|
||||
modifiedOn: plan.modifiedOn
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
onAddPlan(): void {
|
||||
this.tableStore.openCreateModal();
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<PlanTableRow>): void {
|
||||
if (event.action.type === 'view') this.tableStore.openViewModal(event.row);
|
||||
if (event.action.type === 'edit') this.tableStore.openEditModal(event.row);
|
||||
if (event.action.type === 'delete') this.requestDeletePlan(event.row);
|
||||
if (event.action.type === 'activate') this.changePlanStatus(event.row, true);
|
||||
if (event.action.type === 'deactivate') this.changePlanStatus(event.row, false);
|
||||
}
|
||||
|
||||
onDeleteConfirmed(): void {
|
||||
const plan = this.pendingDeletePlan();
|
||||
if (!plan) return;
|
||||
this.pendingDeletePlan.set(null);
|
||||
this.deletingId.set(plan.id);
|
||||
|
||||
this.planApi.delete(plan.id).pipe(
|
||||
finalize(() => this.deletingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Plan deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
let errorMsg = 'Unable to delete plan.';
|
||||
if (err?.status === 409) {
|
||||
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete plan because it has active subscriptions.';
|
||||
} else if (err?.status === 404) {
|
||||
errorMsg = err?.error?.message || 'Plan not found or has already been deleted.';
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.notification.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeletePlan.set(null);
|
||||
}
|
||||
|
||||
private requestDeletePlan(plan: PlanTableRow): void {
|
||||
this.pendingDeletePlan.set(plan);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
private changePlanStatus(plan: PlanTableRow, activate: boolean): void {
|
||||
this.statusChangingId.set(plan.id);
|
||||
|
||||
this.planApi.updateStatus(plan.id, { isActive: activate }).pipe(
|
||||
finalize(() => this.statusChangingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.notification.success(`Plan ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} plan.`;
|
||||
this.notification.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { PlanService } from './data-access/plan.service';
|
||||
export { PlanFormModalComponent } from './components/plan-form-modal/plan-form-modal';
|
||||
export { BillingCycle } from './models/plan.model';
|
||||
export type { PlanDto, PlanLookupDto } from './models/plan.model';
|
||||
+7
-7
@@ -12,9 +12,9 @@ import {
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
||||
import {
|
||||
@@ -45,7 +45,7 @@ export class StateFormModalComponent {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<StateModalMode>('create');
|
||||
@@ -123,7 +123,7 @@ export class StateFormModalComponent {
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load state details.');
|
||||
this.notification.error('Unable to load state details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
@@ -152,7 +152,7 @@ export class StateFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('State created successfully.');
|
||||
this.notification.success('State created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -175,7 +175,7 @@ export class StateFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('State updated successfully.');
|
||||
this.notification.success('State updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -191,9 +191,9 @@ export class StateFormModalComponent {
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A state with this code already exists in this country.');
|
||||
this.notification.error('A state with this code already exists in this country.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} state. Please try again.`);
|
||||
this.notification.error(`Unable to ${action} state. Please try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
||||
import { StateDto, UpdateStateRequest } from '../../models/state.model';
|
||||
@@ -59,7 +59,7 @@ export class StateList implements OnInit {
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
readonly tableStore = inject(DataTableStore<StateDto, StateTableRow>);
|
||||
|
||||
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
||||
@@ -161,7 +161,7 @@ export class StateList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('State deleted successfully.');
|
||||
this.notification.success('State deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -173,7 +173,7 @@ export class StateList implements OnInit {
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
this.notification.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -195,12 +195,12 @@ export class StateList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`State ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.notification.success(`State ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} state.`;
|
||||
this.toastr.error(msg);
|
||||
this.notification.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-7
@@ -12,8 +12,8 @@ import {
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import {
|
||||
CreateTimezoneRequest,
|
||||
@@ -36,7 +36,7 @@ export class TimezoneFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<TimezoneModalMode>('create');
|
||||
@@ -104,7 +104,7 @@ export class TimezoneFormModalComponent {
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load timezone details.');
|
||||
this.notification.error('Unable to load timezone details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
@@ -133,7 +133,7 @@ export class TimezoneFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Timezone created successfully.');
|
||||
this.notification.success('Timezone created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -156,7 +156,7 @@ export class TimezoneFormModalComponent {
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Timezone updated successfully.');
|
||||
this.notification.success('Timezone updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
@@ -183,9 +183,9 @@ export class TimezoneFormModalComponent {
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A timezone with this IANA ID already exists.');
|
||||
this.notification.error('A timezone with this IANA ID already exists.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} timezone. Please try again.`);
|
||||
this.notification.error(`Unable to ${action} timezone. Please try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||
|
||||
import { TimezoneDto, UpdateTimezoneRequest } from '../../models/timezone.model';
|
||||
import { TimezoneService } from '../../data-access/timezone.service';
|
||||
@@ -51,7 +51,7 @@ interface TimezoneTableRow extends DataTableRecord {
|
||||
export class TimezoneList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
readonly tableStore = inject(DataTableStore<TimezoneDto, TimezoneTableRow>);
|
||||
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
@@ -137,7 +137,7 @@ export class TimezoneList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Timezone deleted successfully.');
|
||||
this.notification.success('Timezone deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -149,7 +149,7 @@ export class TimezoneList implements OnInit {
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
this.notification.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -171,12 +171,12 @@ export class TimezoneList implements OnInit {
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`Timezone ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.notification.success(`Timezone ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} timezone.`;
|
||||
this.toastr.error(msg);
|
||||
this.notification.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user