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:
@@ -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 },
|
||||
|
||||
+19
-1
@@ -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>
|
||||
}
|
||||
|
||||
+30
-9
@@ -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
-1
@@ -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' },
|
||||
}
|
||||
];
|
||||
|
||||
+53
@@ -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>
|
||||
+166
@@ -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';
|
||||
Reference in New Issue
Block a user