Merge pull request 'region crud added' (#14) from feature/onboarding-workflow-sc into dev

Reviewed-on: sc/syscom-master-admin-ui#14
This commit is contained in:
2026-08-08 10:30:00 +00:00
18 changed files with 678 additions and 22 deletions
@@ -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 },
@@ -1,7 +1,7 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="sm"
size="md"
[submitAction]="mode() === 'create' ? 'save' : 'update'"
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
@@ -90,6 +90,24 @@
(itemSelected)="selectedCurrency.set($event)"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="regionId"
variant="floating"
size="sm"
inputId="country-region-id"
label="Region"
placeholder="Select region"
[readonly]="isViewMode()"
[submitAttempted]="countrySubmitAttempted()"
[searchFn]="regionSearchFn"
[valueWith]="regionValueFn"
[displayWith]="regionDisplayFn"
[selectedItem]="selectedRegion()"
[clearable]="true"
(itemSelected)="selectedRegion.set($event)"
/>
</div>
</div>
</form>
}
@@ -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<boolean>(false);
@@ -59,6 +61,7 @@ export class CountryFormModalComponent {
readonly countrySubmitAttempted = signal(false);
readonly selectedCountry = signal<CountryDto | null>(null);
readonly selectedCurrency = signal<CurrencyLookupDto | null>(null);
readonly selectedRegion = signal<RegionLookupDto | null>(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<string | null>(null)
defaultCurrencyId: this.formBuilder.control<string | null>(null),
regionId: this.formBuilder.control<string | null>(null)
});
readonly isViewMode = computed(() => this.mode() === 'view');
@@ -86,6 +90,12 @@ export class CountryFormModalComponent {
readonly currencyDisplayFn: AutocompleteDisplayFn<CurrencyLookupDto> = currency =>
`${currency.code} - ${currency.name}`;
readonly regionSearchFn: AutocompleteSearchFn<RegionLookupDto> = (term, page) =>
this.regionApi.autocomplete(term, page);
readonly regionValueFn: AutocompleteValueFn<RegionLookupDto, string> = region => region.id;
readonly regionDisplayFn: AutocompleteDisplayFn<RegionLookupDto> = 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
};
@@ -21,8 +21,9 @@ import { COUNTRY_ENDPOINTS } from './country.endpoints';
export class CountryService {
private readonly http = inject(HttpClient);
getCountryDataTable(query: DataTableQuery): Observable<DataTableResult<CountryDto>> {
return this.http.post<DataTableResult<CountryDto>>(COUNTRY_ENDPOINTS.dataTable, query);
getCountryDataTable(query: DataTableQuery, regionId: string | null = null): Observable<DataTableResult<CountryDto>> {
const params = regionId ? new HttpParams().set('regionId', regionId) : undefined;
return this.http.post<DataTableResult<CountryDto>>(COUNTRY_ENDPOINTS.dataTable, query, { params });
}
createCountry(request: CreateCountryRequest): Observable<CountryDto> {
@@ -45,10 +46,12 @@ export class CountryService {
return this.http.get<CountryDto>(COUNTRY_ENDPOINTS.getById(id));
}
autocomplete(term = '', limit = 50): Observable<CountryLookupDto[]> {
return this.http.get<CountryLookupDto[]>(COUNTRY_ENDPOINTS.autocomplete, {
params: new HttpParams().set('term', term).set('limit', limit),
});
autocomplete(term = '', limit = 50, regionId: string | null = null): Observable<CountryLookupDto[]> {
let params = new HttpParams().set('term', term).set('limit', limit);
if (regionId) {
params = params.set('regionId', regionId);
}
return this.http.get<CountryLookupDto[]>(COUNTRY_ENDPOINTS.autocomplete, { params });
}
}
@@ -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 {
@@ -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()"
>
<ng-template appDataTableToolbar>
<form [formGroup]="regionFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off"
class="flex flex-wrap items-end gap-3 w-full">
<div class="w-64 min-w-[200px]">
<app-autocomplete formControlName="regionId" inputId="country-region-filter" variant="floating" size="sm"
label="Region" placeholder="Search region" [searchFn]="searchRegions" [displayWith]="displayRegion"
[valueWith]="regionValue" [selectedItem]="selectedRegionLookup()" [minSearchLength]="1" [debounceTime]="300"
[limit]="50" [clearable]="true" [hideValidation]="true" wrapperClass="!mb-0 w-full"
(itemSelected)="onFilterRegionSelected($event)" />
</div>
<div class="flex items-center gap-2">
<app-button action="custom" label="Apply" icon="ti ti-filter" variant="primary-full" type="submit" size="sm"
className="!rounded-full shadow-sm !mb-0 min-h-8" />
<app-button action="custom" label="Reset" icon="ti ti-refresh" variant="outline-primary" type="button" size="sm"
className="!rounded-full shadow-sm !mb-0 min-h-8" (buttonClicked)="onResetFilter()" />
</div>
</form>
</ng-template>
<ng-template appDataTableCell="name" let-row let-value="value">
<div class="flex items-center gap-2">
@if (getFlagUrl(row.iso2); as flagUrl) {
@@ -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<CountryDto, CountryTableRow>);
@@ -49,6 +73,22 @@ export class CountryList implements OnInit {
readonly pendingDeleteCountry = signal<CountryTableRow | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly showFilters = signal(false);
readonly selectedRegionLookup = signal<RegionLookupDto | null>(null);
readonly regionFilterForm = this.formBuilder.nonNullable.group({
regionId: ['']
});
readonly searchRegions: AutocompleteSearchFn<RegionLookupDto> = (term, limit) =>
this.regionApi.autocomplete(term, limit);
readonly displayRegion: AutocompleteDisplayFn<RegionLookupDto> = region => region.name;
readonly regionValue: AutocompleteValueFn<RegionLookupDto, string> = region => region.id;
readonly resolveRegion: AutocompleteResolveValueFn<RegionLookupDto, string> = value =>
this.regionApi.getRegionById(value).pipe(
map(region => ({ id: region.id, code: region.code, name: region.name }))
);
readonly columns = signal<DataTableColumn<CountryTableRow>[]>([
{ 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();
}
@@ -1,7 +1,7 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="lg"
size="md"
[submitAction]="mode() === 'create' ? 'save' : 'update'"
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
@@ -49,5 +49,11 @@ export const globalMastersRoutes: Routes = [
canActivate: [superAdminGuard],
loadComponent: () => 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' },
}
];
@@ -0,0 +1,53 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="md"
[submitAction]="mode() === 'create' ? 'save' : 'update'"
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
[loading]="saving() || modalLoading()"
[showSubmitButton]="!isViewMode()"
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
(closed)="closeModal()"
(submitted)="saveRegion()"
>
@if (modalLoading()) {
<div class="flex min-h-32 items-center justify-center">
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
<span class="ms-2">Loading region...</span>
</div>
} @else {
<form [formGroup]="regionForm" (ngSubmit)="saveRegion()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="name"
inputId="region-name"
variant="floating"
label="Region Name"
placeholder="Name"
[required]="true"
[readonly]="isViewMode()"
[maxLength]="150"
[submitAttempted]="regionSubmitAttempted()"
[validationMessages]="{ required: 'Region name is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="code"
inputId="region-code"
variant="floating"
label="Region Code"
placeholder="Code"
[required]="true"
[readonly]="isViewMode()"
[maxLength]="10"
[submitAttempted]="regionSubmitAttempted()"
[validationMessages]="{ required: 'Region code is required.' }"
/>
</div>
</div>
</form>
}
</modal>
@@ -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<boolean>(false);
readonly mode = input<RegionModalMode>('create');
readonly regionId = input<string | null>(null);
readonly saved = output<void>();
readonly closed = output<void>();
readonly modalLoading = signal(false);
readonly saving = signal(false);
readonly regionSubmitAttempted = signal(false);
readonly selectedRegion = signal<RegionDto | null>(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.`);
}
}
@@ -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;
@@ -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<DataTableResult<RegionDto>> {
return this.http.post<DataTableResult<RegionDto>>(REGION_ENDPOINTS.dataTable, query);
}
createRegion(request: CreateRegionRequest): Observable<RegionDto> {
return this.http.post<RegionDto>(REGION_ENDPOINTS.create, request);
}
updateRegion(id: string, request: UpdateRegionRequest): Observable<RegionDto> {
return this.http.put<RegionDto>(REGION_ENDPOINTS.update(id), request);
}
updateStatus(id: string, request: UpdateRegionStatusRequest): Observable<RegionDto> {
return this.http.patch<RegionDto>(REGION_ENDPOINTS.changeStatus(id), request);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(REGION_ENDPOINTS.delete(id));
}
getRegionById(id: string): Observable<RegionDto> {
return this.http.get<RegionDto>(REGION_ENDPOINTS.getById(id));
}
autocomplete(term = '', limit = 50): Observable<RegionLookupDto[]> {
return this.http.get<RegionLookupDto[]>(REGION_ENDPOINTS.autocomplete, {
params: new HttpParams().set('term', term).set('limit', limit),
});
}
}
@@ -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';
@@ -0,0 +1,38 @@
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[actions]="actions()"
[totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Regions"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search regions..."
[searchDebounceTime]="300"
toolTip="Add Region"
(addClicked)="onAddRegion()"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)"
(actionClicked)="onActionClick($event)"
>
</app-data-table>
<app-confirm-dialog
title="Delete Region"
text="Do you really want to delete this region?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
<app-region-form-modal
[open]="tableStore.showModal()"
[mode]="tableStore.modalMode()"
[regionId]="tableStore.selectedItem()?.id ?? null"
(saved)="tableStore.refresh()"
(closed)="tableStore.closeModal()"
/>
@@ -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<RegionDto, RegionTableRow>);
readonly statusChangingId = signal<string | null>(null);
readonly deletingId = signal<string | null>(null);
readonly pendingDeleteRegion = signal<RegionTableRow | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly columns = signal<DataTableColumn<RegionTableRow>[]>([
{ 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<DataTableAction<RegionTableRow>[]>([
{ 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<RegionTableRow>): 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);
}
});
}
}
@@ -0,0 +1,2 @@
export { RegionService } from './data-access/region.service';
export type { RegionLookupDto } from './models/region.model';