new screens added for Plan, Db connections, and code refactor

This commit is contained in:
Gagan7900
2026-08-03 22:08:55 +05:30
parent 430389c611
commit c7c361cc0b
12937 changed files with 6501 additions and 158003 deletions
@@ -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);
}
});
}
@@ -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);
}
});
}
@@ -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);
}
});
}
@@ -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>
@@ -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';
@@ -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()" />
@@ -0,0 +1,3 @@
:host {
display: block;
}
@@ -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' },
}
];
@@ -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);
}
});
}
@@ -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';
@@ -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);
}
});
}
@@ -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);
}
});
}