@if (getFlagUrl(row.iso2); as flagUrl) {
@@ -18,85 +18,79 @@
-
-
+
+
-
-
\ No newline at end of file
diff --git a/src/app/features/global-masters/countries/pages/country-list/country-list.ts b/src/app/features/global-masters/countries/pages/country-list/country-list.ts
index dfff1bc3..a46ddcf1 100644
--- a/src/app/features/global-masters/countries/pages/country-list/country-list.ts
+++ b/src/app/features/global-masters/countries/pages/country-list/country-list.ts
@@ -1,84 +1,87 @@
-import { Component, ElementRef, viewChild } from '@angular/core';
-import { inject, signal, computed } from '@angular/core';
-import type { CountryModalMode } from '../../../../../core/models/country/country.model';
-import { CountryService } from '../../../../../core/services/country/country.service';
-import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
-import { DataTablePageEvent, DataTableSortEvent, DataTableQuery, DataTableColumn, DataTableAction, DataTableActionEvent } from '../../../../../shared/components/data-table/data-table.types';
-import { DataTable } from '../../../../../shared/components/data-table/data-table';
-import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
-import { finalize } from 'rxjs/operators';
-import { Modal } from '../../../../../shared/components/modal/modal';
+import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
ReactiveFormsModule,
Validators
} from '@angular/forms';
+import { ToastrService } from 'ngx-toastr';
+import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs';
-import { Button } from '../../../../../shared/components/button/button';
+import {
+ CountryDto,
+ CountryModalMode,
+ CreateCountryRequest,
+ UpdateCountryRequest
+} from '../../models/country.model';
+import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api';
+import { CountryService } from '../../data-access/country.service';
+import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
+import {
+ DataTableAction,
+ DataTableActionEvent,
+ DataTableColumn,
+ DataTablePageEvent,
+ DataTableQuery,
+ DataTableRecord,
+ DataTableSortEvent
+} from '../../../../../shared/components/data-table/data-table.types';
+import { DataTable } from '../../../../../shared/components/data-table/data-table';
+import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
+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 { CountryDto } from '../../../../../core/models/country/country.model';
-
+import { Modal } from '../../../../../shared/components/modal/modal';
+import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
+interface CountryTableRow extends DataTableRecord {
+ id: string;
+ iso2: string;
+ iso3: string;
+ name: string;
+ phoneCode: string | null;
+ defaultCurrencyId: string | null;
+ isActive: boolean;
+ serialNumber: number;
+ createdOn?: string;
+ modifiedOn?: string | null;
+}
@Component({
selector: 'country-list',
standalone: true,
- imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, Button,
- FormInput],
+ imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog],
templateUrl: './country-list.html',
styleUrl: './country-list.scss',
})
-
-
export class CountryList {
-
- private readonly countryApi: CountryService = inject(CountryService);
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly countryApi = inject(CountryService);
+ private readonly currencyApi = inject(CurrencyService);
private readonly formBuilder = inject(FormBuilder);
+ private readonly elementRef = inject
>(ElementRef);
+ private readonly toastr = inject(ToastrService);
+ private readonly countryQueryRequests$ = new Subject();
readonly queryState = new DataTableQueryState();
- readonly countries = signal([]);
+ readonly countries = signal([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
- readonly loading = signal(false);
- readonly modalMode = signal('create');
+ readonly saving = signal(false);
readonly showCountryModal = signal(false);
readonly countryModalMode = signal('create');
readonly selectedCountryId = signal(null);
- readonly saving = signal(false);
-
- readonly countryModalTitle = computed(() =>
- this.countryModalMode() === 'create'
- ? 'Add Country'
- : 'Edit Country'
- );
-
- readonly countryModalSubtitle = computed(() =>
- this.countryModalMode() === 'create'
- ? 'Enter the country details below.'
- : 'Update the country details below.'
- );
-
- readonly countrySubmitLabel = computed(() =>
- this.countryModalMode() === 'create'
- ? 'Save Country'
- : 'Update Country'
- );
-
- readonly countryLoadingLabel = computed(() =>
- this.countryModalMode() === 'create'
- ? 'Saving Country...'
- : 'Updating Country...'
- );
-
- readonly countrySubmitAction = computed<'save' | 'update'>(() =>
- this.countryModalMode() === 'create'
- ? 'save'
- : 'update'
- );
-
-
+ readonly selectedCountry = signal(null);
+ readonly selectedCurrency = signal(null);
+ readonly countrySubmitAttempted = signal(false);
+ readonly pendingDeleteCountry = signal(null);
+ readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly countryForm = this.formBuilder.nonNullable.group({
name: [
@@ -88,7 +91,6 @@ export class CountryList {
Validators.maxLength(150)
]
],
-
iso2: [
'',
[
@@ -96,7 +98,6 @@ export class CountryList {
Validators.pattern(/^[A-Za-z]{2}$/)
]
],
-
iso3: [
'',
[
@@ -104,106 +105,137 @@ export class CountryList {
Validators.pattern(/^[A-Za-z]{3}$/)
]
],
-
phoneCode: [
'',
[
- Validators.maxLength(20),
- Validators.pattern(/^\+?[0-9]*$/)
+ Validators.maxLength(16),
+ Validators.pattern(/^\+?[0-9\- ]{1,15}$/)
]
],
- currency: [
- '',
- [
- Validators.maxLength(3),
- Validators.pattern(/^[A-Za-z]{3}$/)
- ]
- ],
-
-
- defaultCurrencyId:
- this.formBuilder.control(null)
+ defaultCurrencyId: this.formBuilder.control(null)
});
+ readonly searchCurrencies: AutocompleteSearchFn =
+ (term, limit) => this.currencyApi.autocomplete(term, limit);
+ readonly displayCurrency: AutocompleteDisplayFn = currency => {
+ const baseLabel = [currency.code, currency.name].filter(Boolean).join(' - ');
- readonly columns = signal([
- { key: 'serialNumber', label: 'Sr.No.', header: 'Sr.No.', sortable: false, width: '100px' },
+ return currency.symbol?.trim()
+ ? `${baseLabel} (${currency.symbol})`
+ : baseLabel;
+ };
+ readonly currencyValue: AutocompleteValueFn = currency => currency.id;
+
+ readonly countryModalTitle = computed(() =>
+ this.countryModalMode() === 'create'
+ ? 'Add Country'
+ : 'Edit Country'
+ );
+
+ readonly countrySubmitLabel = computed(() =>
+ this.countryModalMode() === 'create'
+ ? 'Save'
+ : 'Update'
+ );
+
+ readonly countryLoadingLabel = computed(() =>
+ this.countryModalMode() === 'create'
+ ? 'Saving...'
+ : 'Updating...'
+ );
+
+ readonly countrySubmitAction = computed<'save' | 'update'>(() =>
+ this.countryModalMode() === 'create'
+ ? 'save'
+ : 'update'
+ );
+
+ readonly columns = signal[]>([
+ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
{ key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true },
+ { key: 'currencyName', label: 'Default Currency', header: 'Default Currency', sortable: true, align: 'left' },
{
- key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, badgeClass: value =>
+ 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'
+ formatter: value => value ? 'Active' : 'Inactive'
}
]);
- readonly actions = signal([
+ readonly actions = signal[]>([
{
type: 'edit',
label: 'Edit',
- icon: 'ti ti-edit ti-btn-info',
- className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-info me-2'
+ icon: 'ti ti-edit',
+ className: 'text-primary'
},
{
type: 'delete',
label: 'Delete',
- icon: 'ti ti-trash ti-btn-danger',
- className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-danger me-2',
- visible: (row: any) => row.isActive
+ icon: 'ti ti-trash',
+ className: 'text-danger',
+ visible: row => row.isActive
},
{
type: 'activate',
label: 'Activate',
- icon: 'ti ti-check ti-btn-success',
- className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-success me-2',
- visible: (row: any) => !row.isActive
- },
+ icon: 'ti ti-check',
+ className: 'text-success',
+ visible: row => !row.isActive
+ }
]);
+ constructor() {
+ this.countryQueryRequests$
+ .pipe(
+ switchMap(query =>
+ this.countryApi.getCountryDataTable(query).pipe(
+ catchError(() => {
+ this.toastr.error('Unable to load countries.');
+ this.clearCountryGrid();
+ return of(null);
+ })
+ )
+ ),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe(response => {
+ if (!response) {
+ return;
+ }
+
+ const query = this.queryState.getQuery();
+
+ if (response.draw !== query.draw) {
+ return;
+ }
+
+ const countriesWithSerialNumbers: CountryTableRow[] = response.rows.map((country, index) => ({
+ ...country,
+ serialNumber: (query.page - 1) * query.pageSize + index + 1
+ }));
+
+ this.countries.set(countriesWithSerialNumbers);
+ this.totalRecords.set(response.total);
+ this.filteredRecords.set(response.filtered);
+ });
+ }
+
ngOnInit(): void {
this.loadCountries(this.queryState.getQuery());
}
-
loadCountries(query: DataTableQuery): void {
- this.loading.set(true);
-
- this.countryApi
- .getCountryDataTable(query)
- .pipe(
- finalize(() => {
- this.loading.set(false);
- })
- )
- .subscribe({
- next: (response: any) => {
- console.log('Country data loaded:', response);
- if (response.draw !== this.queryState.getQuery().draw) {
- return;
- }
-
- // Add serial numbers to countries
- const countriesWithSerialNumbers = response.rows.map((country: any, index: number) => ({
- ...country,
- serialNumber: (query.page - 1) * query.pageSize + index + 1
- }));
-
- this.countries.set(countriesWithSerialNumbers);
- this.totalRecords.set(response.total);
- this.filteredRecords.set(response.filtered);
- },
- error: (error: any) => {
- console.error('Unable to load countries.', error);
-
- this.countries.set([]);
- this.totalRecords.set(0);
- this.filteredRecords.set(0);
- }
- });
+ this.countryQueryRequests$.next(query);
}
onSearch(value: string): void {
@@ -224,12 +256,10 @@ export class CountryList {
onRefresh(): void {
const currentQuery = this.queryState.getQuery();
- const query: DataTableQuery = {
+ this.loadCountries({
...currentQuery,
draw: currentQuery.draw + 1
- };
-
- this.loadCountries(query);
+ });
}
onReset(): void {
@@ -237,11 +267,25 @@ export class CountryList {
this.loadCountries(query);
}
- onActionClick(event: DataTableActionEvent): void {
- const action = event.action.type;
- const country = event.row;
+ onDeleteConfirmed(): void {
+ const country = this.pendingDeleteCountry();
- switch (action) {
+ if (!country) {
+ return;
+ }
+
+ this.pendingDeleteCountry.set(null);
+ this.deleteCountry(country);
+ }
+
+ onDeleteCancelled(): void {
+ this.pendingDeleteCountry.set(null);
+ }
+
+ onActionClick(event: DataTableActionEvent): void {
+ const country = this.toCountryDto(event.row);
+
+ switch (event.action.type) {
case 'view':
this.viewCountry(country);
break;
@@ -249,7 +293,7 @@ export class CountryList {
this.openEditCountry(country);
break;
case 'delete':
- this.deleteCountry(country);
+ this.requestDeleteCountry(country);
break;
case 'activate':
this.activateCountry(country);
@@ -260,6 +304,9 @@ export class CountryList {
onAddCountry(): void {
this.countryModalMode.set('create');
this.selectedCountryId.set(null);
+ this.selectedCountry.set(null);
+ this.selectedCurrency.set(null);
+ this.countrySubmitAttempted.set(false);
this.countryForm.reset({
name: '',
@@ -268,6 +315,7 @@ export class CountryList {
phoneCode: '',
defaultCurrencyId: null
});
+ this.resetCountryFormState();
this.showCountryModal.set(true);
}
@@ -279,52 +327,102 @@ export class CountryList {
this.showCountryModal.set(false);
this.selectedCountryId.set(null);
+ this.selectedCountry.set(null);
+ this.selectedCurrency.set(null);
+ this.countrySubmitAttempted.set(false);
}
+
saveCountry(): void {
if (this.countryForm.invalid) {
+ this.countrySubmitAttempted.set(true);
this.countryForm.markAllAsTouched();
+ this.focusFirstInvalidCountryControl();
+ return;
+ }
+
+ if (this.saving()) {
return;
}
this.saving.set(true);
- const request = this.countryForm.getRawValue();
+ if (this.countryModalMode() === 'create') {
+ this.countryApi
+ .createCountry(this.buildCreateCountryRequest())
+ .pipe(finalize(() => this.saving.set(false)))
+ .subscribe({
+ next: () => {
+ this.toastr.success('Country saved successfully.');
+ this.finishCountrySave();
+ }
+ });
- // Replace with the actual API request.
- console.log(request);
+ return;
+ }
- this.saving.set(false);
- this.showCountryModal.set(false);
- }
+ const countryId = this.selectedCountryId();
- private viewCountry(country: any): void {
- console.log('Viewing country:', country);
- // TODO: Implement view logic (open modal, navigate to details page, etc.)
+ if (!countryId) {
+ this.saving.set(false);
+ return;
+ }
+
+ this.countryApi
+ .updateCountry(
+ countryId,
+ this.buildUpdateCountryRequest(this.selectedCountry()?.isActive ?? true)
+ )
+ .pipe(finalize(() => this.saving.set(false)))
+ .subscribe({
+ next: () => {
+ this.toastr.success('Country updated successfully.');
+ this.finishCountrySave();
+ }
+ });
}
openEditCountry(country: CountryDto): void {
this.countryModalMode.set('edit');
this.selectedCountryId.set(country.id);
+ this.selectedCountry.set(null);
+ this.selectedCurrency.set(null);
+ this.countrySubmitAttempted.set(false);
- this.countryForm.reset({
- name: country.name ?? '',
- iso2: country.iso2 ?? '',
- iso3: country.iso3 ?? '',
- phoneCode: country.phoneCode ?? '',
- defaultCurrencyId: country.defaultCurrencyId ?? null
- });
+ this.countryApi
+ .getCountryById(country.id)
+ .pipe(
+ switchMap(countryDetails => {
+ const currencyId = countryDetails.defaultCurrencyId;
- this.showCountryModal.set(true);
- }
+ if (!currencyId) {
+ return of({ countryDetails, currency: null });
+ }
- private deleteCountry(country: any): void {
- console.log('Deleting country:', country);
- // TODO: Implement delete logic (API call to delete country)
- }
+ return this.currencyApi.getCurrencyById(currencyId).pipe(
+ map(currency => ({ countryDetails, currency })),
+ catchError(() => {
+ this.toastr.error('Unable to load the selected currency.');
+ return of({ countryDetails, currency: null });
+ })
+ );
+ })
+ )
+ .subscribe({
+ next: ({ countryDetails, currency }) => {
+ this.selectedCountry.set(countryDetails);
+ this.selectedCurrency.set(currency);
+ this.countryForm.reset({
+ name: countryDetails.name ?? '',
+ iso2: countryDetails.iso2 ?? '',
+ iso3: countryDetails.iso3 ?? '',
+ phoneCode: countryDetails.phoneCode ?? '',
+ defaultCurrencyId: countryDetails.defaultCurrencyId ?? null
+ });
+ this.resetCountryFormState();
- private activateCountry(country: any): void {
- console.log('Activating country:', country);
- // TODO: Implement activate logic (API call to activate country)
+ this.showCountryModal.set(true);
+ }
+ });
}
getFlagUrl(iso2: string | null | undefined): string {
@@ -340,4 +438,122 @@ export class CountryList {
image.style.display = 'none';
}
+ private viewCountry(country: CountryDto): void {
+ this.openEditCountry(country);
+ }
+
+ private requestDeleteCountry(country: CountryDto): void {
+ this.pendingDeleteCountry.set(country);
+ this.deleteConfirmDialog()?.open();
+ }
+
+ private deleteCountry(country: CountryDto): void {
+ this.updateCountryStatus(country, false);
+ }
+
+ private activateCountry(country: CountryDto): void {
+ this.updateCountryStatus(country, true);
+ }
+
+ private buildCreateCountryRequest(): CreateCountryRequest {
+ const value = this.countryForm.getRawValue();
+
+ return {
+ name: value.name.trim(),
+ iso2: value.iso2.trim().toUpperCase(),
+ iso3: value.iso3.trim().toUpperCase(),
+ phoneCode: this.nullWhenBlank(value.phoneCode),
+ defaultCurrencyId: this.nullWhenBlank(value.defaultCurrencyId)
+ };
+ }
+
+ private buildUpdateCountryRequest(isActive: boolean): UpdateCountryRequest {
+ return {
+ ...this.buildCreateCountryRequest(),
+ isActive
+ };
+ }
+
+ private countryToUpdateRequest(country: CountryDto, isActive: boolean): UpdateCountryRequest {
+ return {
+ name: country.name?.trim() ?? '',
+ iso2: country.iso2?.trim().toUpperCase() ?? '',
+ iso3: country.iso3?.trim().toUpperCase() ?? '',
+ phoneCode: this.nullWhenBlank(country.phoneCode),
+ defaultCurrencyId: this.nullWhenBlank(country.defaultCurrencyId),
+ isActive
+ };
+ }
+
+ private updateCountryStatus(country: CountryDto, isActive: boolean): void {
+ this.countryApi
+ .updateCountry(country.id, this.countryToUpdateRequest(country, isActive))
+ .subscribe({
+ next: () => {
+ this.toastr.success(
+ isActive
+ ? 'Country activated successfully.'
+ : 'Country deactivated successfully.'
+ );
+ this.loadCountries(this.queryState.getQuery());
+ }
+ });
+ }
+
+ private resetCountryFormState(): void {
+ this.countryForm.markAsPristine();
+ this.countryForm.markAsUntouched();
+ this.countryForm.updateValueAndValidity();
+ }
+
+ private finishCountrySave(): void {
+ this.showCountryModal.set(false);
+ this.selectedCountryId.set(null);
+ this.selectedCountry.set(null);
+ this.countrySubmitAttempted.set(false);
+ this.loadCountries(this.queryState.getQuery());
+ }
+
+ private clearCountryGrid(): void {
+ this.countries.set([]);
+ this.totalRecords.set(0);
+ this.filteredRecords.set(0);
+ }
+
+ private focusFirstInvalidCountryControl(): void {
+ queueMicrotask(() => {
+ const firstInvalidControl =
+ this.elementRef.nativeElement.querySelector(
+ 'modal [data-form-control][aria-invalid="true"]'
+ );
+
+ firstInvalidControl?.focus();
+ firstInvalidControl?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center'
+ });
+ });
+ }
+
+ private nullWhenBlank(value: string | null | undefined): string | null {
+ const normalized = value?.trim();
+
+ return normalized
+ ? normalized
+ : null;
+ }
+
+ private toCountryDto(row: CountryTableRow): CountryDto {
+ return {
+ id: row.id,
+ iso2: row.iso2,
+ iso3: row.iso3,
+ name: row.name,
+ phoneCode: row.phoneCode,
+ defaultCurrencyId: row.defaultCurrencyId,
+ isActive: row.isActive,
+ createdOn: row.createdOn,
+ modifiedOn: row.modifiedOn
+ };
+ }
}
diff --git a/src/app/features/global-masters/countries/public-api.ts b/src/app/features/global-masters/countries/public-api.ts
new file mode 100644
index 00000000..158a7781
--- /dev/null
+++ b/src/app/features/global-masters/countries/public-api.ts
@@ -0,0 +1,2 @@
+export { CountryService } from './data-access/country.service';
+export type { CountryLookupDto } from './models/country.model';
diff --git a/src/app/features/global-masters/currencies/data-access/currency.endpoints.ts b/src/app/features/global-masters/currencies/data-access/currency.endpoints.ts
new file mode 100644
index 00000000..9eb91c37
--- /dev/null
+++ b/src/app/features/global-masters/currencies/data-access/currency.endpoints.ts
@@ -0,0 +1,44 @@
+import { buildApiUrl } from '../../../../core/config/api-url.util';
+
+
+export const CURRENCY_ENDPOINTS = {
+ dataTable: buildApiUrl(
+ 'masterAdmin',
+ '/v1/currencies/datatable'
+ ),
+
+ create: buildApiUrl(
+ 'masterAdmin',
+ '/v1/currencies'
+ ),
+
+ getById: (id: string) =>
+ buildApiUrl(
+ 'masterAdmin',
+ `/v1/currencies/${encodeURIComponent(id)}`
+ ),
+
+ update: (id: string) =>
+ buildApiUrl(
+ 'masterAdmin',
+ `/v1/currencies/${encodeURIComponent(id)}`
+ ),
+
+ delete: (id: string) =>
+ buildApiUrl(
+ 'masterAdmin',
+ `/v1/currencies/${encodeURIComponent(id)}`
+ ),
+
+ changeStatus: (id: string) =>
+ buildApiUrl(
+ 'masterAdmin',
+ `/v1/currencies/${encodeURIComponent(id)}/status`
+ ),
+
+ autocomplete:
+ buildApiUrl(
+ 'masterAdmin',
+ '/v1/currencies/autocomplete'
+ ),
+} as const;
diff --git a/src/app/features/global-masters/currencies/data-access/currency.service.ts b/src/app/features/global-masters/currencies/data-access/currency.service.ts
new file mode 100644
index 00000000..cebb5a1c
--- /dev/null
+++ b/src/app/features/global-masters/currencies/data-access/currency.service.ts
@@ -0,0 +1,46 @@
+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 {
+ CreateCurrencyRequest,
+ CurrencyDto,
+ CurrencyLookupDto,
+ UpdateCurrencyRequest
+} from '../models/currency.model';
+import { CURRENCY_ENDPOINTS } from './currency.endpoints';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CurrencyService {
+ private readonly http = inject(HttpClient);
+
+ getCurrencyDataTable(query: DataTableQuery): Observable> {
+ return this.http.post>(CURRENCY_ENDPOINTS.dataTable, query);
+ }
+
+ createCurrency(request: CreateCurrencyRequest): Observable {
+ return this.http.post(CURRENCY_ENDPOINTS.create, request);
+ }
+
+ updateCurrency(id: string, request: UpdateCurrencyRequest): Observable {
+ return this.http.put(CURRENCY_ENDPOINTS.update(id), request);
+ }
+
+ getCurrencyById(id: string): Observable {
+ return this.http.get(CURRENCY_ENDPOINTS.getById(id));
+ }
+
+ autocomplete(term: string | null, limit = 10): Observable {
+ let params = new HttpParams().set('limit', limit);
+ const normalizedTerm = term?.trim();
+
+ if (normalizedTerm) {
+ params = params.set('term', normalizedTerm);
+ }
+
+ return this.http.get(CURRENCY_ENDPOINTS.autocomplete, { params });
+ }
+}
diff --git a/src/app/features/global-masters/currencies/models/currency.model.ts b/src/app/features/global-masters/currencies/models/currency.model.ts
new file mode 100644
index 00000000..8ea67172
--- /dev/null
+++ b/src/app/features/global-masters/currencies/models/currency.model.ts
@@ -0,0 +1,43 @@
+export interface CurrencyCountryFlag {
+ readonly iso2: string;
+}
+
+export type CurrencyIso2Value =
+ | string
+ | readonly (string | CurrencyCountryFlag)[]
+ | null;
+
+export interface CurrencyDto {
+ id: string;
+ code: string;
+ iso2: CurrencyIso2Value;
+ name: string;
+ symbol: string;
+ numericCode: number;
+ decimalDigits: number;
+ isActive: boolean;
+ createdOn?: string;
+ modifiedOn?: string | null;
+}
+
+
+export interface CurrencyLookupDto {
+ readonly id: string;
+ readonly code: string;
+ readonly name: string;
+ readonly symbol: string;
+}
+
+export interface CreateCurrencyRequest {
+ code: string;
+ name: string;
+ symbol: string;
+ numericCode: number;
+ decimalDigits: number;
+}
+
+export interface UpdateCurrencyRequest extends CreateCurrencyRequest {
+ isActive: boolean;
+}
+
+export type CurrencyModalMode = 'create' | 'edit';
diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html
new file mode 100644
index 00000000..27f32995
--- /dev/null
+++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html
@@ -0,0 +1,146 @@
+
+
+ @if (visibleCountries(row); as countries) {
+ @if (countries.length > 0) {
+
+ @for (country of countries; track country.iso2) {
+
+
+ @if (getFlagUrl(country.iso2); as flagUrl) {
+
+
+ } @else {
+
+ }
+
+ {{ country.iso2 }}
+
+ }
+
+ @if (remainingCountryCount(row); as remaining) {
+
+
+
+
+ @if (iso2TooltipPlacement() === 'right') {
+
+ } @else {
+
+
+ }
+
+
+
+
+
+
+
+
+ @for (country of remainingCountries(row); track country.iso2) {
+
+
+ @if (getFlagUrl(country.iso2); as flagUrl) {
+
+
+ } @else {
+
+ }
+
+
{{ country.iso2 }}
+
+ }
+
+
+
+
+
+ }
+
+ } @else {
+ —
+ }
+ }
+
+
+
+
+ {{ value }}
+
+
+
+
+
+
+
+
diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.scss b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts
new file mode 100644
index 00000000..cfeeb807
--- /dev/null
+++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts
@@ -0,0 +1,623 @@
+import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+ CdkConnectedOverlay,
+ CdkOverlayOrigin,
+ ConnectedOverlayPositionChange,
+ ConnectedPosition
+} from '@angular/cdk/overlay';
+import {
+ FormBuilder,
+ ReactiveFormsModule,
+ Validators
+} from '@angular/forms';
+import { ToastrService } from 'ngx-toastr';
+import { Subject, catchError, finalize, of, switchMap } from 'rxjs';
+
+import {
+ CreateCurrencyRequest,
+ CurrencyCountryFlag,
+ CurrencyDto,
+ CurrencyIso2Value,
+ CurrencyModalMode,
+ UpdateCurrencyRequest
+} from '../../models/currency.model';
+import { CurrencyService } from '../../data-access/currency.service';
+import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
+import {
+ DataTableAction,
+ DataTableActionEvent,
+ DataTableColumn,
+ DataTablePageEvent,
+ DataTableQuery,
+ DataTableRecord,
+ DataTableSortEvent
+} from '../../../../../shared/components/data-table/data-table.types';
+import { DataTable } from '../../../../../shared/components/data-table/data-table';
+import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
+import { Modal } from '../../../../../shared/components/modal/modal';
+import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
+import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
+
+type Iso2TooltipPlacement = 'above' | 'below' | 'left' | 'right';
+interface CurrencyTableRow extends DataTableRecord {
+ id: string;
+ code: string;
+ iso2: CurrencyIso2Value;
+ name: string;
+ symbol: string;
+ numericCode: number;
+ decimalDigits: number;
+ isActive: boolean;
+ serialNumber: number;
+ createdOn?: string;
+ modifiedOn?: string | null;
+}
+
+@Component({
+ selector: 'currency-list',
+ standalone: true,
+ imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog, CdkOverlayOrigin, CdkConnectedOverlay],
+ templateUrl: './currency-list.html',
+ styleUrl: './currency-list.scss',
+})
+export class CurrencyList {
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly currencyApi = inject(CurrencyService);
+ private readonly formBuilder = inject(FormBuilder);
+ private readonly elementRef = inject>(ElementRef);
+ private readonly toastr = inject(ToastrService);
+ private readonly currencyQueryRequests$ = new Subject();
+
+ readonly queryState = new DataTableQueryState();
+
+ readonly currencies = signal([]);
+ readonly totalRecords = signal(0);
+ readonly filteredRecords = signal(0);
+ readonly saving = signal(false);
+
+ readonly showCurrencyModal = signal(false);
+ readonly currencyModalMode = signal('create');
+ readonly selectedCurrencyId = signal(null);
+ readonly selectedCurrency = signal(null);
+ readonly currencySubmitAttempted = signal(false);
+ readonly pendingDeleteCurrency = signal(null);
+ readonly deleteConfirmDialog = viewChild(ConfirmDialog);
+ readonly openIso2TooltipCurrencyId = signal(null);
+ readonly iso2TooltipPlacement = signal('right');
+ readonly iso2TooltipPositions: ConnectedPosition[] = [
+ {
+ originX: 'end',
+ originY: 'center',
+ overlayX: 'start',
+ overlayY: 'center',
+ offsetX: 12
+ }
+ ];
+ private iso2TooltipCloseTimer: ReturnType | null = null;
+
+ readonly currencyForm = this.formBuilder.nonNullable.group({
+ name: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(100)
+ ]
+ ],
+ code: [
+ '',
+ [
+ Validators.required,
+ Validators.minLength(3),
+ Validators.maxLength(3),
+ Validators.pattern(/^[A-Za-z]{3}$/)
+ ]
+ ],
+ symbol: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(8)
+ ]
+ ],
+ numericCode: [
+ 0,
+ [
+ Validators.required,
+ Validators.min(1),
+ Validators.max(999)
+ ]
+ ],
+ decimalDigits: [
+ 2,
+ [
+ Validators.required,
+ Validators.min(0),
+ Validators.max(4)
+ ]
+ ]
+ });
+
+ readonly currencyModalTitle = computed(() =>
+ this.currencyModalMode() === 'create'
+ ? 'Add Currency'
+ : 'Edit Currency'
+ );
+
+ readonly currencySubmitLabel = computed(() =>
+ this.currencyModalMode() === 'create'
+ ? 'Save'
+ : 'Update'
+ );
+
+ readonly currencyLoadingLabel = computed(() =>
+ this.currencyModalMode() === 'create'
+ ? 'Saving...'
+ : 'Updating...'
+ );
+
+ readonly currencySubmitAction = computed<'save' | 'update'>(() =>
+ this.currencyModalMode() === 'create'
+ ? 'save'
+ : 'update'
+ );
+
+ readonly columns = signal[]>([
+ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
+ { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
+ { key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true , align: 'left'},
+ { key: 'code', label: 'Code', header: 'Code', sortable: true },
+ { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true },
+ { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true },
+ { key: 'decimalDigits', label: 'Decimal Digits', header: 'Decimal Digits', sortable: true },
+ {
+ 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[]>([
+ {
+ type: 'edit',
+ label: 'Edit',
+ icon: 'ti ti-edit',
+ className: 'text-primary'
+ },
+ {
+ type: 'delete',
+ label: 'Delete',
+ icon: 'ti ti-trash',
+ className: 'text-danger',
+ visible: row => row.isActive
+ },
+ {
+ type: 'activate',
+ label: 'Activate',
+ icon: 'ti ti-check',
+ className: 'text-success',
+ visible: row => !row.isActive
+ }
+ ]);
+
+ constructor() {
+ this.currencyQueryRequests$
+ .pipe(
+ switchMap(query =>
+ this.currencyApi.getCurrencyDataTable(query).pipe(
+ catchError(() => {
+ this.toastr.error('Unable to load currencies.');
+ this.clearCurrencyGrid();
+ return of(null);
+ })
+ )
+ ),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe(response => {
+ if (!response) {
+ return;
+ }
+
+ const query = this.queryState.getQuery();
+
+ if (response.draw !== query.draw) {
+ return;
+ }
+
+ const currenciesWithSerialNumbers: CurrencyTableRow[] = response.rows.map((currency, index) => ({
+ ...currency,
+ serialNumber: (query.page - 1) * query.pageSize + index + 1
+ }));
+
+ this.currencies.set(currenciesWithSerialNumbers);
+ this.totalRecords.set(response.total);
+ this.filteredRecords.set(response.filtered);
+ });
+ }
+
+ ngOnInit(): void {
+ this.loadCurrencies(this.queryState.getQuery());
+ }
+
+ loadCurrencies(query: DataTableQuery): void {
+ this.currencyQueryRequests$.next(query);
+ }
+
+ onSearch(value: string): void {
+ const query = this.queryState.setSearch(value.trim());
+ this.loadCurrencies(query);
+ }
+
+ onPageChange(event: DataTablePageEvent): void {
+ const query = this.queryState.setPage(event);
+ this.loadCurrencies(query);
+ }
+
+ onSortChange(event: DataTableSortEvent): void {
+ const query = this.queryState.setSort(event);
+ this.loadCurrencies(query);
+ }
+
+ onRefresh(): void {
+ const currentQuery = this.queryState.getQuery();
+
+ this.loadCurrencies({
+ ...currentQuery,
+ draw: currentQuery.draw + 1
+ });
+ }
+
+ onReset(): void {
+ const query = this.queryState.reset();
+ this.loadCurrencies(query);
+ }
+
+ onDeleteConfirmed(): void {
+ const currency = this.pendingDeleteCurrency();
+
+ if (!currency) {
+ return;
+ }
+
+ this.pendingDeleteCurrency.set(null);
+ this.deleteCurrency(currency);
+ }
+
+ onDeleteCancelled(): void {
+ this.pendingDeleteCurrency.set(null);
+ }
+
+ onActionClick(event: DataTableActionEvent): void {
+ const currency = this.toCurrencyDto(event.row);
+
+ switch (event.action.type) {
+ case 'view':
+ this.viewCurrency(currency);
+ break;
+ case 'edit':
+ this.openEditCurrency(currency);
+ break;
+ case 'delete':
+ this.requestDeleteCurrency(currency);
+ break;
+ case 'activate':
+ this.activateCurrency(currency);
+ break;
+ }
+ }
+
+ onAddCurrency(): void {
+ this.currencyModalMode.set('create');
+ this.selectedCurrencyId.set(null);
+ this.selectedCurrency.set(null);
+ this.currencySubmitAttempted.set(false);
+
+ this.currencyForm.reset({
+ name: '',
+ code: '',
+ symbol: '',
+ numericCode: 0,
+ decimalDigits: 2
+ });
+ this.resetCurrencyFormState();
+
+ this.showCurrencyModal.set(true);
+ }
+
+ closeCurrencyModal(): void {
+ if (this.saving()) {
+ return;
+ }
+
+ this.showCurrencyModal.set(false);
+ this.selectedCurrencyId.set(null);
+ this.selectedCurrency.set(null);
+ this.currencySubmitAttempted.set(false);
+ }
+
+ saveCurrency(): void {
+ if (this.currencyForm.invalid) {
+ this.currencySubmitAttempted.set(true);
+ this.currencyForm.markAllAsTouched();
+ this.focusFirstInvalidCurrencyControl();
+ return;
+ }
+
+ if (this.saving()) {
+ return;
+ }
+
+ this.saving.set(true);
+
+ if (this.currencyModalMode() === 'create') {
+ this.currencyApi
+ .createCurrency(this.buildCreateCurrencyRequest())
+ .pipe(finalize(() => this.saving.set(false)))
+ .subscribe({
+ next: () => {
+ this.toastr.success('Currency saved successfully.');
+ this.finishCurrencySave();
+ }
+ });
+
+ return;
+ }
+
+ const currencyId = this.selectedCurrencyId();
+
+ if (!currencyId) {
+ this.saving.set(false);
+ return;
+ }
+
+ this.currencyApi
+ .updateCurrency(
+ currencyId,
+ this.buildUpdateCurrencyRequest(this.selectedCurrency()?.isActive ?? true)
+ )
+ .pipe(finalize(() => this.saving.set(false)))
+ .subscribe({
+ next: () => {
+ this.toastr.success('Currency updated successfully.');
+ this.finishCurrencySave();
+ }
+ });
+ }
+
+ private viewCurrency(currency: CurrencyDto): void {
+ this.openEditCurrency(currency);
+ }
+
+ private openEditCurrency(currency: CurrencyDto): void {
+ this.currencyModalMode.set('edit');
+ this.selectedCurrencyId.set(currency.id);
+ this.selectedCurrency.set(currency);
+ this.currencySubmitAttempted.set(false);
+
+ this.currencyApi
+ .getCurrencyById(currency.id)
+ .subscribe({
+ next: currencyDetails => {
+ this.selectedCurrency.set(currencyDetails);
+ this.currencyForm.reset({
+ name: currencyDetails.name ?? '',
+ code: currencyDetails.code ?? '',
+ symbol: currencyDetails.symbol ?? '',
+ numericCode: currencyDetails.numericCode ?? '0',
+ decimalDigits: currencyDetails.decimalDigits ?? 2
+ });
+ this.resetCurrencyFormState();
+
+ this.showCurrencyModal.set(true);
+ }
+ });
+ }
+
+ private requestDeleteCurrency(currency: CurrencyDto): void {
+ this.pendingDeleteCurrency.set(currency);
+ this.deleteConfirmDialog()?.open();
+ }
+
+ private deleteCurrency(currency: CurrencyDto): void {
+ this.updateCurrencyStatus(currency, false);
+ }
+
+ private activateCurrency(currency: CurrencyDto): void {
+ this.updateCurrencyStatus(currency, true);
+ }
+
+ private buildCreateCurrencyRequest(): CreateCurrencyRequest {
+ const value = this.currencyForm.getRawValue();
+
+ return {
+ name: value.name.trim(),
+ code: value.code.trim().toUpperCase(),
+ symbol: value.symbol.trim(),
+ numericCode: value.numericCode,
+ decimalDigits: value.decimalDigits
+ };
+ }
+
+ private buildUpdateCurrencyRequest(isActive: boolean): UpdateCurrencyRequest {
+ return {
+ ...this.buildCreateCurrencyRequest(),
+ isActive
+ };
+ }
+
+ private currencyToUpdateRequest(currency: CurrencyDto, isActive: boolean): UpdateCurrencyRequest {
+ return {
+ name: currency.name?.trim() ?? '',
+ code: currency.code?.trim().toUpperCase() ?? '',
+ symbol: currency.symbol?.trim() ?? '',
+ numericCode: currency.numericCode,
+ decimalDigits: currency.decimalDigits,
+ isActive
+ };
+ }
+
+ private updateCurrencyStatus(currency: CurrencyDto, isActive: boolean): void {
+ this.currencyApi
+ .updateCurrency(currency.id, this.currencyToUpdateRequest(currency, isActive))
+ .subscribe({
+ next: () => {
+ this.toastr.success(
+ isActive
+ ? 'Currency activated successfully.'
+ : 'Currency deactivated successfully.'
+ );
+ this.loadCurrencies(this.queryState.getQuery());
+ }
+ });
+ }
+
+ private resetCurrencyFormState(): void {
+ this.currencyForm.markAsPristine();
+ this.currencyForm.markAsUntouched();
+ this.currencyForm.updateValueAndValidity();
+ }
+
+ private finishCurrencySave(): void {
+ this.showCurrencyModal.set(false);
+ this.selectedCurrencyId.set(null);
+ this.selectedCurrency.set(null);
+ this.currencySubmitAttempted.set(false);
+ this.loadCurrencies(this.queryState.getQuery());
+ }
+
+ private clearCurrencyGrid(): void {
+ this.currencies.set([]);
+ this.totalRecords.set(0);
+ this.filteredRecords.set(0);
+ }
+
+ private focusFirstInvalidCurrencyControl(): void {
+ queueMicrotask(() => {
+ const firstInvalidControl =
+ this.elementRef.nativeElement.querySelector(
+ 'modal [data-form-control][aria-invalid="true"]'
+ );
+
+ firstInvalidControl?.focus();
+ firstInvalidControl?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center'
+ });
+ });
+ }
+
+ private toCurrencyDto(row: CurrencyTableRow): CurrencyDto {
+ return {
+ id: row.id,
+ code: row.code,
+ iso2: row.iso2,
+ name: row.name,
+ symbol: row.symbol,
+ numericCode: row.numericCode,
+ decimalDigits: row.decimalDigits,
+ isActive: row.isActive,
+ createdOn: row.createdOn,
+ modifiedOn: row.modifiedOn
+ };
+ }
+
+ visibleCountries(row: CurrencyTableRow): readonly CurrencyCountryFlag[] {
+ return this.normalizeIso2Codes(row.iso2).slice(0, 1);
+ }
+
+ remainingCountries(row: CurrencyTableRow): readonly CurrencyCountryFlag[] {
+ return this.normalizeIso2Codes(row.iso2).slice(1);
+ }
+
+ remainingCountryCount(row: CurrencyTableRow): number {
+ return this.remainingCountries(row).length;
+ }
+
+ openIso2Tooltip(row: CurrencyTableRow): void {
+ this.cancelIso2TooltipClose();
+ this.openIso2TooltipCurrencyId.set(row.id);
+ }
+
+ scheduleIso2TooltipClose(): void {
+ this.cancelIso2TooltipClose();
+ this.iso2TooltipCloseTimer = setTimeout(() => this.closeIso2Tooltip(), 120);
+ }
+
+ isIso2TooltipOpen(row: CurrencyTableRow): boolean {
+ return this.openIso2TooltipCurrencyId() === row.id;
+ }
+
+ iso2TooltipId(row: CurrencyTableRow): string {
+ return `currency-iso2-tooltip-${row.id}`;
+ }
+
+ closeIso2Tooltip(): void {
+ this.cancelIso2TooltipClose();
+ this.openIso2TooltipCurrencyId.set(null);
+ }
+
+ onIso2TooltipKeydown(event: KeyboardEvent): void {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ this.closeIso2Tooltip();
+ }
+ }
+
+ onIso2TooltipPositionChange(event: ConnectedOverlayPositionChange): void {
+ this.iso2TooltipPlacement.set(
+ event.connectionPair.overlayY === 'bottom' ? 'above' : 'below'
+ );
+ }
+
+ normalizeIso2Codes(value: unknown): CurrencyCountryFlag[] {
+ const items: readonly unknown[] = Array.isArray(value)
+ ? value
+ : typeof value === 'string'
+ ? value.split(/[,;|]/)
+ : [];
+ const codes = new Set();
+
+ for (const item of items) {
+ const rawCode = typeof item === 'string'
+ ? item
+ : item && typeof item === 'object' && 'iso2' in item
+ ? String(item.iso2)
+ : '';
+ const iso2 = rawCode.trim().toUpperCase();
+
+ if (/^[A-Z]{2}$/.test(iso2)) {
+ codes.add(iso2);
+ }
+ }
+
+ return [...codes].map(iso2 => ({ iso2 }));
+ }
+
+ private cancelIso2TooltipClose(): void {
+ if (this.iso2TooltipCloseTimer !== null) {
+ clearTimeout(this.iso2TooltipCloseTimer);
+ this.iso2TooltipCloseTimer = null;
+ }
+ }
+
+ getFlagUrl(value: unknown): string {
+ const code = this.normalizeIso2Codes(value)[0]?.iso2.toLowerCase();
+
+ return code && /^[a-z]{2}$/.test(code)
+ ? `https://flagcdn.com/24x18/${code}.png`
+ : '';
+ }
+ onFlagError(event: Event): void {
+ const image = event.target as HTMLImageElement;
+ image.classList.add('hidden');
+ image.nextElementSibling?.classList.remove('hidden');
+ }
+}
diff --git a/src/app/features/global-masters/currencies/public-api.ts b/src/app/features/global-masters/currencies/public-api.ts
new file mode 100644
index 00000000..ed036285
--- /dev/null
+++ b/src/app/features/global-masters/currencies/public-api.ts
@@ -0,0 +1,2 @@
+export { CurrencyService } from './data-access/currency.service';
+export type { CurrencyDto, CurrencyLookupDto } from './models/currency.model';
diff --git a/src/app/features/global-masters/global-masters.routes.ts b/src/app/features/global-masters/global-masters.routes.ts
index 666207af..35cf4e8e 100644
--- a/src/app/features/global-masters/global-masters.routes.ts
+++ b/src/app/features/global-masters/global-masters.routes.ts
@@ -4,16 +4,31 @@ export const globalMastersRoutes: Routes = [
{
path: 'countries',
loadComponent: () => import('./countries/pages/country-list/country-list').then((m) => m.CountryList),
- data: { childTitle: 'Country Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
+ data: { childTitle: 'Country Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'states',
loadComponent: () => import('./states/pages/state-list/state-list').then((m) => m.StateList),
- data: { childTitle: 'State Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
+ data: { childTitle: 'State Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'cities',
loadComponent: () => import('./cities/pages/city-list/city-list').then((m) => m.CityList),
- data: { childTitle: 'City Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
+ data: { childTitle: 'City Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
+ {
+ path: 'currencies',
+ loadComponent: () => import('./currencies/pages/currency-list/currency-list').then((m) => m.CurrencyList),
+ data: { childTitle: 'Currency Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
+ },
+ {
+ path: 'languages',
+ loadComponent: () => import('./languages/pages/language-list/language-list').then((m) => m.LanguageList),
+ data: { childTitle: 'Language Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
+ },
+ {
+ path: 'timezones',
+ loadComponent: () => import('./timezones/pages/timezone-list/timezone-list').then((m) => m.TimezoneList),
+ data: { childTitle: 'Timezone Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
+ }
];
diff --git a/src/app/features/global-masters/languages/data-access/language.endpoints.ts b/src/app/features/global-masters/languages/data-access/language.endpoints.ts
new file mode 100644
index 00000000..123ad1fa
--- /dev/null
+++ b/src/app/features/global-masters/languages/data-access/language.endpoints.ts
@@ -0,0 +1,11 @@
+import { buildApiUrl } from '../../../../core/config/api-url.util';
+
+export const LANGUAGE_ENDPOINTS = {
+ dataTable: buildApiUrl('masterAdmin', '/v1/languages/datatable'),
+ create: buildApiUrl('masterAdmin', '/v1/languages'),
+ getById: (id: string) =>
+ buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
+ update: (id: string) =>
+ buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
+ autocomplete: buildApiUrl('masterAdmin', '/v1/languages/autocomplete')
+} as const;
diff --git a/src/app/features/global-masters/languages/data-access/language.service.ts b/src/app/features/global-masters/languages/data-access/language.service.ts
new file mode 100644
index 00000000..d491b95f
--- /dev/null
+++ b/src/app/features/global-masters/languages/data-access/language.service.ts
@@ -0,0 +1,48 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import { LANGUAGE_ENDPOINTS } from './language.endpoints';
+import {
+ CreateLanguageRequest,
+ LanguageDto,
+ LanguageLookupDto,
+ UpdateLanguageRequest
+} from '../models/language.model';
+import {
+ DataTableQuery,
+ DataTableResult
+} from '../../../../shared/components/data-table/data-table.types';
+
+@Injectable({ providedIn: 'root' })
+export class LanguageService {
+ private readonly http = inject(HttpClient);
+
+ getDataTable(query: DataTableQuery): Observable> {
+ return this.http.post>(LANGUAGE_ENDPOINTS.dataTable, query);
+ }
+
+ getById(id: string): Observable {
+ return this.http.get(LANGUAGE_ENDPOINTS.getById(id));
+ }
+
+ create(request: CreateLanguageRequest): Observable {
+ return this.http.post(LANGUAGE_ENDPOINTS.create, request);
+ }
+
+ update(id: string, request: UpdateLanguageRequest): Observable {
+ return this.http.put(LANGUAGE_ENDPOINTS.update(id), request);
+ }
+
+ autocomplete(
+ term: string | null,
+ limit = 10
+ ): Observable {
+ let params = new HttpParams().set('limit', limit);
+ if (term !== null) {
+ params = params.set('term', term);
+ }
+
+ return this.http.get(LANGUAGE_ENDPOINTS.autocomplete, { params });
+ }
+}
diff --git a/src/app/features/global-masters/languages/models/language.model.ts b/src/app/features/global-masters/languages/models/language.model.ts
new file mode 100644
index 00000000..fc1f7539
--- /dev/null
+++ b/src/app/features/global-masters/languages/models/language.model.ts
@@ -0,0 +1,31 @@
+export interface LanguageDto {
+ id: string;
+ code: string;
+ name: string;
+ nativeName: string;
+ isRightToLeft: boolean;
+ isActive: boolean;
+ createdOn: string;
+ modifiedOn: string | null;
+}
+
+export interface LanguageLookupDto {
+ readonly id: string;
+ readonly code: string;
+ readonly name: string;
+ readonly nativeName: string;
+ readonly isRightToLeft: boolean;
+}
+
+export interface CreateLanguageRequest {
+ code: string;
+ name: string;
+ nativeName: string;
+ isRightToLeft: boolean;
+}
+
+export interface UpdateLanguageRequest extends CreateLanguageRequest {
+ isActive: boolean;
+}
+
+export type LanguageModalMode = 'create' | 'edit';
diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.html b/src/app/features/global-masters/languages/pages/language-list/language-list.html
new file mode 100644
index 00000000..6aaa3e77
--- /dev/null
+++ b/src/app/features/global-masters/languages/pages/language-list/language-list.html
@@ -0,0 +1,67 @@
+
+
+ {{ value }}
+
+
+ {{ value }}
+
+
+
+
+
+
+ @if (modalLoading()) {
+
+
+ Loading language...
+
+ } @else {
+
+ }
+
\ No newline at end of file
diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.scss b/src/app/features/global-masters/languages/pages/language-list/language-list.scss
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/app/features/global-masters/languages/pages/language-list/language-list.scss
@@ -0,0 +1 @@
+
diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.ts b/src/app/features/global-masters/languages/pages/language-list/language-list.ts
new file mode 100644
index 00000000..56e47501
--- /dev/null
+++ b/src/app/features/global-masters/languages/pages/language-list/language-list.ts
@@ -0,0 +1,356 @@
+import { HttpErrorResponse } from '@angular/common/http';
+import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
+import { ToastrService } from 'ngx-toastr';
+import { Subject, catchError, finalize, of, switchMap } from 'rxjs';
+
+import {
+ CreateLanguageRequest,
+ LanguageDto,
+ LanguageModalMode,
+ UpdateLanguageRequest
+} from '../../models/language.model';
+import { LanguageService } from '../../data-access/language.service';
+import { DataTable } from '../../../../../shared/components/data-table/data-table';
+import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
+import {
+ DataTableAction,
+ DataTableActionEvent,
+ DataTableColumn,
+ DataTablePageEvent,
+ DataTableQuery,
+ DataTableRecord,
+ DataTableSortEvent
+} from '../../../../../shared/components/data-table/data-table.types';
+import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
+import { Modal } from '../../../../../shared/components/modal/modal';
+import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
+import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
+
+interface LanguageTableRow extends DataTableRecord {
+ id: string;
+ code: string;
+ name: string;
+ nativeName: string;
+ isRightToLeft: boolean;
+ isActive: boolean;
+ serialNumber: number;
+ createdOn: string;
+ modifiedOn: string | null;
+}
+
+@Component({
+ selector: 'language-list',
+ standalone: true,
+ imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
+ templateUrl: './language-list.html',
+ styleUrl: './language-list.scss'
+})
+export class LanguageList {
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly languageApi = inject(LanguageService);
+ private readonly formBuilder = inject(FormBuilder);
+ private readonly elementRef = inject>(ElementRef);
+ private readonly toastr = inject(ToastrService);
+ private readonly queryRequests$ = new Subject();
+
+ readonly queryState = new DataTableQueryState();
+ readonly languages = signal([]);
+ readonly totalRecords = signal(0);
+ readonly modalLoading = signal(false);
+ readonly saving = signal(false);
+ readonly statusChangingId = signal(null);
+ readonly showModal = signal(false);
+ readonly modalMode = signal('create');
+ readonly selectedLanguageId = signal(null);
+ readonly selectedLanguage = signal(null);
+ readonly submitAttempted = signal(false);
+ readonly pendingDeleteLanguageId = signal(null);
+ readonly deleteConfirmDialog = viewChild(ConfirmDialog);
+
+ readonly languageForm = this.formBuilder.nonNullable.group({
+ name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
+ code: ['', [
+ Validators.required,
+ Validators.maxLength(35),
+ Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/)
+ ]],
+ nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
+ isRightToLeft: [false]
+ });
+
+ readonly modalTitle = computed(() =>
+ this.modalMode() === 'create' ? 'Add Language' : 'Edit Language'
+ );
+ readonly submitLabel = computed(() =>
+ this.modalMode() === 'create' ? 'Save' : 'Update'
+ );
+ readonly loadingLabel = computed(() =>
+ this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
+ );
+ readonly submitAction = computed<'save' | 'update'>(() =>
+ this.modalMode() === 'create' ? 'save' : 'update'
+ );
+
+ readonly columns = signal[]>([
+ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
+ { key: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left' },
+ { key: 'code', label: 'Language Code', header: 'Language Code', sortable: true },
+ { key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true },
+ {
+ key: 'isRightToLeft', label: 'Direction', header: 'Direction', sortable: true,
+ formatter: value => value ? 'RTL' : 'LTR'
+ },
+ {
+ 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[]>([
+ {
+ type: 'edit',
+ label: 'Edit',
+ icon: 'ti ti-edit',
+ className: 'text-primary'
+ },
+ {
+ type: 'delete',
+ label: 'Delete',
+ icon: 'ti ti-trash',
+ className: 'text-danger',
+ visible: row => row.isActive,
+ disabled: row => this.statusChangingId() === row.id
+ },
+ {
+ type: 'activate',
+ label: 'Activate',
+ icon: 'ti ti-check',
+ className: 'text-success',
+ visible: row => !row.isActive,
+ disabled: row => this.statusChangingId() === row.id
+ }
+ ]);
+
+ constructor() {
+ this.queryRequests$.pipe(
+ switchMap(query => this.languageApi.getDataTable(query).pipe(
+ catchError(() => {
+ this.toastr.error('Unable to load languages.');
+ this.languages.set([]);
+ this.totalRecords.set(0);
+ return of(null);
+ })
+ )),
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe(response => {
+ if (!response || response.draw !== this.queryState.getQuery().draw) {
+ return;
+ }
+ const query = this.queryState.getQuery();
+ this.languages.set(response.rows.map((language, index) => ({
+ ...language,
+ serialNumber: (query.page - 1) * query.pageSize + index + 1
+ })));
+ this.totalRecords.set(response.total);
+ });
+ }
+
+ ngOnInit(): void {
+ this.loadLanguages(this.queryState.getQuery());
+ }
+
+ loadLanguages(query: DataTableQuery): void { this.queryRequests$.next(query); }
+ onSearch(value: string): void { this.loadLanguages(this.queryState.setSearch(value.trim())); }
+ onPageChange(event: DataTablePageEvent): void { this.loadLanguages(this.queryState.setPage(event)); }
+ onSortChange(event: DataTableSortEvent): void { this.loadLanguages(this.queryState.setSort(event)); }
+
+ onDeleteConfirmed(): void {
+ const languageId = this.pendingDeleteLanguageId();
+
+ if (!languageId) {
+ return;
+ }
+
+ this.pendingDeleteLanguageId.set(null);
+ this.changeLanguageStatus(languageId, false);
+ }
+
+ onDeleteCancelled(): void {
+ this.pendingDeleteLanguageId.set(null);
+ }
+
+ onActionClick(event: DataTableActionEvent): void {
+ if (event.action.type === 'edit') {
+ this.openEditLanguage(event.row.id);
+ } else if (event.action.type === 'delete') {
+ this.requestDeleteLanguage(event.row.id);
+ } else if (event.action.type === 'activate') {
+ this.changeLanguageStatus(event.row.id, true);
+ }
+ }
+
+ private requestDeleteLanguage(id: string): void {
+ this.pendingDeleteLanguageId.set(id);
+ this.deleteConfirmDialog()?.open();
+ }
+
+ onAddLanguage(): void {
+ this.modalMode.set('create');
+ this.selectedLanguageId.set(null);
+ this.selectedLanguage.set(null);
+ this.submitAttempted.set(false);
+ this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
+ this.resetFormState();
+ this.showModal.set(true);
+ }
+
+ openEditLanguage(id: string): void {
+ this.modalMode.set('edit');
+ this.selectedLanguageId.set(id);
+ this.selectedLanguage.set(null);
+ this.submitAttempted.set(false);
+ this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
+ this.resetFormState();
+ this.modalLoading.set(true);
+ this.showModal.set(true);
+
+ this.languageApi.getById(id).pipe(
+ finalize(() => this.modalLoading.set(false)),
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe({
+ next: language => {
+ if (this.selectedLanguageId() !== language.id || !this.showModal()) {
+ return;
+ }
+ this.selectedLanguage.set(language);
+ this.languageForm.reset({
+ name: language.name,
+ code: language.code,
+ nativeName: language.nativeName,
+ isRightToLeft: language.isRightToLeft
+ });
+ this.resetFormState();
+ },
+ error: () => this.showModal.set(false)
+ });
+ }
+
+ closeModal(): void {
+ if (this.saving()) return;
+ this.showModal.set(false);
+ this.selectedLanguageId.set(null);
+ this.selectedLanguage.set(null);
+ this.submitAttempted.set(false);
+ }
+
+ saveLanguage(): void {
+ if (this.languageForm.invalid) {
+ this.submitAttempted.set(true);
+ this.languageForm.markAllAsTouched();
+ this.focusFirstInvalidControl();
+ return;
+ }
+ if (this.saving() || this.modalLoading()) return;
+
+ this.saving.set(true);
+ const request = this.buildCreateRequest();
+ const operation = this.modalMode() === 'create'
+ ? this.languageApi.create(request)
+ : this.languageApi.update(
+ this.selectedLanguageId() ?? '',
+ { ...request, isActive: this.selectedLanguage()?.isActive ?? true }
+ );
+
+ operation.pipe(
+ finalize(() => this.saving.set(false)),
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe({
+ next: () => {
+ this.toastr.success(
+ this.modalMode() === 'create'
+ ? 'Language saved successfully.'
+ : 'Language updated successfully.'
+ );
+ this.finishSave();
+ },
+ error: (error: HttpErrorResponse) => this.handleSaveError(error)
+ });
+ }
+
+ private buildCreateRequest(): CreateLanguageRequest {
+ const value = this.languageForm.getRawValue();
+ return {
+ code: this.normalizeCode(value.code),
+ name: value.name.trim(),
+ nativeName: value.nativeName.trim(),
+ isRightToLeft: value.isRightToLeft
+ };
+ }
+
+ private normalizeCode(code: string): string {
+ return code.trim().split('-').map((part, index) =>
+ index === 0 ? part.toLowerCase() : part.toUpperCase()
+ ).join('-');
+ }
+
+ private changeLanguageStatus(id: string, isActive: boolean): void {
+ if (this.statusChangingId()) return;
+ this.statusChangingId.set(id);
+ this.languageApi.getById(id).pipe(
+ switchMap(language => this.languageApi.update(id, {
+ code: language.code,
+ name: language.name,
+ nativeName: language.nativeName,
+ isRightToLeft: language.isRightToLeft,
+ isActive
+ })),
+ finalize(() => this.statusChangingId.set(null)),
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe({
+ next: () => {
+ this.toastr.success(isActive
+ ? 'Language activated successfully.'
+ : 'Language deleted successfully.');
+ this.loadLanguages(this.queryState.getQuery());
+ },
+ error: (error: HttpErrorResponse) => {
+ if (error.status === 404) this.toastr.error('The language is no longer available.');
+ }
+ });
+ }
+
+ private handleSaveError(error: HttpErrorResponse): void {
+ if (error.status === 409) {
+ this.toastr.error('A language with this code already exists.', 'Duplicate language code');
+ }
+ }
+
+ private finishSave(): void {
+ this.showModal.set(false);
+ this.selectedLanguageId.set(null);
+ this.selectedLanguage.set(null);
+ this.submitAttempted.set(false);
+ this.loadLanguages(this.queryState.getQuery());
+ }
+
+ private resetFormState(): void {
+ this.languageForm.markAsPristine();
+ this.languageForm.markAsUntouched();
+ this.languageForm.updateValueAndValidity();
+ }
+
+ private focusFirstInvalidControl(): void {
+ queueMicrotask(() => {
+ const control = this.elementRef.nativeElement.querySelector(
+ 'modal [data-form-control][aria-invalid="true"]'
+ );
+ control?.focus();
+ control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ });
+ }
+}
diff --git a/src/app/features/global-masters/languages/public-api.ts b/src/app/features/global-masters/languages/public-api.ts
new file mode 100644
index 00000000..6d9a5cf5
--- /dev/null
+++ b/src/app/features/global-masters/languages/public-api.ts
@@ -0,0 +1,2 @@
+export { LanguageService } from './data-access/language.service';
+export type { LanguageLookupDto } from './models/language.model';
diff --git a/src/app/core/end-points/state/state.endpoints.ts b/src/app/features/global-masters/states/data-access/state.endpoints.ts
similarity index 81%
rename from src/app/core/end-points/state/state.endpoints.ts
rename to src/app/features/global-masters/states/data-access/state.endpoints.ts
index afdadc65..9f32324b 100644
--- a/src/app/core/end-points/state/state.endpoints.ts
+++ b/src/app/features/global-masters/states/data-access/state.endpoints.ts
@@ -1,4 +1,4 @@
-import { buildApiUrl } from '../../config/api-url.util';
+import { buildApiUrl } from '../../../../core/config/api-url.util';
export const STATE_ENDPOINTS = {
@@ -18,6 +18,11 @@ export const STATE_ENDPOINTS = {
`/v1/states/${encodeURIComponent(id)}`
),
+ autocomplete: buildApiUrl(
+ 'masterAdmin',
+ '/v1/states/autocomplete'
+ ),
+
update: (id: string) =>
buildApiUrl(
'masterAdmin',
@@ -35,4 +40,4 @@ export const STATE_ENDPOINTS = {
'masterAdmin',
`/v1/states/${encodeURIComponent(id)}/status`
),
-} as const;
\ No newline at end of file
+} as const;
diff --git a/src/app/features/global-masters/states/data-access/state.service.ts b/src/app/features/global-masters/states/data-access/state.service.ts
new file mode 100644
index 00000000..ae925b34
--- /dev/null
+++ b/src/app/features/global-masters/states/data-access/state.service.ts
@@ -0,0 +1,45 @@
+import { HttpClient, HttpParams } from "@angular/common/http";
+import { Injectable, inject } from "@angular/core";
+import { STATE_ENDPOINTS } from "./state.endpoints"
+import { Observable } from "rxjs";
+import { DataTableQuery, DataTableResult } from "../../../../shared/components/data-table/data-table.types";
+import {
+ CreateStateRequest,
+ StateDto,
+ StateLookupDto,
+ UpdateStateRequest
+} from "../models/state.model";
+
+@Injectable({
+ providedIn: 'root'
+})
+export class StateService {
+
+ private readonly http = inject(HttpClient);
+
+ getStateDataTable(query: DataTableQuery, countryId: string | null = null): Observable> {
+ const params = countryId ? new HttpParams().set('countryId', countryId) : undefined;
+ return this.http.post>(`${STATE_ENDPOINTS.dataTable}`, query, { params });
+ }
+
+ createState(request: CreateStateRequest): Observable {
+ return this.http.post(STATE_ENDPOINTS.create, request);
+ }
+
+ updateState(id: string, request: UpdateStateRequest): Observable {
+ return this.http.put(STATE_ENDPOINTS.update(id), request);
+ }
+
+ getStateById(id: string): Observable {
+ return this.http.get(STATE_ENDPOINTS.getById(id));
+ }
+
+ autocomplete(countryId: string, term = '', limit = 50): Observable {
+ return this.http.get(STATE_ENDPOINTS.autocomplete, {
+ params: new HttpParams()
+ .set('countryId', countryId)
+ .set('term', term)
+ .set('limit', limit)
+ });
+ }
+}
diff --git a/src/app/features/global-masters/states/models/state.model.ts b/src/app/features/global-masters/states/models/state.model.ts
new file mode 100644
index 00000000..384358d9
--- /dev/null
+++ b/src/app/features/global-masters/states/models/state.model.ts
@@ -0,0 +1,30 @@
+export interface StateDto {
+ id: string;
+ countryId: string;
+ name: string;
+ code: string | null;
+ isActive: boolean;
+ createdOn?: string;
+ modifiedOn?: string | null;
+}
+
+export interface StateLookupDto {
+ id: string;
+ name: string;
+ code: string;
+}
+
+export interface CreateStateRequest {
+ countryId: string;
+ name: string;
+ code: string;
+}
+
+export interface UpdateStateRequest {
+ countryId: null;
+ name: string;
+ code: string;
+ isActive: boolean;
+}
+
+export type StateModalMode = 'create' | 'edit';
diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.html b/src/app/features/global-masters/states/pages/state-list/state-list.html
index f621fc5b..0728e910 100644
--- a/src/app/features/global-masters/states/pages/state-list/state-list.html
+++ b/src/app/features/global-masters/states/pages/state-list/state-list.html
@@ -1,19 +1,104 @@
-
+
+
- (searchChanged)="onSearch($event)"
- (pageChanged)="onPageChange($event)"
- (sortChanged)="onSortChange($event)"
- (actionClicked)="onActionClick($event)">
-