From 9d14c3ead21706c7fcda5179f7e1f6b2dfb517bf Mon Sep 17 00:00:00 2001 From: Gagan7900 <54118395+Gagan7900@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:58:59 +0530 Subject: [PATCH] region crud added --- src/app/core/services/common/menu.data.ts | 1 + .../country-form-modal.html | 20 ++- .../country-form-modal/country-form-modal.ts | 39 +++- .../countries/data-access/country.service.ts | 15 +- .../countries/models/country.model.ts | 4 + .../pages/country-list/country-list.html | 21 +++ .../pages/country-list/country-list.ts | 77 +++++++- .../exchange-rate-form-modal.html | 2 +- .../global-masters/global-masters.routes.ts | 6 + .../region-form-modal/region-form-modal.html | 53 ++++++ .../region-form-modal/region-form-modal.ts | 166 ++++++++++++++++++ .../regions/data-access/region.endpoints.ts | 21 +++ .../regions/data-access/region.service.ts | 53 ++++++ .../regions/models/region.model.ts | 28 +++ .../pages/region-list/region-list.html | 38 ++++ .../pages/region-list/region-list.scss | 0 .../regions/pages/region-list/region-list.ts | 154 ++++++++++++++++ .../global-masters/regions/public-api.ts | 2 + 18 files changed, 678 insertions(+), 22 deletions(-) create mode 100644 src/app/features/global-masters/regions/components/region-form-modal/region-form-modal.html create mode 100644 src/app/features/global-masters/regions/components/region-form-modal/region-form-modal.ts create mode 100644 src/app/features/global-masters/regions/data-access/region.endpoints.ts create mode 100644 src/app/features/global-masters/regions/data-access/region.service.ts create mode 100644 src/app/features/global-masters/regions/models/region.model.ts create mode 100644 src/app/features/global-masters/regions/pages/region-list/region-list.html create mode 100644 src/app/features/global-masters/regions/pages/region-list/region-list.scss create mode 100644 src/app/features/global-masters/regions/pages/region-list/region-list.ts create mode 100644 src/app/features/global-masters/regions/public-api.ts diff --git a/src/app/core/services/common/menu.data.ts b/src/app/core/services/common/menu.data.ts index 94305ede..4000dead 100644 --- a/src/app/core/services/common/menu.data.ts +++ b/src/app/core/services/common/menu.data.ts @@ -28,6 +28,7 @@ export const SAAS_MENU_DATA: MenuContext = { { path: '/global-masters/exchange-rates', title: 'Exchange Rates (ROE)', type: 'link', dirchange: false }, { path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false }, { path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false }, + { path: '/global-masters/regions', title: 'Region', type: 'link', dirchange: false }, { path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false }, { path: '/global-masters/states', title: 'State', type: 'link', dirchange: false }, { path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false }, diff --git a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html index 9c2691eb..be6cb280 100644 --- a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html +++ b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html @@ -1,7 +1,7 @@ +
+ +
} diff --git a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts index 15bedb8e..8f790f90 100644 --- a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts +++ b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts @@ -23,6 +23,7 @@ import { UpdateCountryRequest } from '../../models/country.model'; import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api'; +import { RegionLookupDto, RegionService } from '../../../regions/public-api'; import { CountryService } from '../../data-access/country.service'; import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; @@ -45,6 +46,7 @@ export class CountryFormModalComponent { private readonly formBuilder = inject(FormBuilder); private readonly countryApi = inject(CountryService); private readonly currencyApi = inject(CurrencyService); + private readonly regionApi = inject(RegionService); private readonly notification = inject(NotificationService); readonly open = input(false); @@ -59,6 +61,7 @@ export class CountryFormModalComponent { readonly countrySubmitAttempted = signal(false); readonly selectedCountry = signal(null); readonly selectedCurrency = signal(null); + readonly selectedRegion = signal(null); readonly countryForm = this.formBuilder.nonNullable.group({ name: ['', [Validators.required, Validators.maxLength(150)]], @@ -68,7 +71,8 @@ export class CountryFormModalComponent { '', [Validators.maxLength(16), Validators.pattern(/^\+?[0-9]{1,15}$/)] ], - defaultCurrencyId: this.formBuilder.control(null) + defaultCurrencyId: this.formBuilder.control(null), + regionId: this.formBuilder.control(null) }); readonly isViewMode = computed(() => this.mode() === 'view'); @@ -86,6 +90,12 @@ export class CountryFormModalComponent { readonly currencyDisplayFn: AutocompleteDisplayFn = currency => `${currency.code} - ${currency.name}`; + readonly regionSearchFn: AutocompleteSearchFn = (term, page) => + this.regionApi.autocomplete(term, page); + readonly regionValueFn: AutocompleteValueFn = region => region.id; + readonly regionDisplayFn: AutocompleteDisplayFn = region => + `${region.code} - ${region.name}`; + constructor() { effect(() => { if (this.open()) { @@ -96,8 +106,9 @@ export class CountryFormModalComponent { prepareModal(id: string | null): void { this.countrySubmitAttempted.set(false); - this.countryForm.reset({ name: '', iso2: '', iso3: '', phoneCode: '', defaultCurrencyId: null }); + this.countryForm.reset({ name: '', iso2: '', iso3: '', phoneCode: '', defaultCurrencyId: null, regionId: null }); this.selectedCurrency.set(null); + this.selectedRegion.set(null); if (!id || this.mode() === 'create') { this.selectedCountry.set(null); @@ -109,25 +120,33 @@ export class CountryFormModalComponent { this.countryApi.getCountryById(id).pipe( switchMap(country => { this.selectedCountry.set(country); - if (!country.defaultCurrencyId) return of({ country, currency: null }); - return this.currencyApi.getCurrencyById(country.defaultCurrencyId).pipe( - map(currency => ({ country, currency })), - catchError(() => of({ country, currency: null })) + const currency$ = country.defaultCurrencyId + ? this.currencyApi.getCurrencyById(country.defaultCurrencyId).pipe(catchError(() => of(null))) + : of(null); + const region$ = country.regionId + ? this.regionApi.getRegionById(country.regionId).pipe(catchError(() => of(null))) + : of(null); + return currency$.pipe( + switchMap(currency => region$.pipe(map(region => ({ country, currency, region })))) ); }), finalize(() => this.modalLoading.set(false)), takeUntilDestroyed(this.destroyRef) ).subscribe({ - next: ({ country, currency }) => { + next: ({ country, currency, region }) => { if (currency) { this.selectedCurrency.set({ id: currency.id, code: currency.code, name: currency.name, symbol: currency.symbol }); } + if (region) { + this.selectedRegion.set({ id: region.id, code: region.code, name: region.name }); + } this.countryForm.patchValue({ name: country.name, iso2: country.iso2, iso3: country.iso3, phoneCode: country.phoneCode ?? '', - defaultCurrencyId: country.defaultCurrencyId + defaultCurrencyId: country.defaultCurrencyId, + regionId: country.regionId }); }, error: () => { @@ -153,7 +172,8 @@ export class CountryFormModalComponent { iso2: this.countryForm.controls.iso2.value.trim().toUpperCase(), iso3: this.countryForm.controls.iso3.value.trim().toUpperCase(), phoneCode: this.countryForm.controls.phoneCode.value.trim() || null, - defaultCurrencyId: this.countryForm.controls.defaultCurrencyId.value || null + defaultCurrencyId: this.countryForm.controls.defaultCurrencyId.value || null, + regionId: this.countryForm.controls.regionId.value || null }; this.countryApi.createCountry(request).pipe( @@ -178,6 +198,7 @@ export class CountryFormModalComponent { iso3: this.countryForm.controls.iso3.value.trim().toUpperCase(), phoneCode: this.countryForm.controls.phoneCode.value.trim() || null, defaultCurrencyId: this.countryForm.controls.defaultCurrencyId.value || null, + regionId: this.countryForm.controls.regionId.value || null, isActive: this.selectedCountry()?.isActive ?? true }; diff --git a/src/app/features/global-masters/countries/data-access/country.service.ts b/src/app/features/global-masters/countries/data-access/country.service.ts index 3865932a..0e9f8173 100644 --- a/src/app/features/global-masters/countries/data-access/country.service.ts +++ b/src/app/features/global-masters/countries/data-access/country.service.ts @@ -21,8 +21,9 @@ import { COUNTRY_ENDPOINTS } from './country.endpoints'; export class CountryService { private readonly http = inject(HttpClient); - getCountryDataTable(query: DataTableQuery): Observable> { - return this.http.post>(COUNTRY_ENDPOINTS.dataTable, query); + getCountryDataTable(query: DataTableQuery, regionId: string | null = null): Observable> { + const params = regionId ? new HttpParams().set('regionId', regionId) : undefined; + return this.http.post>(COUNTRY_ENDPOINTS.dataTable, query, { params }); } createCountry(request: CreateCountryRequest): Observable { @@ -45,10 +46,12 @@ export class CountryService { return this.http.get(COUNTRY_ENDPOINTS.getById(id)); } - autocomplete(term = '', limit = 50): Observable { - return this.http.get(COUNTRY_ENDPOINTS.autocomplete, { - params: new HttpParams().set('term', term).set('limit', limit), - }); + autocomplete(term = '', limit = 50, regionId: string | null = null): Observable { + let params = new HttpParams().set('term', term).set('limit', limit); + if (regionId) { + params = params.set('regionId', regionId); + } + return this.http.get(COUNTRY_ENDPOINTS.autocomplete, { params }); } } diff --git a/src/app/features/global-masters/countries/models/country.model.ts b/src/app/features/global-masters/countries/models/country.model.ts index 9020295f..b065b8b9 100644 --- a/src/app/features/global-masters/countries/models/country.model.ts +++ b/src/app/features/global-masters/countries/models/country.model.ts @@ -5,6 +5,9 @@ export interface CountryDto { name: string; phoneCode: string | null; defaultCurrencyId: string | null; + regionId: string | null; + regionCode?: string | null; + regionName?: string | null; isActive: boolean; createdOn?: string; modifiedOn?: string | null; @@ -22,6 +25,7 @@ export interface CreateCountryRequest { name: string; phoneCode: string | null; defaultCurrencyId: string | null; + regionId: string | null; } export interface UpdateCountryRequest extends CreateCountryRequest { diff --git a/src/app/features/global-masters/countries/pages/country-list/country-list.html b/src/app/features/global-masters/countries/pages/country-list/country-list.html index 1bfbf840..46f0f2dd 100644 --- a/src/app/features/global-masters/countries/pages/country-list/country-list.html +++ b/src/app/features/global-masters/countries/pages/country-list/country-list.html @@ -11,13 +11,34 @@ [showAddButton]="true" searchPlaceholder="Search countries..." [searchDebounceTime]="300" + [showFilterButton]="true" + [filterActive]="showFilters()" toolTip="Add Country" (addClicked)="onAddCountry()" (searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)" (sortChanged)="tableStore.onSortChange($event)" (actionClicked)="onActionClick($event)" + (filterClicked)="onToggleFilters()" > + +
+
+ +
+
+ + +
+
+
@if (getFlagUrl(row.iso2); as flagUrl) { 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 a13bbe6b..faf66fb9 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,11 +1,13 @@ import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { finalize } from 'rxjs/operators'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { finalize, map } 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'; -import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { RegionLookupDto, RegionService } from '../../../regions/public-api'; +import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table'; import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, @@ -14,7 +16,15 @@ import { DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteResolveValueFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { Button as AppButton } from '../../../../../shared/components/button/button'; import { CountryFormModalComponent } from '../../components/country-form-modal/country-form-modal'; interface CountryTableRow extends DataTableRecord { @@ -24,6 +34,9 @@ interface CountryTableRow extends DataTableRecord { name: string; phoneCode: string | null; defaultCurrencyId: string | null; + regionId: string | null; + regionCode?: string | null; + regionName?: string | null; isActive: boolean; serialNumber: number; createdOn?: string; @@ -33,7 +46,16 @@ interface CountryTableRow extends DataTableRecord { @Component({ selector: 'country-list', standalone: true, - imports: [DataTable, DataTableCellDirective, ConfirmDialog, CountryFormModalComponent], + imports: [ + DataTable, + DataTableToolbarDirective, + DataTableCellDirective, + ReactiveFormsModule, + Autocomplete, + ConfirmDialog, + AppButton, + CountryFormModalComponent + ], providers: [DataTableStore], templateUrl: './country-list.html', styleUrl: './country-list.scss', @@ -41,6 +63,8 @@ interface CountryTableRow extends DataTableRecord { export class CountryList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly countryApi = inject(CountryService); + private readonly regionApi = inject(RegionService); + private readonly formBuilder = inject(FormBuilder); private readonly notification = inject(NotificationService); readonly tableStore = inject(DataTableStore); @@ -49,6 +73,22 @@ export class CountryList implements OnInit { readonly pendingDeleteCountry = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); + readonly showFilters = signal(false); + readonly selectedRegionLookup = signal(null); + + readonly regionFilterForm = this.formBuilder.nonNullable.group({ + regionId: [''] + }); + + readonly searchRegions: AutocompleteSearchFn = (term, limit) => + this.regionApi.autocomplete(term, limit); + readonly displayRegion: AutocompleteDisplayFn = region => region.name; + readonly regionValue: AutocompleteValueFn = region => region.id; + readonly resolveRegion: AutocompleteResolveValueFn = value => + this.regionApi.getRegionById(value).pipe( + map(region => ({ id: region.id, code: region.code, name: region.name })) + ); + readonly columns = signal[]>([ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, @@ -59,7 +99,12 @@ export class CountryList implements OnInit { badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' }, { - key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true, headerAlign: 'center', align: 'center', + key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, + badgeClass: value => value ? 'badge bg-primary/10 text-primary' : '', + formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' + }, + { + key: 'regionName', label: 'Region', header: 'Region', sortable: false, headerAlign: 'center', align: 'center', formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' }, { @@ -87,10 +132,32 @@ export class CountryList implements OnInit { ngOnInit(): void { this.tableStore.initialize({ - fetcher: query => this.countryApi.getCountryDataTable(query) + fetcher: query => { + const regionId = this.regionFilterForm.controls.regionId.value || null; + return this.countryApi.getCountryDataTable(query, regionId); + } }); } + onFilterRegionSelected(region: RegionLookupDto | null): void { + this.selectedRegionLookup.set(region); + this.regionFilterForm.controls.regionId.setValue(region ? region.id : ''); + } + + onApplyFilter(): void { + this.tableStore.refresh(); + } + + onResetFilter(): void { + this.regionFilterForm.reset({ regionId: '' }); + this.selectedRegionLookup.set(null); + this.tableStore.reset(); + } + + onToggleFilters(): void { + this.showFilters.update(value => !value); + } + onAddCountry(): void { this.tableStore.openCreateModal(); } diff --git a/src/app/features/global-masters/exchange-rates/components/exchange-rate-form-modal/exchange-rate-form-modal.html b/src/app/features/global-masters/exchange-rates/components/exchange-rate-form-modal/exchange-rate-form-modal.html index 43351583..04e7f064 100644 --- a/src/app/features/global-masters/exchange-rates/components/exchange-rate-form-modal/exchange-rate-form-modal.html +++ b/src/app/features/global-masters/exchange-rates/components/exchange-rate-form-modal/exchange-rate-form-modal.html @@ -1,7 +1,7 @@ import('./plans/pages/plan-list/plan-list').then((m) => m.PlanList), data: { childTitle: 'Plan Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' }, + }, + { + path: 'regions', + canActivate: [superAdminGuard], + loadComponent: () => import('./regions/pages/region-list/region-list').then((m) => m.RegionList), + data: { childTitle: 'Region Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' }, } ]; diff --git a/src/app/features/global-masters/regions/components/region-form-modal/region-form-modal.html b/src/app/features/global-masters/regions/components/region-form-modal/region-form-modal.html new file mode 100644 index 00000000..b64950fd --- /dev/null +++ b/src/app/features/global-masters/regions/components/region-form-modal/region-form-modal.html @@ -0,0 +1,53 @@ + + @if (modalLoading()) { +
+ + Loading region... +
+ } @else { +
+
+
+ +
+
+ +
+
+
+ } +
diff --git a/src/app/features/global-masters/regions/components/region-form-modal/region-form-modal.ts b/src/app/features/global-masters/regions/components/region-form-modal/region-form-modal.ts new file mode 100644 index 00000000..3c9216de --- /dev/null +++ b/src/app/features/global-masters/regions/components/region-form-modal/region-form-modal.ts @@ -0,0 +1,166 @@ +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 { finalize } from 'rxjs/operators'; +import { NotificationService } from '../../../../../core/services/common/notification.service'; + +import { + CreateRegionRequest, + RegionDto, + RegionModalMode, + UpdateRegionRequest +} from '../../models/region.model'; +import { RegionService } from '../../data-access/region.service'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Modal } from '../../../../../shared/components/modal/modal'; + +@Component({ + selector: 'app-region-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput], + templateUrl: './region-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RegionFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly regionApi = inject(RegionService); + private readonly notification = inject(NotificationService); + + readonly open = input(false); + readonly mode = input('create'); + readonly regionId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly regionSubmitAttempted = signal(false); + readonly selectedRegion = signal(null); + + readonly regionForm = this.formBuilder.nonNullable.group({ + name: ['', [Validators.required, Validators.maxLength(150)]], + code: ['', [Validators.required, Validators.maxLength(10)]] + }); + + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add Region'; + case 'edit': return 'Edit Region'; + case 'view': return 'View Region'; + } + }); + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.regionId()); + } + }); + } + + prepareModal(id: string | null): void { + this.regionSubmitAttempted.set(false); + this.regionForm.reset({ name: '', code: '' }); + + if (!id || this.mode() === 'create') { + this.selectedRegion.set(null); + this.modalLoading.set(false); + return; + } + + this.modalLoading.set(true); + this.regionApi.getRegionById(id).pipe( + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: region => { + this.selectedRegion.set(region); + this.regionForm.patchValue({ + name: region.name, + code: region.code + }); + }, + error: () => { + this.notification.error('Unable to load region details.'); + this.closeModal(); + } + }); + } + + saveRegion(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.regionSubmitAttempted.set(true); + if (this.regionForm.invalid || this.saving()) return; + + this.saving.set(true); + if (this.mode() === 'create') { + const request: CreateRegionRequest = { + name: this.regionForm.controls.name.value.trim(), + code: this.regionForm.controls.code.value.trim().toUpperCase() + }; + + this.regionApi.createRegion(request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.notification.success('Region created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.regionId(); + if (!id) return; + + const request: UpdateRegionRequest = { + name: this.regionForm.controls.name.value.trim(), + code: this.regionForm.controls.code.value.trim().toUpperCase() + }; + + this.regionApi.updateRegion(id, request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.notification.success('Region 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 region with this code or name already exists.'); + return; + } + this.notification.error(`Unable to ${action} region. Please try again.`); + } +} diff --git a/src/app/features/global-masters/regions/data-access/region.endpoints.ts b/src/app/features/global-masters/regions/data-access/region.endpoints.ts new file mode 100644 index 00000000..6b0d5efb --- /dev/null +++ b/src/app/features/global-masters/regions/data-access/region.endpoints.ts @@ -0,0 +1,21 @@ +import { buildApiUrl } from '../../../../core/config/api-url.util'; + +export const REGION_ENDPOINTS = { + dataTable: buildApiUrl('masterAdmin', '/v1/regions/datatable'), + + create: buildApiUrl('masterAdmin', '/v1/regions'), + + getById: (id: string) => + buildApiUrl('masterAdmin', `/v1/regions/${encodeURIComponent(id)}`), + + autocomplete: buildApiUrl('masterAdmin', '/v1/regions/autocomplete'), + + update: (id: string) => + buildApiUrl('masterAdmin', `/v1/regions/${encodeURIComponent(id)}`), + + delete: (id: string) => + buildApiUrl('masterAdmin', `/v1/regions/${encodeURIComponent(id)}`), + + changeStatus: (id: string) => + buildApiUrl('masterAdmin', `/v1/regions/${encodeURIComponent(id)}/status`), +} as const; diff --git a/src/app/features/global-masters/regions/data-access/region.service.ts b/src/app/features/global-masters/regions/data-access/region.service.ts new file mode 100644 index 00000000..17a64f73 --- /dev/null +++ b/src/app/features/global-masters/regions/data-access/region.service.ts @@ -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 { + CreateRegionRequest, + RegionDto, + RegionLookupDto, + UpdateRegionRequest, + UpdateRegionStatusRequest, +} from '../models/region.model'; +import { REGION_ENDPOINTS } from './region.endpoints'; + +@Injectable({ + providedIn: 'root', +}) +export class RegionService { + private readonly http = inject(HttpClient); + + getRegionDataTable(query: DataTableQuery): Observable> { + return this.http.post>(REGION_ENDPOINTS.dataTable, query); + } + + createRegion(request: CreateRegionRequest): Observable { + return this.http.post(REGION_ENDPOINTS.create, request); + } + + updateRegion(id: string, request: UpdateRegionRequest): Observable { + return this.http.put(REGION_ENDPOINTS.update(id), request); + } + + updateStatus(id: string, request: UpdateRegionStatusRequest): Observable { + return this.http.patch(REGION_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(REGION_ENDPOINTS.delete(id)); + } + + getRegionById(id: string): Observable { + return this.http.get(REGION_ENDPOINTS.getById(id)); + } + + autocomplete(term = '', limit = 50): Observable { + return this.http.get(REGION_ENDPOINTS.autocomplete, { + params: new HttpParams().set('term', term).set('limit', limit), + }); + } +} diff --git a/src/app/features/global-masters/regions/models/region.model.ts b/src/app/features/global-masters/regions/models/region.model.ts new file mode 100644 index 00000000..31f1d5cb --- /dev/null +++ b/src/app/features/global-masters/regions/models/region.model.ts @@ -0,0 +1,28 @@ +export interface RegionDto { + id: string; + code: string; + name: string; + countryCount?: number; + isActive: boolean; + createdOn?: string; + modifiedOn?: string | null; +} + +export interface RegionLookupDto { + id: string; + code: string; + name: string; +} + +export interface CreateRegionRequest { + code: string; + name: string; +} + +export type UpdateRegionRequest = CreateRegionRequest; + +export interface UpdateRegionStatusRequest { + isActive: boolean; +} + +export type RegionModalMode = 'create' | 'edit' | 'view'; diff --git a/src/app/features/global-masters/regions/pages/region-list/region-list.html b/src/app/features/global-masters/regions/pages/region-list/region-list.html new file mode 100644 index 00000000..18e43887 --- /dev/null +++ b/src/app/features/global-masters/regions/pages/region-list/region-list.html @@ -0,0 +1,38 @@ + + + + + + diff --git a/src/app/features/global-masters/regions/pages/region-list/region-list.scss b/src/app/features/global-masters/regions/pages/region-list/region-list.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/features/global-masters/regions/pages/region-list/region-list.ts b/src/app/features/global-masters/regions/pages/region-list/region-list.ts new file mode 100644 index 00000000..e78af58a --- /dev/null +++ b/src/app/features/global-masters/regions/pages/region-list/region-list.ts @@ -0,0 +1,154 @@ +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 { RegionDto } from '../../models/region.model'; +import { RegionService } from '../../data-access/region.service'; +import { DataTable } 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 { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { RegionFormModalComponent } from '../../components/region-form-modal/region-form-modal'; + +interface RegionTableRow extends DataTableRecord { + id: string; + code: string; + name: string; + countryCount?: number; + isActive: boolean; + serialNumber: number; + createdOn?: string; + modifiedOn?: string | null; +} + +@Component({ + selector: 'region-list', + standalone: true, + imports: [DataTable, ConfirmDialog, RegionFormModalComponent], + providers: [DataTableStore], + templateUrl: './region-list.html', + styleUrl: './region-list.scss', +}) +export class RegionList implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly regionApi = inject(RegionService); + private readonly notification = inject(NotificationService); + readonly tableStore = inject(DataTableStore); + + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteRegion = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); + + readonly columns = signal[]>([ + { 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', width: '110px', badge: true, + badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' + }, + { + key: 'countryCount', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'center', align: 'center', width: '110px', + formatter: value => (typeof value === 'number' ? value : 0).toString() + }, + { + 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: '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.regionApi.getRegionDataTable(query) + }); + } + + onAddRegion(): void { + this.tableStore.openCreateModal(); + } + + onActionClick(event: DataTableActionEvent): 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.requestDeleteRegion(event.row); + if (event.action.type === 'activate') this.changeRegionStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeRegionStatus(event.row, false); + } + + onDeleteConfirmed(): void { + const region = this.pendingDeleteRegion(); + if (!region) return; + this.pendingDeleteRegion.set(null); + this.deletingId.set(region.id); + + this.regionApi.delete(region.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.notification.success('Region deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete region.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete region because countries are still assigned to it.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Region 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.pendingDeleteRegion.set(null); + } + + private requestDeleteRegion(region: RegionTableRow): void { + this.pendingDeleteRegion.set(region); + this.deleteConfirmDialog()?.open(); + } + + private changeRegionStatus(region: RegionTableRow, activate: boolean): void { + this.statusChangingId.set(region.id); + + this.regionApi.updateStatus(region.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.notification.success(`Region ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} region.`; + this.notification.error(msg); + } + }); + } +} diff --git a/src/app/features/global-masters/regions/public-api.ts b/src/app/features/global-masters/regions/public-api.ts new file mode 100644 index 00000000..a670576d --- /dev/null +++ b/src/app/features/global-masters/regions/public-api.ts @@ -0,0 +1,2 @@ +export { RegionService } from './data-access/region.service'; +export type { RegionLookupDto } from './models/region.model';