orgnanization onboarding stepper form changes, code refactor for custom data-table grid
This commit is contained in:
+93
@@ -0,0 +1,93 @@
|
||||
<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)="saveCity()"
|
||||
>
|
||||
@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 city...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="cityForm" (ngSubmit)="saveCity()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="city-country-id"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="Country"
|
||||
placeholder="Select country"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[searchFn]="countrySearchFn"
|
||||
[valueWith]="countryValueFn"
|
||||
[displayWith]="countryDisplayFn"
|
||||
[selectedItem]="selectedFormCountry()"
|
||||
[minSearchLength]="0"
|
||||
(itemSelected)="onFormCountryChanged($event)"
|
||||
[validationMessages]="{ required: 'Country is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="stateId"
|
||||
inputId="city-state-id"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="State"
|
||||
placeholder="Select state"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[searchFn]="stateSearchFn"
|
||||
[valueWith]="stateValueFn"
|
||||
[displayWith]="stateDisplayFn"
|
||||
[selectedItem]="selectedFormState()"
|
||||
[minSearchLength]="0"
|
||||
(itemSelected)="selectedFormState.set($event)"
|
||||
[validationMessages]="{ required: 'State is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="name"
|
||||
inputId="city-name"
|
||||
variant="floating"
|
||||
label="City Name"
|
||||
placeholder="Name"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="150"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'City name is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="code"
|
||||
inputId="city-code"
|
||||
variant="floating"
|
||||
label="City Code"
|
||||
placeholder="Code"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="16"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'City code is required.' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
@@ -0,0 +1,229 @@
|
||||
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 { ToastrService } from 'ngx-toastr';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { CityDto, CityModalMode, CreateCityRequest, UpdateCityRequest } from '../../models/city.model';
|
||||
import { CountryLookupDto } from '../../../countries/models/country.model';
|
||||
import { StateLookupDto } from '../../../states/models/state.model';
|
||||
import { TimezoneLookupDto } from '../../../timezones/models/timezone.model';
|
||||
import { CityService } from '../../data-access/city.service';
|
||||
import { CountryService } from '../../../countries/data-access/country.service';
|
||||
import { StateService } from '../../../states/data-access/state.service';
|
||||
import { TimezoneService } from '../../../timezones/data-access/timezone.service';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-city-form-modal',
|
||||
standalone: true,
|
||||
imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete],
|
||||
templateUrl: './city-form-modal.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CityFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly cityApi = inject(CityService);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<CityModalMode>('create');
|
||||
readonly cityId = input<string | null>(null);
|
||||
|
||||
readonly saved = output<void>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly selectedCity = signal<CityDto | null>(null);
|
||||
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedFormState = signal<StateLookupDto | null>(null);
|
||||
|
||||
readonly cityForm = this.formBuilder.nonNullable.group({
|
||||
countryId: ['', Validators.required],
|
||||
stateId: ['', Validators.required],
|
||||
name: ['', [Validators.required, Validators.maxLength(150)]],
|
||||
code: ['', [Validators.required, Validators.maxLength(16), Validators.pattern(/^[A-Za-z0-9_-]+$/)]],
|
||||
timezoneId: this.formBuilder.control<string | null>(null)
|
||||
});
|
||||
|
||||
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.mode()) {
|
||||
case 'create': return 'Add City';
|
||||
case 'edit': return 'Edit City';
|
||||
case 'view': return 'View City';
|
||||
}
|
||||
});
|
||||
|
||||
readonly countrySearchFn: AutocompleteSearchFn<CountryLookupDto> = (term, page) =>
|
||||
this.countryApi.autocomplete(term, page);
|
||||
readonly countryValueFn: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||
readonly countryDisplayFn: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||
|
||||
readonly stateSearchFn: AutocompleteSearchFn<StateLookupDto> = (term, page) => {
|
||||
const countryId = this.cityForm.controls.countryId.value || this.selectedFormCountry()?.id || '';
|
||||
if (!countryId) return of([]);
|
||||
return this.stateApi.autocomplete(countryId, term || '', page);
|
||||
};
|
||||
readonly stateValueFn: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
|
||||
readonly stateDisplayFn: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
|
||||
|
||||
readonly timezoneSearchFn: AutocompleteSearchFn<TimezoneLookupDto> = (term, page) =>
|
||||
this.timezoneApi.autocomplete(term, page);
|
||||
readonly timezoneValueFn: AutocompleteValueFn<TimezoneLookupDto, string> = tz => tz.id;
|
||||
readonly timezoneDisplayFn: AutocompleteDisplayFn<TimezoneLookupDto> = tz => tz.displayName;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.open()) {
|
||||
this.prepareModal(this.cityId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFormCountryChanged(country: CountryLookupDto | null): void {
|
||||
this.selectedFormCountry.set(country);
|
||||
this.cityForm.controls.countryId.setValue(country ? country.id : '');
|
||||
this.cityForm.controls.stateId.setValue('');
|
||||
this.selectedFormState.set(null);
|
||||
}
|
||||
|
||||
prepareModal(id: string | null): void {
|
||||
this.submitAttempted.set(false);
|
||||
this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null });
|
||||
this.selectedFormCountry.set(null);
|
||||
this.selectedFormState.set(null);
|
||||
|
||||
if (!id || this.mode() === 'create') {
|
||||
this.selectedCity.set(null);
|
||||
this.modalLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalLoading.set(true);
|
||||
this.cityApi.getCityById(id).pipe(
|
||||
switchMap(city => {
|
||||
this.selectedCity.set(city);
|
||||
return this.stateApi.getStateById(city.stateId).pipe(
|
||||
switchMap(state => this.countryApi.getCountryById(state.countryId).pipe(
|
||||
map(country => ({ city, state, country }))
|
||||
)),
|
||||
catchError(() => of({ city, state: null, country: null }))
|
||||
);
|
||||
}),
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: ({ city, state, country }) => {
|
||||
if (country) this.selectedFormCountry.set({ id: country.id, name: country.name, iso2: country.iso2 });
|
||||
if (state) this.selectedFormState.set({ id: state.id, name: state.name, code: state.code ?? '' });
|
||||
|
||||
this.cityForm.patchValue({
|
||||
countryId: country?.id ?? '',
|
||||
stateId: city.stateId,
|
||||
name: city.name,
|
||||
code: city.code ?? '',
|
||||
timezoneId: city.timezoneId
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load city details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveCity(): void {
|
||||
if (this.isViewMode()) {
|
||||
this.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitAttempted.set(true);
|
||||
if (this.cityForm.invalid || this.saving()) return;
|
||||
|
||||
this.saving.set(true);
|
||||
if (this.mode() === 'create') {
|
||||
const request: CreateCityRequest = {
|
||||
stateId: this.cityForm.controls.stateId.value,
|
||||
name: this.cityForm.controls.name.value.trim(),
|
||||
code: this.cityForm.controls.code.value.trim().toUpperCase(),
|
||||
timezoneId: this.cityForm.controls.timezoneId.value || null
|
||||
};
|
||||
|
||||
this.cityApi.createCity(request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('City created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'create')
|
||||
});
|
||||
} else {
|
||||
const id = this.cityId();
|
||||
if (!id) return;
|
||||
|
||||
const request: UpdateCityRequest = {
|
||||
name: this.cityForm.controls.name.value.trim(),
|
||||
code: this.cityForm.controls.code.value.trim().toUpperCase(),
|
||||
timezoneId: this.cityForm.controls.timezoneId.value || null,
|
||||
isActive: this.selectedCity()?.isActive ?? true
|
||||
};
|
||||
|
||||
this.cityApi.updateCity(id, request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('City 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.toastr.error('A city with this code already exists in this state.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} city. Please try again.`);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@ export const CITY_ENDPOINTS = {
|
||||
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`),
|
||||
update: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`),
|
||||
delete: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`),
|
||||
changeStatus: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}/status`),
|
||||
autocomplete: buildApiUrl('masterAdmin', '/v1/cities/autocomplete')
|
||||
} as const;
|
||||
|
||||
@@ -6,7 +6,8 @@ import { CITY_ENDPOINTS } from './city.endpoints';
|
||||
import {
|
||||
CityDto,
|
||||
CreateCityRequest,
|
||||
UpdateCityRequest
|
||||
UpdateCityRequest,
|
||||
UpdateCityStatusRequest
|
||||
} from '../models/city.model';
|
||||
import {
|
||||
DataTableQuery,
|
||||
@@ -40,6 +41,14 @@ export class CityService {
|
||||
return this.http.put<CityDto>(CITY_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
updateStatus(id: string, request: UpdateCityStatusRequest): Observable<CityDto> {
|
||||
return this.http.patch<CityDto>(CITY_ENDPOINTS.changeStatus(id), request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(CITY_ENDPOINTS.delete(id));
|
||||
}
|
||||
|
||||
getCityById(id: string): Observable<CityDto> {
|
||||
return this.http.get<CityDto>(CITY_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ export interface CityDto {
|
||||
id: string;
|
||||
stateId: string;
|
||||
name: string;
|
||||
state:string;
|
||||
country:string;
|
||||
state?: string | { name?: string };
|
||||
stateName?: string;
|
||||
country?: string | { name?: string };
|
||||
countryName?: string;
|
||||
code: string | null;
|
||||
timezoneId: string | null;
|
||||
isActive: boolean;
|
||||
@@ -25,4 +27,9 @@ export interface UpdateCityRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type CityModalMode = 'create' | 'edit';
|
||||
export interface UpdateCityStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type CityModalMode = 'create' | 'edit' | 'view';
|
||||
|
||||
|
||||
@@ -1,176 +1,97 @@
|
||||
<!-- Start::row-1 -->
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||
<form [formGroup]="filterForm" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="city-country-filter"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="Country"
|
||||
placeholder="Search country"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[selectedItem]="selectedCountry()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onFilterCountrySelected($event)"
|
||||
(cleared)="onFilterCountryCleared()"
|
||||
/>
|
||||
</div>
|
||||
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter($event)" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="city-country-filter"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="Country"
|
||||
placeholder="Search country"
|
||||
[searchFn]="filterCountrySearchFn"
|
||||
[valueWith]="filterCountryValueFn"
|
||||
[displayWith]="filterCountryDisplayFn"
|
||||
[selectedItem]="selectedCountry()"
|
||||
[minSearchLength]="0"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onFilterCountryChanged($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||
<app-autocomplete
|
||||
formControlName="stateId"
|
||||
inputId="city-state-filter"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="State"
|
||||
[placeholder]="filterStatePlaceholder()"
|
||||
[searchFn]="searchFilterStates"
|
||||
[displayWith]="displayState"
|
||||
[valueWith]="stateValue"
|
||||
[selectedItem]="selectedFilterState()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[disabled]="!selectedCountryId()"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onFilterStateSelected($event)"
|
||||
(cleared)="onFilterStateCleared()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-2 lg:col-span-1">
|
||||
<app-button
|
||||
action="custom"
|
||||
label="Filter"
|
||||
icon="ti ti-filter"
|
||||
variant="primary-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
||||
(buttonClicked)="applyCityFilters()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</app-filter-card>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End::row-1 -->
|
||||
|
||||
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
|
||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||
tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
|
||||
[showAddButton]="true" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||
searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)"
|
||||
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)" toolTip="Add City" />
|
||||
|
||||
<modal [open]="showCityModal()" [title]="modalTitle()" size="lg"
|
||||
[submitAction]="modalMode() === 'create' ? 'save' : 'update'" [submitLabel]="submitLabel()"
|
||||
[loadingLabel]="loadingLabel()" [loading]="saving()"
|
||||
(closed)="closeCityModal()" (submitted)="saveCity()">
|
||||
<form [formGroup]="cityForm" (ngSubmit)="saveCity()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="city-country"
|
||||
variant="floating"
|
||||
label="Country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[resolveValueFn]="resolveCountry"
|
||||
[selectedItem]="selectedFormCountry()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[required]="true"
|
||||
[readonly]="modalMode() !== 'create'"
|
||||
[validationMessages]="{ required: 'Country is required.' }"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
wrapperClass="w-full"
|
||||
(itemSelected)="onFormCountrySelected($event)"
|
||||
(cleared)="onFormCountryCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="stateId"
|
||||
inputId="city-state"
|
||||
variant="floating"
|
||||
label="State"
|
||||
[placeholder]="formStatePlaceholder()"
|
||||
[help]="'Select a country first'"
|
||||
[searchFn]="searchFormStates"
|
||||
[displayWith]="displayState"
|
||||
[valueWith]="stateValue"
|
||||
[resolveValueFn]="resolveState"
|
||||
[selectedItem]="selectedFormState()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[required]="true"
|
||||
[readonly]="modalMode() !== 'create'"
|
||||
[validationMessages]="{ required: 'State is required.' }"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
wrapperClass="w-full"
|
||||
(itemSelected)="onFormStateSelected($event)"
|
||||
(cleared)="onFormStateCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="name" inputId="city-name" label="City Name" variant="floating"
|
||||
placeholder="Name" autocomplete="off" [required]="true"
|
||||
[maxLength]="150" [validationMessages]="{
|
||||
required: 'City Name is required.',
|
||||
maxlength: 'City Name cannot exceed 150 characters.'
|
||||
}" [submitAttempted]="submitAttempted()" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="code" inputId="city-code" label="City Code" variant="floating"
|
||||
placeholder="Code" autocomplete="off" [required]="true"
|
||||
[maxLength]="16" [validationMessages]="{
|
||||
required: 'City Code is required.',
|
||||
maxlength: 'City Code cannot exceed 16 characters.',
|
||||
pattern: 'City Code can contain letters, numbers, hyphens, and underscores only.'
|
||||
}" [submitAttempted]="submitAttempted()" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="timezoneId"
|
||||
inputId="city-timezone"
|
||||
variant="floating"
|
||||
label="Timezone"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchTimezones"
|
||||
[displayWith]="displayTimezone"
|
||||
[valueWith]="timezoneValue"
|
||||
[resolveValueFn]="resolveTimezone"
|
||||
[minSearchLength]="2"
|
||||
[debounceTime]="300"
|
||||
[limit]="10"
|
||||
emptyText="No timezones found"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||
<app-autocomplete
|
||||
formControlName="stateId"
|
||||
inputId="city-state-filter"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="State"
|
||||
placeholder="Search state"
|
||||
[searchFn]="filterStateSearchFn"
|
||||
[valueWith]="filterStateValueFn"
|
||||
[displayWith]="filterStateDisplayFn"
|
||||
[selectedItem]="selectedFilterState()"
|
||||
[minSearchLength]="0"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onFilterStateChanged($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-2 lg:col-span-1">
|
||||
<app-button
|
||||
action="custom"
|
||||
label="Filter"
|
||||
icon="ti ti-filter"
|
||||
variant="primary-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
||||
(buttonClicked)="onApplyFilter($event)"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</modal>
|
||||
</app-filter-card>
|
||||
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="tableStore.rows()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="tableStore.totalRecords()"
|
||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||
[pageSize]="tableStore.queryState.pageSize()"
|
||||
tableTitle="Cities"
|
||||
buttonTitle="Add"
|
||||
[showSearch]="true"
|
||||
[showAddButton]="true"
|
||||
searchPlaceholder="Search cities..."
|
||||
[searchDebounceTime]="300"
|
||||
toolTip="Add City"
|
||||
(addClicked)="onAddCity()"
|
||||
(searchChanged)="tableStore.onSearch($event)"
|
||||
(pageChanged)="tableStore.onPageChange($event)"
|
||||
(sortChanged)="tableStore.onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
/>
|
||||
|
||||
<app-confirm-dialog
|
||||
title="Delete City"
|
||||
text="Do you really want to delete this city?"
|
||||
confirmButtonText="Delete"
|
||||
cancelButtonText="Cancel"
|
||||
(confirmed)="onDeleteConfirmed()"
|
||||
(cancelled)="onDeleteCancelled()"
|
||||
/>
|
||||
|
||||
<app-city-form-modal
|
||||
[open]="tableStore.showModal()"
|
||||
[mode]="tableStore.modalMode()"
|
||||
[cityId]="tableStore.selectedItem()?.id ?? null"
|
||||
(saved)="tableStore.refresh(); tableStore.closeModal()"
|
||||
(closed)="tableStore.closeModal()"
|
||||
/>
|
||||
|
||||
@@ -1,54 +1,34 @@
|
||||
import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import {
|
||||
Subject,
|
||||
catchError,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
finalize,
|
||||
map,
|
||||
of,
|
||||
switchMap,
|
||||
take
|
||||
} from 'rxjs';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CityDto,
|
||||
CityModalMode,
|
||||
CreateCityRequest,
|
||||
UpdateCityRequest
|
||||
} from '../../models/city.model';
|
||||
import { CityDto, UpdateCityRequest } from '../../models/city.model';
|
||||
import { CountryLookupDto } from '../../../countries/models/country.model';
|
||||
import { StateLookupDto } from '../../../states/models/state.model';
|
||||
import { TimezoneDto, TimezoneLookupDto } from '../../../timezones/models/timezone.model';
|
||||
import { CityService } from '../../../cities/data-access/city.service';
|
||||
import { CityService } from '../../data-access/city.service';
|
||||
import { CountryService } from '../../../countries/data-access/country.service';
|
||||
import { StateService } from '../../../states/data-access/state.service';
|
||||
import { TimezoneService } from '../../../timezones/data-access/timezone.service';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
|
||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTablePageEvent,
|
||||
DataTableQuery,
|
||||
DataTableRecord,
|
||||
DataTableSortEvent
|
||||
DataTableRecord
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteResolveValueFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||
import { Button } from '../../../../../shared/components/button/button';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { CityFormModalComponent } from '../../components/city-form-modal/city-form-modal';
|
||||
|
||||
interface CityTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
@@ -60,6 +40,8 @@ interface CityTableRow extends DataTableRecord {
|
||||
serialNumber: number;
|
||||
state: string;
|
||||
country: string;
|
||||
stateName: string;
|
||||
countryName: string;
|
||||
createdOn?: string;
|
||||
modifiedOn?: string | null;
|
||||
}
|
||||
@@ -67,495 +49,213 @@ interface CityTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'city-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard, Button],
|
||||
imports: [
|
||||
DataTable,
|
||||
ReactiveFormsModule,
|
||||
Autocomplete,
|
||||
FilterCard,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
CityFormModalComponent
|
||||
],
|
||||
providers: [DataTableStore],
|
||||
templateUrl: './city-list.html',
|
||||
styleUrl: './city-list.scss'
|
||||
})
|
||||
export class CityList {
|
||||
export class CityList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cityApi = inject(CityService);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly cityQueryRequests$ = new Subject<DataTableQuery>();
|
||||
readonly tableStore = inject(DataTableStore<CityDto, CityTableRow>);
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
readonly cities = signal<CityTableRow[]>([]);
|
||||
readonly selectedCountryId = signal<string | null>(null);
|
||||
readonly selectedStateId = signal<string | null>(null);
|
||||
readonly appliedCountryId = signal<string | null>(null);
|
||||
readonly appliedStateId = signal<string | null>(null);
|
||||
readonly selectedCountry = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedFilterState = signal<StateLookupDto | null>(null);
|
||||
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedFormState = signal<StateLookupDto | null>(null);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly saving = signal(false);
|
||||
readonly showCityModal = signal(false);
|
||||
readonly modalMode = signal<CityModalMode>('create');
|
||||
readonly selectedCity = signal<CityDto | null>(null);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly pendingDeleteCity = signal<CityTableRow | null>(null);
|
||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||
|
||||
readonly filterForm = this.formBuilder.nonNullable.group({
|
||||
countryId: [''],
|
||||
stateId: [{ value: '', disabled: true }]
|
||||
});
|
||||
|
||||
readonly cityForm = this.formBuilder.nonNullable.group({
|
||||
countryId: ['', Validators.required],
|
||||
stateId: ['', Validators.required],
|
||||
name: ['', [Validators.required, Validators.maxLength(150)]],
|
||||
code: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(16),
|
||||
Validators.pattern(/^[A-Za-z0-9_-]+$/)
|
||||
]
|
||||
],
|
||||
timezoneId: this.formBuilder.control<string | null>(null)
|
||||
});
|
||||
|
||||
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> =
|
||||
(term, limit) => this.timezoneApi.autocomplete(term, limit);
|
||||
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> =
|
||||
(term, limit) => this.countryApi.autocomplete(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load countries.');
|
||||
return of<CountryLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
readonly searchFilterStates: AutocompleteSearchFn<StateLookupDto> =
|
||||
(term, limit) => {
|
||||
const countryId = this.selectedCountryId();
|
||||
if (!countryId) return of<StateLookupDto[]>([]);
|
||||
return this.stateApi.autocomplete(countryId, term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load states.');
|
||||
return of<StateLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
};
|
||||
readonly searchFormStates: AutocompleteSearchFn<StateLookupDto> =
|
||||
(term, limit) => {
|
||||
const countryId = this.cityForm.controls.countryId.value;
|
||||
if (!countryId) return of<StateLookupDto[]>([]);
|
||||
return this.stateApi.autocomplete(countryId, term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load states.');
|
||||
return of<StateLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
};
|
||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||
value => this.countryApi.getCountryById(value).pipe(
|
||||
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||
);
|
||||
readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
|
||||
readonly stateValue: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
|
||||
readonly resolveState: AutocompleteResolveValueFn<StateLookupDto, string> =
|
||||
value => this.stateApi.getStateById(value).pipe(
|
||||
map(state => ({ id: state.id, name: state.name, code: state.code ?? '' }))
|
||||
);
|
||||
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> =
|
||||
timezone => `${timezone.ianaId} — ${timezone.displayName}`;
|
||||
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> =
|
||||
timezone => timezone.id;
|
||||
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> =
|
||||
value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone)));
|
||||
readonly filterStatePlaceholder = computed(() =>
|
||||
this.selectedCountryId() ? 'Search' : 'Select a country first'
|
||||
);
|
||||
readonly formStatePlaceholder = computed(() =>
|
||||
this.cityForm.controls.countryId.value ? 'Search' : 'Search'
|
||||
);
|
||||
readonly emptyMessage = computed(() =>
|
||||
this.appliedCountryId() && this.appliedStateId()
|
||||
? 'No cities found'
|
||||
: 'Select a country and state'
|
||||
);
|
||||
|
||||
readonly emptyDescription = computed(() =>
|
||||
this.appliedCountryId() && this.appliedStateId()
|
||||
? 'There are no cities available for the selected state.'
|
||||
: 'Choose a country and state to view available cities.'
|
||||
);
|
||||
readonly modalTitle = computed(() => {
|
||||
const mode = this.modalMode();
|
||||
return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City';
|
||||
});
|
||||
readonly submitLabel = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Save' : 'Update'
|
||||
);
|
||||
readonly loadingLabel = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<CityTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '70px' },
|
||||
{ key: 'name', label: 'City Name', header: 'City Name', sortable: true, align: 'left' },
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
||||
{ key: 'stateName', label: 'State', header: 'State', sortable: false },
|
||||
{ key: 'countryName', label: 'Country', header: 'Country', sortable: false },
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||
{ key: 'name', label: 'City Name', header: 'City Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' },
|
||||
{ key: 'stateName', label: 'State', header: 'State', sortable: true, headerAlign: 'center', align: 'left' },
|
||||
{ key: 'countryName', label: 'Country', header: 'Country', sortable: true, headerAlign: 'center', align: 'center' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
width: '100px',
|
||||
formatter: value => value ? 'Active' : 'Inactive',
|
||||
badgeClass: value => value === true
|
||||
? 'badge bg-success/10 text-success'
|
||||
: 'badge bg-danger/10 text-danger'
|
||||
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<CityTableRow>[]>([
|
||||
// { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
type: 'deactivate',
|
||||
label: 'Deactivate',
|
||||
icon: 'ti ti-ban',
|
||||
className: 'text-danger',
|
||||
visible: row => row.isActive
|
||||
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-check',
|
||||
className: 'text-success',
|
||||
visible: row => !row.isActive
|
||||
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
|
||||
}
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.configureCityQueries();
|
||||
this.configureFilterChanges();
|
||||
this.configureFormChanges();
|
||||
}
|
||||
readonly filterCountrySearchFn: AutocompleteSearchFn<CountryLookupDto> = (term, page) =>
|
||||
this.countryApi.autocomplete(term, page);
|
||||
readonly filterCountryValueFn: AutocompleteValueFn<CountryLookupDto, string> = c => c.id;
|
||||
readonly filterCountryDisplayFn: AutocompleteDisplayFn<CountryLookupDto> = c => c.name;
|
||||
|
||||
readonly filterStateSearchFn: AutocompleteSearchFn<StateLookupDto> = (term, page) => {
|
||||
const countryId = this.filterForm.controls.countryId.value || this.selectedCountry()?.id || '';
|
||||
if (!countryId) return of([]);
|
||||
return this.stateApi.autocomplete(countryId, term || '', page);
|
||||
};
|
||||
readonly filterStateValueFn: AutocompleteValueFn<StateLookupDto, string> = s => s.id;
|
||||
readonly filterStateDisplayFn: AutocompleteDisplayFn<StateLookupDto> = s => s.name;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCities(this.queryState.getQuery());
|
||||
this.tableStore.initialize({
|
||||
fetcher: query => {
|
||||
const countryId = this.filterForm.controls.countryId.value || null;
|
||||
const stateId = this.filterForm.controls.stateId.value || null;
|
||||
return this.cityApi.getCityDataTable(query, countryId, stateId);
|
||||
},
|
||||
mapRow: (city, serialNumber) => {
|
||||
const resolvedStateName = city.stateName
|
||||
|| (typeof city.state === 'string' ? city.state : city.state?.name)
|
||||
|| '—';
|
||||
const resolvedCountryName = city.countryName
|
||||
|| (typeof city.country === 'string' ? city.country : city.country?.name)
|
||||
|| '—';
|
||||
|
||||
return {
|
||||
id: city.id,
|
||||
stateId: city.stateId,
|
||||
name: city.name,
|
||||
code: city.code,
|
||||
timezoneId: city.timezoneId,
|
||||
isActive: city.isActive,
|
||||
serialNumber,
|
||||
state: resolvedStateName,
|
||||
country: resolvedCountryName,
|
||||
stateName: resolvedStateName,
|
||||
countryName: resolvedCountryName,
|
||||
createdOn: city.createdOn,
|
||||
modifiedOn: city.modifiedOn
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFilterCountrySelected(country: CountryLookupDto): void {
|
||||
onFilterCountryChanged(country: CountryLookupDto | null): void {
|
||||
this.selectedCountry.set(country);
|
||||
this.filterForm.controls.countryId.setValue(country ? country.id : '');
|
||||
this.filterForm.controls.stateId.setValue('');
|
||||
this.selectedFilterState.set(null);
|
||||
if (country) {
|
||||
this.filterForm.controls.stateId.enable();
|
||||
} else {
|
||||
this.filterForm.controls.stateId.disable();
|
||||
}
|
||||
}
|
||||
|
||||
onFilterCountryCleared(): void {
|
||||
this.selectedCountry.set(null);
|
||||
}
|
||||
|
||||
onFilterStateSelected(state: StateLookupDto): void {
|
||||
onFilterStateChanged(state: StateLookupDto | null): void {
|
||||
this.selectedFilterState.set(state);
|
||||
}
|
||||
|
||||
onFilterStateCleared(): void {
|
||||
this.selectedFilterState.set(null);
|
||||
}
|
||||
|
||||
applyCityFilters(): void {
|
||||
const countryId = this.filterForm.controls.countryId.value || null;
|
||||
const stateId = this.filterForm.controls.stateId.value || null;
|
||||
|
||||
this.appliedCountryId.set(countryId);
|
||||
this.appliedStateId.set(stateId);
|
||||
|
||||
this.loadCities(this.queryState.setPage({
|
||||
pageIndex: 1,
|
||||
pageSize: this.queryState.pageSize()
|
||||
}));
|
||||
}
|
||||
|
||||
onFormCountrySelected(country: CountryLookupDto): void {
|
||||
this.selectedFormCountry.set(country);
|
||||
}
|
||||
|
||||
onFormCountryCleared(): void {
|
||||
this.selectedFormCountry.set(null);
|
||||
}
|
||||
|
||||
onFormStateSelected(state: StateLookupDto): void {
|
||||
this.selectedFormState.set(state);
|
||||
}
|
||||
|
||||
onFormStateCleared(): void {
|
||||
this.selectedFormState.set(null);
|
||||
}
|
||||
|
||||
loadCities(query: DataTableQuery): void {
|
||||
this.cityQueryRequests$.next(query);
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
this.loadCities(this.queryState.setSearch(value.trim()));
|
||||
}
|
||||
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
this.loadCities(this.queryState.setPage(event));
|
||||
}
|
||||
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
this.loadCities(this.queryState.setSort(event));
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<CityTableRow>): void {
|
||||
const city = this.toCityDto(event.row);
|
||||
switch (event.action.type) {
|
||||
case 'edit':
|
||||
this.openExistingCity(city, 'edit');
|
||||
break;
|
||||
case 'activate':
|
||||
this.updateCityStatus(city, true);
|
||||
break;
|
||||
case 'deactivate':
|
||||
this.updateCityStatus(city, false);
|
||||
break;
|
||||
onApplyFilter(event?: Event): void {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
this.tableStore.refresh();
|
||||
}
|
||||
|
||||
onResetFilter(): void {
|
||||
this.filterForm.reset({ countryId: '', stateId: '' });
|
||||
this.filterForm.controls.stateId.disable();
|
||||
this.selectedCountry.set(null);
|
||||
this.selectedFilterState.set(null);
|
||||
this.tableStore.reset();
|
||||
}
|
||||
|
||||
onAddCity(): void {
|
||||
this.modalMode.set('create');
|
||||
this.selectedCity.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.selectedFormState.set(null);
|
||||
this.cityForm.enable({ emitEvent: false });
|
||||
this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { emitEvent: false });
|
||||
this.resetFormState();
|
||||
this.showCityModal.set(true);
|
||||
this.tableStore.openCreateModal();
|
||||
}
|
||||
|
||||
closeCityModal(): void {
|
||||
if (this.saving()) return;
|
||||
this.showCityModal.set(false);
|
||||
this.selectedCity.set(null);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.selectedFormState.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
onActionClick(event: DataTableActionEvent<CityTableRow>): 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.requestDeleteCity(event.row);
|
||||
if (event.action.type === 'activate') this.changeCityStatus(event.row, true);
|
||||
if (event.action.type === 'deactivate') this.changeCityStatus(event.row, false);
|
||||
}
|
||||
|
||||
saveCity(): void {
|
||||
if (this.saving()) return;
|
||||
if (this.cityForm.invalid) {
|
||||
this.submitAttempted.set(true);
|
||||
this.cityForm.markAllAsTouched();
|
||||
this.focusFirstInvalidControl();
|
||||
return;
|
||||
}
|
||||
onDeleteConfirmed(): void {
|
||||
const city = this.pendingDeleteCity();
|
||||
if (!city) return;
|
||||
this.pendingDeleteCity.set(null);
|
||||
this.deletingId.set(city.id);
|
||||
|
||||
this.saving.set(true);
|
||||
const city = this.selectedCity();
|
||||
const request$ = this.modalMode() === 'create'
|
||||
? this.cityApi.createCity(this.buildCreateRequest())
|
||||
: city
|
||||
? this.cityApi.updateCity(city.id, this.buildUpdateRequest(city.isActive))
|
||||
: null;
|
||||
|
||||
if (!request$) {
|
||||
this.saving.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
request$.pipe(finalize(() => this.saving.set(false))).subscribe({
|
||||
this.cityApi.delete(city.id).pipe(
|
||||
finalize(() => this.deletingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(
|
||||
this.modalMode() === 'create'
|
||||
? 'City saved successfully.'
|
||||
: 'City updated successfully.'
|
||||
);
|
||||
this.showCityModal.set(false);
|
||||
this.selectedCity.set(null);
|
||||
this.loadCities(this.queryState.getQuery());
|
||||
this.toastr.success('City deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
let errorMsg = 'Unable to delete city.';
|
||||
if (err?.status === 409) {
|
||||
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete city because it is currently in use or referenced by other records.';
|
||||
} else if (err?.status === 404) {
|
||||
errorMsg = err?.error?.message || 'City not found or has already been deleted.';
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private configureCityQueries(): void {
|
||||
this.cityQueryRequests$.pipe(
|
||||
switchMap(query => {
|
||||
const stateId = this.appliedStateId();
|
||||
const countryId = this.appliedCountryId();
|
||||
return this.cityApi.getCityDataTable(query, countryId, stateId).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load cities.');
|
||||
this.clearGrid();
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(response => {
|
||||
if (!response) return;
|
||||
const query = this.queryState.getQuery();
|
||||
if (response.draw !== query.draw) return;
|
||||
|
||||
this.cities.set(response.rows.map((city, index) => ({
|
||||
...city,
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
||||
})));
|
||||
this.totalRecords.set(response.filtered);
|
||||
});
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteCity.set(null);
|
||||
}
|
||||
|
||||
private configureFilterChanges(): void {
|
||||
this.filterForm.controls.countryId.valueChanges.pipe(
|
||||
distinctUntilChanged(),
|
||||
private requestDeleteCity(city: CityTableRow): void {
|
||||
this.pendingDeleteCity.set(city);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
private changeCityStatus(city: CityTableRow, activate: boolean): void {
|
||||
this.statusChangingId.set(city.id);
|
||||
|
||||
this.cityApi.updateStatus(city.id, { isActive: activate }).pipe(
|
||||
finalize(() => this.statusChangingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(countryId => {
|
||||
if (!countryId || this.selectedCountry()?.id !== countryId) {
|
||||
this.selectedCountry.set(null);
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`City ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} city.`;
|
||||
this.toastr.error(msg);
|
||||
}
|
||||
|
||||
this.selectedCountryId.set(countryId || null);
|
||||
this.selectedStateId.set(null);
|
||||
this.selectedFilterState.set(null);
|
||||
|
||||
this.filterForm.controls.stateId.reset('', { emitEvent: false });
|
||||
countryId
|
||||
? this.filterForm.controls.stateId.enable({ emitEvent: false })
|
||||
: this.filterForm.controls.stateId.disable({ emitEvent: false });
|
||||
|
||||
});
|
||||
|
||||
this.filterForm.controls.stateId.valueChanges.pipe(
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(stateId => {
|
||||
if (!stateId || this.selectedFilterState()?.id !== stateId) {
|
||||
this.selectedFilterState.set(null);
|
||||
}
|
||||
|
||||
this.selectedStateId.set(stateId || null);
|
||||
});
|
||||
}
|
||||
|
||||
private configureFormChanges(): void {
|
||||
this.cityForm.controls.countryId.valueChanges.pipe(
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(countryId => {
|
||||
if (!this.showCityModal() || this.modalMode() !== 'create') return;
|
||||
|
||||
if (!countryId || this.selectedFormCountry()?.id !== countryId) {
|
||||
this.selectedFormCountry.set(null);
|
||||
}
|
||||
|
||||
this.selectedFormState.set(null);
|
||||
this.cityForm.controls.stateId.reset('', { emitEvent: false });
|
||||
this.cityForm.controls.stateId.enable({ emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
private openExistingCity(city: CityDto, mode: 'edit'): void {
|
||||
this.cityApi.getCityById(city.id).pipe(
|
||||
switchMap(details => this.stateApi.getStateById(details.stateId).pipe(
|
||||
map(stateDetails => ({ details, countryId: stateDetails.countryId }))
|
||||
)),
|
||||
take(1)
|
||||
).subscribe(({ details, countryId }) => {
|
||||
this.selectedCity.set(details);
|
||||
this.modalMode.set(mode);
|
||||
this.submitAttempted.set(false);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.selectedFormState.set(null);
|
||||
this.cityForm.enable({ emitEvent: false });
|
||||
this.cityForm.reset({
|
||||
countryId,
|
||||
stateId: details.stateId,
|
||||
name: details.name ?? '',
|
||||
code: details.code ?? '',
|
||||
timezoneId: details.timezoneId
|
||||
}, { emitEvent: false });
|
||||
// this.cityForm.controls.countryId.disable({ emitEvent: false });
|
||||
// this.cityForm.controls.stateId.disable({ emitEvent: false });
|
||||
this.resetFormState();
|
||||
this.showCityModal.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
private updateCityStatus(city: CityDto, isActive: boolean): void {
|
||||
this.cityApi.updateCity(city.id, {
|
||||
name: city.name.trim(),
|
||||
code: city.code?.trim().toUpperCase() ?? '',
|
||||
timezoneId: city.timezoneId,
|
||||
isActive
|
||||
}).subscribe(() => {
|
||||
this.toastr.success(
|
||||
isActive ? 'City activated successfully.' : 'City deactivated successfully.'
|
||||
);
|
||||
this.loadCities(this.queryState.getQuery());
|
||||
});
|
||||
}
|
||||
|
||||
private buildCreateRequest(): CreateCityRequest {
|
||||
const value = this.cityForm.getRawValue();
|
||||
return {
|
||||
stateId: value.stateId,
|
||||
name: value.name.trim(),
|
||||
code: value.code.trim().toUpperCase(),
|
||||
timezoneId: value.timezoneId
|
||||
};
|
||||
}
|
||||
|
||||
private buildUpdateRequest(isActive: boolean): UpdateCityRequest {
|
||||
const value = this.cityForm.getRawValue();
|
||||
return {
|
||||
name: value.name.trim(),
|
||||
code: value.code.trim().toUpperCase(),
|
||||
timezoneId: value.timezoneId,
|
||||
isActive
|
||||
};
|
||||
}
|
||||
|
||||
private clearGrid(): void {
|
||||
this.cities.set([]);
|
||||
this.totalRecords.set(0);
|
||||
}
|
||||
|
||||
private resetFormState(): void {
|
||||
this.cityForm.markAsPristine();
|
||||
this.cityForm.markAsUntouched();
|
||||
this.cityForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
control?.focus();
|
||||
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
|
||||
private toCityDto(row: CityTableRow): CityDto {
|
||||
return {
|
||||
id: row.id,
|
||||
stateId: row.stateId,
|
||||
name: row.name,
|
||||
state: row.state,
|
||||
country: row.country,
|
||||
code: row.code,
|
||||
timezoneId: row.timezoneId,
|
||||
isActive: row.isActive,
|
||||
createdOn: row.createdOn,
|
||||
modifiedOn: row.modifiedOn
|
||||
};
|
||||
}
|
||||
|
||||
private toTimezoneLookup(timezone: TimezoneDto): TimezoneLookupDto {
|
||||
return {
|
||||
id: timezone.id,
|
||||
ianaId: timezone.ianaId,
|
||||
displayName: timezone.displayName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<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)="saveCountry()"
|
||||
>
|
||||
@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 country...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="countryForm" (ngSubmit)="saveCountry()" 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="country-name"
|
||||
variant="floating"
|
||||
label="Country Name"
|
||||
placeholder="Name"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="150"
|
||||
[submitAttempted]="countrySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Country name is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="iso2"
|
||||
inputId="country-iso2"
|
||||
variant="floating"
|
||||
label="ISO2 Code"
|
||||
placeholder="Code"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="2"
|
||||
[submitAttempted]="countrySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'ISO2 code is required.', pattern: 'ISO2 must be exactly 2 letters.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="iso3"
|
||||
inputId="country-iso3"
|
||||
variant="floating"
|
||||
label="ISO3 Code"
|
||||
placeholder="Code"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="3"
|
||||
[submitAttempted]="countrySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'ISO3 code is required.', pattern: 'ISO3 must be exactly 3 letters.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="phoneCode"
|
||||
inputId="country-phone-code"
|
||||
variant="floating"
|
||||
label="Phone Code"
|
||||
placeholder="+1"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="16"
|
||||
[submitAttempted]="countrySubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="defaultCurrencyId"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
inputId="country-currency-id"
|
||||
label="Default Currency"
|
||||
placeholder="Select currency"
|
||||
[readonly]="isViewMode()"
|
||||
[submitAttempted]="countrySubmitAttempted()"
|
||||
[searchFn]="currencySearchFn"
|
||||
[valueWith]="currencyValueFn"
|
||||
[displayWith]="currencyDisplayFn"
|
||||
[selectedItem]="selectedCurrency()"
|
||||
(itemSelected)="selectedCurrency.set($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
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 { ToastrService } from 'ngx-toastr';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CountryDto,
|
||||
CountryModalMode,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest
|
||||
} from '../../models/country.model';
|
||||
import { CurrencyLookupDto, CurrencyService } from '../../../currencies/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';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-country-form-modal',
|
||||
standalone: true,
|
||||
imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete],
|
||||
templateUrl: './country-form-modal.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CountryFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<CountryModalMode>('create');
|
||||
readonly countryId = input<string | null>(null);
|
||||
|
||||
readonly saved = output<void>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly countrySubmitAttempted = signal(false);
|
||||
readonly selectedCountry = signal<CountryDto | null>(null);
|
||||
readonly selectedCurrency = signal<CurrencyLookupDto | null>(null);
|
||||
|
||||
readonly countryForm = this.formBuilder.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(150)]],
|
||||
iso2: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{2}$/)]],
|
||||
iso3: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{3}$/)]],
|
||||
phoneCode: [
|
||||
'',
|
||||
[Validators.maxLength(16), Validators.pattern(/^\+?[0-9]{1,15}$/)]
|
||||
],
|
||||
defaultCurrencyId: this.formBuilder.control<string | null>(null)
|
||||
});
|
||||
|
||||
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.mode()) {
|
||||
case 'create': return 'Add Country';
|
||||
case 'edit': return 'Edit Country';
|
||||
case 'view': return 'View Country';
|
||||
}
|
||||
});
|
||||
|
||||
readonly currencySearchFn: AutocompleteSearchFn<CurrencyLookupDto> = (term, page) =>
|
||||
this.currencyApi.autocomplete(term, page);
|
||||
readonly currencyValueFn: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
|
||||
readonly currencyDisplayFn: AutocompleteDisplayFn<CurrencyLookupDto> = currency =>
|
||||
`${currency.code} - ${currency.name}`;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.open()) {
|
||||
this.prepareModal(this.countryId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prepareModal(id: string | null): void {
|
||||
this.countrySubmitAttempted.set(false);
|
||||
this.countryForm.reset({ name: '', iso2: '', iso3: '', phoneCode: '', defaultCurrencyId: null });
|
||||
this.selectedCurrency.set(null);
|
||||
|
||||
if (!id || this.mode() === 'create') {
|
||||
this.selectedCountry.set(null);
|
||||
this.modalLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalLoading.set(true);
|
||||
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 }))
|
||||
);
|
||||
}),
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: ({ country, currency }) => {
|
||||
if (currency) {
|
||||
this.selectedCurrency.set({ id: currency.id, code: currency.code, name: currency.name, symbol: currency.symbol });
|
||||
}
|
||||
this.countryForm.patchValue({
|
||||
name: country.name,
|
||||
iso2: country.iso2,
|
||||
iso3: country.iso3,
|
||||
phoneCode: country.phoneCode ?? '',
|
||||
defaultCurrencyId: country.defaultCurrencyId
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load country details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveCountry(): void {
|
||||
if (this.isViewMode()) {
|
||||
this.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.countrySubmitAttempted.set(true);
|
||||
if (this.countryForm.invalid || this.saving()) return;
|
||||
|
||||
this.saving.set(true);
|
||||
if (this.mode() === 'create') {
|
||||
const request: CreateCountryRequest = {
|
||||
name: this.countryForm.controls.name.value.trim(),
|
||||
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
|
||||
};
|
||||
|
||||
this.countryApi.createCountry(request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Country created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'create')
|
||||
});
|
||||
} else {
|
||||
const id = this.countryId();
|
||||
if (!id) return;
|
||||
|
||||
const request: UpdateCountryRequest = {
|
||||
name: this.countryForm.controls.name.value.trim(),
|
||||
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,
|
||||
isActive: this.selectedCountry()?.isActive ?? true
|
||||
};
|
||||
|
||||
this.countryApi.updateCountry(id, request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Country 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.toastr.error('A country with this ISO code already exists.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} country. Please try again.`);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CountryLookupDto,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
UpdateCountryStatusRequest,
|
||||
} from '../models/country.model';
|
||||
import { COUNTRY_ENDPOINTS } from './country.endpoints';
|
||||
|
||||
@@ -32,6 +33,14 @@ export class CountryService {
|
||||
return this.http.put<CountryDto>(COUNTRY_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
updateStatus(id: string, request: UpdateCountryStatusRequest): Observable<CountryDto> {
|
||||
return this.http.patch<CountryDto>(COUNTRY_ENDPOINTS.changeStatus(id), request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(COUNTRY_ENDPOINTS.delete(id));
|
||||
}
|
||||
|
||||
getCountryById(id: string): Observable<CountryDto> {
|
||||
return this.http.get<CountryDto>(COUNTRY_ENDPOINTS.getById(id));
|
||||
}
|
||||
@@ -42,3 +51,4 @@ export class CountryService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,4 +28,9 @@ export interface UpdateCountryRequest extends CreateCountryRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type CountryModalMode = 'create' | 'edit';
|
||||
export interface UpdateCountryStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type CountryModalMode = 'create' | 'edit' | 'view';
|
||||
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
<app-data-table [columns]="columns()" [rows]="countries()" [actions]="actions()"
|
||||
(addClicked)="onAddCountry()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
|
||||
[pageSize]="queryState.pageSize()" tableTitle="Countries" buttonTitle="Add"
|
||||
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search countries..." [searchDebounceTime]="300"
|
||||
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)" toolTip="Add Country">
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="tableStore.rows()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="tableStore.totalRecords()"
|
||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||
[pageSize]="tableStore.queryState.pageSize()"
|
||||
tableTitle="Countries"
|
||||
buttonTitle="Add"
|
||||
[showSearch]="true"
|
||||
[showAddButton]="true"
|
||||
searchPlaceholder="Search countries..."
|
||||
[searchDebounceTime]="300"
|
||||
toolTip="Add Country"
|
||||
(addClicked)="onAddCountry()"
|
||||
(searchChanged)="tableStore.onSearch($event)"
|
||||
(pageChanged)="tableStore.onPageChange($event)"
|
||||
(sortChanged)="tableStore.onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
>
|
||||
<ng-template appDataTableCell="name" let-row let-value="value">
|
||||
<div class="flex items-center gap-2">
|
||||
@if (getFlagUrl(row.iso2); as flagUrl) {
|
||||
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
|
||||
(error)="onFlagError($event)" />
|
||||
<img
|
||||
[src]="flagUrl"
|
||||
[alt]="value + ' flag'"
|
||||
class="w-6 h-[18px] object-cover rounded-sm shrink-0"
|
||||
(error)="onFlagError($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<span class="font-semibold">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span class="font-semibold">{{ value }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-data-table>
|
||||
@@ -27,70 +42,10 @@
|
||||
(cancelled)="onDeleteCancelled()"
|
||||
/>
|
||||
|
||||
<modal [open]="showCountryModal()" [title]="countryModalTitle()" size="md"
|
||||
[submitAction]="countrySubmitAction()" [submitLabel]="countrySubmitLabel()" [loadingLabel]="countryLoadingLabel()"
|
||||
[loading]="saving()" (closed)="closeCountryModal()"
|
||||
(submitted)="saveCountry()">
|
||||
<form [formGroup]="countryForm" (ngSubmit)="saveCountry()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-6">
|
||||
<app-form-input formControlName="name" inputId="country-name" label="Country Name" variant="floating"
|
||||
placeholder="Name" autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
|
||||
required: 'Country Name is required.',
|
||||
maxlength: 'Country Name cannot exceed 150 characters.'
|
||||
}" [submitAttempted]="countrySubmitAttempted()" />
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="defaultCurrencyId"
|
||||
inputId="country-default-currency-id"
|
||||
variant="floating"
|
||||
label="Currency"
|
||||
placeholder="e.g.: USD"
|
||||
[searchFn]="searchCurrencies"
|
||||
[displayWith]="displayCurrency"
|
||||
[valueWith]="currencyValue"
|
||||
[selectedItem]="selectedCurrency()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="10"
|
||||
[clearable]="true"
|
||||
emptyText="No currencies found"
|
||||
typeToSearchText="Type to search currencies"
|
||||
[submitAttempted]="countrySubmitAttempted()"
|
||||
wrapperClass="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="iso2" inputId="country-iso2" label="ISO2 Code" placeholder="e.g.: IN" variant="floating"
|
||||
autocomplete="off" [required]="true" [minLength]="2" [maxLength]="2"
|
||||
[validationMessages]="{
|
||||
required: 'ISO2 Code is required.',
|
||||
minlength: 'ISO2 Code must contain exactly 2 letters.',
|
||||
maxlength: 'ISO2 Code must contain exactly 2 letters.',
|
||||
pattern: 'ISO2 Code can contain letters only.'
|
||||
}" [submitAttempted]="countrySubmitAttempted()" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="iso3" inputId="country-iso3" label="ISO3 Code" placeholder="e.g.: IND" variant="floating"
|
||||
autocomplete="off" [required]="true" [minLength]="3" [maxLength]="3"
|
||||
[validationMessages]="{
|
||||
required: 'ISO3 Code is required.',
|
||||
minlength: 'ISO3 Code must contain exactly 3 letters.',
|
||||
maxlength: 'ISO3 Code must contain exactly 3 letters.',
|
||||
pattern: 'ISO3 Code can contain letters only.'
|
||||
}" [submitAttempted]="countrySubmitAttempted()" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="phoneCode" inputId="country-phone-code" label="Phone Code" type="tel" variant="floating"
|
||||
inputMode="tel" placeholder="e.g.: +91" autocomplete="off" [maxLength]="16" [validationMessages]="{
|
||||
maxlength: 'Phone Code cannot exceed 16 characters.',
|
||||
pattern: 'Phone Code can contain an optional plus sign, digits, hyphens, and spaces only.'
|
||||
}" [submitAttempted]="countrySubmitAttempted()" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modal>
|
||||
<app-country-form-modal
|
||||
[open]="tableStore.showModal()"
|
||||
[mode]="tableStore.modalMode()"
|
||||
[countryId]="tableStore.selectedItem()?.id ?? null"
|
||||
(saved)="tableStore.refresh()"
|
||||
(closed)="tableStore.closeModal()"
|
||||
/>
|
||||
|
||||
@@ -1,42 +1,21 @@
|
||||
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CountryDto,
|
||||
CountryModalMode,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest
|
||||
} from '../../models/country.model';
|
||||
import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api';
|
||||
import { CountryDto, UpdateCountryRequest } from '../../models/country.model';
|
||||
import { CountryService } from '../../data-access/country.service';
|
||||
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTablePageEvent,
|
||||
DataTableQuery,
|
||||
DataTableRecord,
|
||||
DataTableSortEvent
|
||||
DataTableRecord
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { CountryFormModalComponent } from '../../components/country-form-modal/country-form-modal';
|
||||
|
||||
interface CountryTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
@@ -54,380 +33,110 @@ interface CountryTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'country-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog],
|
||||
imports: [DataTable, DataTableCellDirective, ConfirmDialog, CountryFormModalComponent],
|
||||
providers: [DataTableStore],
|
||||
templateUrl: './country-list.html',
|
||||
styleUrl: './country-list.scss',
|
||||
})
|
||||
export class CountryList {
|
||||
export class CountryList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly countryQueryRequests$ = new Subject<DataTableQuery>();
|
||||
readonly tableStore = inject(DataTableStore<CountryDto, CountryTableRow>);
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
|
||||
readonly countries = signal<CountryTableRow[]>([]);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly filteredRecords = signal(0);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly showCountryModal = signal(false);
|
||||
readonly countryModalMode = signal<CountryModalMode>('create');
|
||||
readonly selectedCountryId = signal<string | null>(null);
|
||||
readonly selectedCountry = signal<CountryDto | null>(null);
|
||||
readonly selectedCurrency = signal<CurrencyLookupDto | null>(null);
|
||||
readonly countrySubmitAttempted = signal(false);
|
||||
readonly pendingDeleteCountry = signal<CountryDto | null>(null);
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly pendingDeleteCountry = signal<CountryTableRow | null>(null);
|
||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||
|
||||
readonly countryForm = this.formBuilder.nonNullable.group({
|
||||
name: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(150)
|
||||
]
|
||||
],
|
||||
iso2: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^[A-Za-z]{2}$/)
|
||||
]
|
||||
],
|
||||
iso3: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^[A-Za-z]{3}$/)
|
||||
]
|
||||
],
|
||||
phoneCode: [
|
||||
'',
|
||||
[
|
||||
Validators.maxLength(16),
|
||||
Validators.pattern(/^\+?[0-9\- ]{1,15}$/)
|
||||
]
|
||||
],
|
||||
defaultCurrencyId: this.formBuilder.control<string | null>(null)
|
||||
});
|
||||
|
||||
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> =
|
||||
(term, limit) => this.currencyApi.autocomplete(term, limit);
|
||||
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = currency => {
|
||||
const baseLabel = [currency.code, currency.name].filter(Boolean).join(' - ');
|
||||
|
||||
return currency.symbol?.trim()
|
||||
? `${baseLabel} (${currency.symbol})`
|
||||
: baseLabel;
|
||||
};
|
||||
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
|
||||
|
||||
readonly countryModalTitle = computed(() =>
|
||||
this.countryModalMode() === 'create'
|
||||
? 'Add Country'
|
||||
: 'Edit Country'
|
||||
);
|
||||
|
||||
readonly countrySubmitLabel = computed(() =>
|
||||
this.countryModalMode() === 'create'
|
||||
? 'Save'
|
||||
: 'Update'
|
||||
);
|
||||
|
||||
readonly countryLoadingLabel = computed(() =>
|
||||
this.countryModalMode() === 'create'
|
||||
? 'Saving...'
|
||||
: 'Updating...'
|
||||
);
|
||||
|
||||
readonly countrySubmitAction = computed<'save' | 'update'>(() =>
|
||||
this.countryModalMode() === 'create'
|
||||
? 'save'
|
||||
: 'update'
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<CountryTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
|
||||
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
|
||||
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
|
||||
{ key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true },
|
||||
{ key: 'currencyName', label: 'Default Currency', header: 'Default Currency', sortable: true, align: 'left' },
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true,
|
||||
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary'
|
||||
},
|
||||
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true,
|
||||
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary'
|
||||
},
|
||||
{
|
||||
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',
|
||||
key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true, headerAlign: 'center', align: 'center',
|
||||
formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—'
|
||||
},
|
||||
{
|
||||
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
|
||||
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
|
||||
formatter: value => value ? 'Active' : 'Inactive'
|
||||
}
|
||||
]);
|
||||
|
||||
readonly actions = signal<DataTableAction<CountryTableRow>[]>([
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
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: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ti ti-trash',
|
||||
className: 'text-danger',
|
||||
visible: row => row.isActive
|
||||
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: 'activate',
|
||||
label: 'Activate',
|
||||
icon: 'ti ti-check',
|
||||
className: 'text-success',
|
||||
visible: row => !row.isActive
|
||||
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
}
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.countryQueryRequests$
|
||||
.pipe(
|
||||
switchMap(query =>
|
||||
this.countryApi.getCountryDataTable(query).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load countries.');
|
||||
this.clearCountryGrid();
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.queryState.getQuery();
|
||||
|
||||
if (response.draw !== query.draw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const countriesWithSerialNumbers: CountryTableRow[] = response.rows.map((country, index) => ({
|
||||
...country,
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
||||
}));
|
||||
|
||||
this.countries.set(countriesWithSerialNumbers);
|
||||
this.totalRecords.set(response.total);
|
||||
this.filteredRecords.set(response.filtered);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCountries(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
loadCountries(query: DataTableQuery): void {
|
||||
this.countryQueryRequests$.next(query);
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
const query = this.queryState.setSearch(value.trim());
|
||||
this.loadCountries(query);
|
||||
}
|
||||
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
const query = this.queryState.setPage(event);
|
||||
this.loadCountries(query);
|
||||
}
|
||||
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
const query = this.queryState.setSort(event);
|
||||
this.loadCountries(query);
|
||||
}
|
||||
|
||||
onRefresh(): void {
|
||||
const currentQuery = this.queryState.getQuery();
|
||||
|
||||
this.loadCountries({
|
||||
...currentQuery,
|
||||
draw: currentQuery.draw + 1
|
||||
this.tableStore.initialize({
|
||||
fetcher: query => this.countryApi.getCountryDataTable(query)
|
||||
});
|
||||
}
|
||||
|
||||
onReset(): void {
|
||||
const query = this.queryState.reset();
|
||||
this.loadCountries(query);
|
||||
onAddCountry(): void {
|
||||
this.tableStore.openCreateModal();
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<CountryTableRow>): 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.requestDeleteCountry(event.row);
|
||||
if (event.action.type === 'activate') this.changeCountryStatus(event.row, true);
|
||||
if (event.action.type === 'deactivate') this.changeCountryStatus(event.row, false);
|
||||
}
|
||||
|
||||
onDeleteConfirmed(): void {
|
||||
const country = this.pendingDeleteCountry();
|
||||
|
||||
if (!country) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!country) return;
|
||||
this.pendingDeleteCountry.set(null);
|
||||
this.deleteCountry(country);
|
||||
this.deletingId.set(country.id);
|
||||
|
||||
this.countryApi.delete(country.id).pipe(
|
||||
finalize(() => this.deletingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Country deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
let errorMsg = 'Unable to delete country.';
|
||||
if (err?.status === 409) {
|
||||
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete country because it is currently in use or referenced by other records.';
|
||||
} else if (err?.status === 404) {
|
||||
errorMsg = err?.error?.message || 'Country not found or has already been deleted.';
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteCountry.set(null);
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<CountryTableRow>): void {
|
||||
const country = this.toCountryDto(event.row);
|
||||
|
||||
switch (event.action.type) {
|
||||
case 'view':
|
||||
this.viewCountry(country);
|
||||
break;
|
||||
case 'edit':
|
||||
this.openEditCountry(country);
|
||||
break;
|
||||
case 'delete':
|
||||
this.requestDeleteCountry(country);
|
||||
break;
|
||||
case 'activate':
|
||||
this.activateCountry(country);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onAddCountry(): void {
|
||||
this.countryModalMode.set('create');
|
||||
this.selectedCountryId.set(null);
|
||||
this.selectedCountry.set(null);
|
||||
this.selectedCurrency.set(null);
|
||||
this.countrySubmitAttempted.set(false);
|
||||
|
||||
this.countryForm.reset({
|
||||
name: '',
|
||||
iso2: '',
|
||||
iso3: '',
|
||||
phoneCode: '',
|
||||
defaultCurrencyId: null
|
||||
});
|
||||
this.resetCountryFormState();
|
||||
|
||||
this.showCountryModal.set(true);
|
||||
}
|
||||
|
||||
closeCountryModal(): void {
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showCountryModal.set(false);
|
||||
this.selectedCountryId.set(null);
|
||||
this.selectedCountry.set(null);
|
||||
this.selectedCurrency.set(null);
|
||||
this.countrySubmitAttempted.set(false);
|
||||
}
|
||||
|
||||
saveCountry(): void {
|
||||
if (this.countryForm.invalid) {
|
||||
this.countrySubmitAttempted.set(true);
|
||||
this.countryForm.markAllAsTouched();
|
||||
this.focusFirstInvalidCountryControl();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
|
||||
if (this.countryModalMode() === 'create') {
|
||||
this.countryApi
|
||||
.createCountry(this.buildCreateCountryRequest())
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Country saved successfully.');
|
||||
this.finishCountrySave();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const countryId = this.selectedCountryId();
|
||||
|
||||
if (!countryId) {
|
||||
this.saving.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.countryApi
|
||||
.updateCountry(
|
||||
countryId,
|
||||
this.buildUpdateCountryRequest(this.selectedCountry()?.isActive ?? true)
|
||||
)
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Country updated successfully.');
|
||||
this.finishCountrySave();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openEditCountry(country: CountryDto): void {
|
||||
this.countryModalMode.set('edit');
|
||||
this.selectedCountryId.set(country.id);
|
||||
this.selectedCountry.set(null);
|
||||
this.selectedCurrency.set(null);
|
||||
this.countrySubmitAttempted.set(false);
|
||||
|
||||
this.countryApi
|
||||
.getCountryById(country.id)
|
||||
.pipe(
|
||||
switchMap(countryDetails => {
|
||||
const currencyId = countryDetails.defaultCurrencyId;
|
||||
|
||||
if (!currencyId) {
|
||||
return of({ countryDetails, currency: null });
|
||||
}
|
||||
|
||||
return this.currencyApi.getCurrencyById(currencyId).pipe(
|
||||
map(currency => ({ countryDetails, currency })),
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load the selected currency.');
|
||||
return of({ countryDetails, currency: null });
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ countryDetails, currency }) => {
|
||||
this.selectedCountry.set(countryDetails);
|
||||
this.selectedCurrency.set(currency);
|
||||
this.countryForm.reset({
|
||||
name: countryDetails.name ?? '',
|
||||
iso2: countryDetails.iso2 ?? '',
|
||||
iso3: countryDetails.iso3 ?? '',
|
||||
phoneCode: countryDetails.phoneCode ?? '',
|
||||
defaultCurrencyId: countryDetails.defaultCurrencyId ?? null
|
||||
});
|
||||
this.resetCountryFormState();
|
||||
|
||||
this.showCountryModal.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFlagUrl(iso2: string | null | undefined): string {
|
||||
const code = iso2?.trim().toLowerCase();
|
||||
|
||||
return code && /^[a-z]{2}$/.test(code)
|
||||
? `https://flagcdn.com/24x18/${code}.png`
|
||||
: '';
|
||||
@@ -438,122 +147,26 @@ export class CountryList {
|
||||
image.style.display = 'none';
|
||||
}
|
||||
|
||||
private viewCountry(country: CountryDto): void {
|
||||
this.openEditCountry(country);
|
||||
}
|
||||
|
||||
private requestDeleteCountry(country: CountryDto): void {
|
||||
private requestDeleteCountry(country: CountryTableRow): void {
|
||||
this.pendingDeleteCountry.set(country);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
private deleteCountry(country: CountryDto): void {
|
||||
this.updateCountryStatus(country, false);
|
||||
}
|
||||
private changeCountryStatus(country: CountryTableRow, activate: boolean): void {
|
||||
this.statusChangingId.set(country.id);
|
||||
|
||||
private activateCountry(country: CountryDto): void {
|
||||
this.updateCountryStatus(country, true);
|
||||
}
|
||||
|
||||
private buildCreateCountryRequest(): CreateCountryRequest {
|
||||
const value = this.countryForm.getRawValue();
|
||||
|
||||
return {
|
||||
name: value.name.trim(),
|
||||
iso2: value.iso2.trim().toUpperCase(),
|
||||
iso3: value.iso3.trim().toUpperCase(),
|
||||
phoneCode: this.nullWhenBlank(value.phoneCode),
|
||||
defaultCurrencyId: this.nullWhenBlank(value.defaultCurrencyId)
|
||||
};
|
||||
}
|
||||
|
||||
private buildUpdateCountryRequest(isActive: boolean): UpdateCountryRequest {
|
||||
return {
|
||||
...this.buildCreateCountryRequest(),
|
||||
isActive
|
||||
};
|
||||
}
|
||||
|
||||
private countryToUpdateRequest(country: CountryDto, isActive: boolean): UpdateCountryRequest {
|
||||
return {
|
||||
name: country.name?.trim() ?? '',
|
||||
iso2: country.iso2?.trim().toUpperCase() ?? '',
|
||||
iso3: country.iso3?.trim().toUpperCase() ?? '',
|
||||
phoneCode: this.nullWhenBlank(country.phoneCode),
|
||||
defaultCurrencyId: this.nullWhenBlank(country.defaultCurrencyId),
|
||||
isActive
|
||||
};
|
||||
}
|
||||
|
||||
private updateCountryStatus(country: CountryDto, isActive: boolean): void {
|
||||
this.countryApi
|
||||
.updateCountry(country.id, this.countryToUpdateRequest(country, isActive))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(
|
||||
isActive
|
||||
? 'Country activated successfully.'
|
||||
: 'Country deactivated successfully.'
|
||||
);
|
||||
this.loadCountries(this.queryState.getQuery());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetCountryFormState(): void {
|
||||
this.countryForm.markAsPristine();
|
||||
this.countryForm.markAsUntouched();
|
||||
this.countryForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
private finishCountrySave(): void {
|
||||
this.showCountryModal.set(false);
|
||||
this.selectedCountryId.set(null);
|
||||
this.selectedCountry.set(null);
|
||||
this.countrySubmitAttempted.set(false);
|
||||
this.loadCountries(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
private clearCountryGrid(): void {
|
||||
this.countries.set([]);
|
||||
this.totalRecords.set(0);
|
||||
this.filteredRecords.set(0);
|
||||
}
|
||||
|
||||
private focusFirstInvalidCountryControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const firstInvalidControl =
|
||||
this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
|
||||
firstInvalidControl?.focus();
|
||||
firstInvalidControl?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
this.countryApi.updateStatus(country.id, { isActive: activate }).pipe(
|
||||
finalize(() => this.statusChangingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`Country ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} country.`;
|
||||
this.toastr.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private nullWhenBlank(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim();
|
||||
|
||||
return normalized
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
private toCountryDto(row: CountryTableRow): CountryDto {
|
||||
return {
|
||||
id: row.id,
|
||||
iso2: row.iso2,
|
||||
iso3: row.iso3,
|
||||
name: row.name,
|
||||
phoneCode: row.phoneCode,
|
||||
defaultCurrencyId: row.defaultCurrencyId,
|
||||
isActive: row.isActive,
|
||||
createdOn: row.createdOn,
|
||||
modifiedOn: row.modifiedOn
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<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)="saveCurrency()"
|
||||
>
|
||||
@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 currency...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="currencyForm" (ngSubmit)="saveCurrency()" 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="code"
|
||||
inputId="currency-code"
|
||||
variant="floating"
|
||||
label="ISO Code"
|
||||
placeholder="Code"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="3"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Currency code is required.', pattern: 'Currency code must be 3 letters.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="name"
|
||||
inputId="currency-name"
|
||||
variant="floating"
|
||||
label="Currency Name"
|
||||
placeholder="Name"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="100"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Currency name is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="symbol"
|
||||
inputId="currency-symbol"
|
||||
variant="floating"
|
||||
label="Symbol"
|
||||
placeholder="Symbol"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="10"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Currency symbol is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="numericCode"
|
||||
inputId="currency-numeric-code"
|
||||
variant="floating"
|
||||
label="Numeric Code"
|
||||
type="number"
|
||||
placeholder="Code"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="1"
|
||||
[max]="999"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="decimalDigits"
|
||||
inputId="currency-decimal-digits"
|
||||
variant="floating"
|
||||
label="Decimal Digits"
|
||||
type="number"
|
||||
placeholder="Digits"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="0"
|
||||
[max]="8"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
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 { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CreateCurrencyRequest,
|
||||
CurrencyDto,
|
||||
CurrencyIso2Value,
|
||||
CurrencyModalMode,
|
||||
UpdateCurrencyRequest
|
||||
} from '../../models/currency.model';
|
||||
import { CurrencyService } from '../../data-access/currency.service';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-currency-form-modal',
|
||||
standalone: true,
|
||||
imports: [Modal, ReactiveFormsModule, FormInput],
|
||||
templateUrl: './currency-form-modal.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CurrencyFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<CurrencyModalMode>('create');
|
||||
readonly currencyId = input<string | null>(null);
|
||||
|
||||
readonly saved = output<void>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly currencySubmitAttempted = signal(false);
|
||||
readonly selectedCurrency = signal<CurrencyDto | null>(null);
|
||||
|
||||
readonly currencyForm = this.formBuilder.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(100)]],
|
||||
code: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{3}$/)]],
|
||||
symbol: ['', [Validators.required, Validators.maxLength(10)]],
|
||||
numericCode: [
|
||||
0,
|
||||
[Validators.required, Validators.min(1), Validators.max(999)]
|
||||
],
|
||||
decimalDigits: [
|
||||
2,
|
||||
[Validators.required, Validators.min(0), Validators.max(8)]
|
||||
]
|
||||
});
|
||||
|
||||
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.mode()) {
|
||||
case 'create': return 'Add Currency';
|
||||
case 'edit': return 'Edit Currency';
|
||||
case 'view': return 'View Currency';
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.open()) {
|
||||
this.prepareModal(this.currencyId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prepareModal(id: string | null): void {
|
||||
this.currencySubmitAttempted.set(false);
|
||||
this.currencyForm.reset({
|
||||
name: '', code: '', symbol: '', numericCode: 0, decimalDigits: 2
|
||||
});
|
||||
|
||||
if (!id || this.mode() === 'create') {
|
||||
this.selectedCurrency.set(null);
|
||||
this.modalLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalLoading.set(true);
|
||||
this.currencyApi.getCurrencyById(id).pipe(
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: currency => {
|
||||
this.selectedCurrency.set(currency);
|
||||
this.currencyForm.patchValue({
|
||||
name: currency.name,
|
||||
code: currency.code,
|
||||
symbol: currency.symbol,
|
||||
numericCode: currency.numericCode,
|
||||
decimalDigits: currency.decimalDigits
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load currency details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveCurrency(): void {
|
||||
if (this.isViewMode()) {
|
||||
this.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currencySubmitAttempted.set(true);
|
||||
if (this.currencyForm.invalid || this.saving()) return;
|
||||
|
||||
this.saving.set(true);
|
||||
if (this.mode() === 'create') {
|
||||
const request: CreateCurrencyRequest = {
|
||||
code: this.currencyForm.controls.code.value.trim().toUpperCase(),
|
||||
name: this.currencyForm.controls.name.value.trim(),
|
||||
symbol: this.currencyForm.controls.symbol.value.trim(),
|
||||
numericCode: this.currencyForm.controls.numericCode.value,
|
||||
decimalDigits: this.currencyForm.controls.decimalDigits.value
|
||||
};
|
||||
|
||||
this.currencyApi.createCurrency(request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Currency created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'create')
|
||||
});
|
||||
} else {
|
||||
const id = this.currencyId();
|
||||
if (!id) return;
|
||||
|
||||
const request: UpdateCurrencyRequest = {
|
||||
code: this.currencyForm.controls.code.value.trim().toUpperCase(),
|
||||
name: this.currencyForm.controls.name.value.trim(),
|
||||
symbol: this.currencyForm.controls.symbol.value.trim(),
|
||||
numericCode: this.currencyForm.controls.numericCode.value,
|
||||
decimalDigits: this.currencyForm.controls.decimalDigits.value,
|
||||
isActive: this.selectedCurrency()?.isActive ?? true
|
||||
};
|
||||
|
||||
this.currencyApi.updateCurrency(id, request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Currency 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.toastr.error('A currency with this ISO code already exists.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} currency. Please try again.`);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
CreateCurrencyRequest,
|
||||
CurrencyDto,
|
||||
CurrencyLookupDto,
|
||||
UpdateCurrencyRequest
|
||||
UpdateCurrencyRequest,
|
||||
UpdateCurrencyStatusRequest
|
||||
} from '../models/currency.model';
|
||||
import { CURRENCY_ENDPOINTS } from './currency.endpoints';
|
||||
|
||||
@@ -29,6 +30,14 @@ export class CurrencyService {
|
||||
return this.http.put<CurrencyDto>(CURRENCY_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
updateStatus(id: string, request: UpdateCurrencyStatusRequest): Observable<CurrencyDto> {
|
||||
return this.http.patch<CurrencyDto>(CURRENCY_ENDPOINTS.changeStatus(id), request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(CURRENCY_ENDPOINTS.delete(id));
|
||||
}
|
||||
|
||||
getCurrencyById(id: string): Observable<CurrencyDto> {
|
||||
return this.http.get<CurrencyDto>(CURRENCY_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface CurrencyDto {
|
||||
modifiedOn?: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface CurrencyLookupDto {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
@@ -40,4 +39,9 @@ export interface UpdateCurrencyRequest extends CreateCurrencyRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type CurrencyModalMode = 'create' | 'edit';
|
||||
export interface UpdateCurrencyStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type CurrencyModalMode = 'create' | 'edit' | 'view';
|
||||
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
<app-data-table [columns]="columns()" [rows]="currencies()" [actions]="actions()" [totalRecords]="totalRecords()"
|
||||
[pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||
tableTitle="Currency Management" buttonTitle="Add" [showSearch]="true" [showAddButton]="true"
|
||||
searchPlaceholder="Search currencies..." [searchDebounceTime]="300" toolTip="Add Currency"
|
||||
(addClicked)="onAddCurrency()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
||||
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)">
|
||||
<ng-template appDataTableCell="iso2" let-row>
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="tableStore.rows()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="tableStore.totalRecords()"
|
||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||
[pageSize]="tableStore.queryState.pageSize()"
|
||||
tableTitle="Currencies Management"
|
||||
buttonTitle="Add"
|
||||
[showSearch]="true"
|
||||
[showAddButton]="true"
|
||||
searchPlaceholder="Search currencies..."
|
||||
[searchDebounceTime]="300"
|
||||
toolTip="Add Currency"
|
||||
(addClicked)="onAddCurrency()"
|
||||
(searchChanged)="tableStore.onSearch($event)"
|
||||
(pageChanged)="tableStore.onPageChange($event)"
|
||||
(sortChanged)="tableStore.onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
>
|
||||
<ng-template appDataTableCell="iso2" let-row>
|
||||
@if (visibleCountries(row); as countries) {
|
||||
@if (countries.length > 0) {
|
||||
<div class="inline-flex max-w-full items-center gap-2 whitespace-nowrap" (click)="$event.stopPropagation()">
|
||||
@@ -100,47 +114,22 @@
|
||||
<span class="badge bg-primary/10 text-primary">
|
||||
{{ value }}
|
||||
</span>
|
||||
</ng-template></app-data-table>
|
||||
</ng-template>
|
||||
</app-data-table>
|
||||
|
||||
<app-confirm-dialog title="Delete Currency" text="Do you really want to delete this currency?"
|
||||
confirmButtonText="Delete" cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()"
|
||||
(cancelled)="onDeleteCancelled()" />
|
||||
<app-confirm-dialog
|
||||
title="Delete Currency"
|
||||
text="Do you really want to delete this currency?"
|
||||
confirmButtonText="Delete"
|
||||
cancelButtonText="Cancel"
|
||||
(confirmed)="onDeleteConfirmed()"
|
||||
(cancelled)="onDeleteCancelled()"
|
||||
/>
|
||||
|
||||
<modal class="modern-modal" [open]="showCurrencyModal()" [title]="currencyModalTitle()" size="md"
|
||||
[submitAction]="currencySubmitAction()" [submitLabel]="currencySubmitLabel()"
|
||||
[loadingLabel]="currencyLoadingLabel()" [loading]="saving()" (closed)="closeCurrencyModal()"
|
||||
(submitted)="saveCurrency()">
|
||||
<form [formGroup]="currencyForm" (ngSubmit)="saveCurrency()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<app-form-input formControlName="name" inputId="currency-name" label="Currency Name" autocomplete="off"
|
||||
variant="floating" [required]="true" [maxLength]="100" [submitAttempted]="currencySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Currency Name is required.', maxlength: 'Currency Name cannot exceed 100 characters.' }" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<app-form-input formControlName="code" inputId="currency-code" label="Currency Code" autocomplete="off"
|
||||
variant="floating" [required]="true" [minLength]="3" [maxLength]="3" pattern="[A-Za-z]{3}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Currency Code is required.', minlength: 'Currency Code must contain exactly 3 letters.', maxlength: 'Currency Code must contain exactly 3 letters.', pattern: 'Currency Code can contain letters only.' }" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<app-form-input formControlName="symbol" inputId="currency-symbol" label="Currency Symbol"
|
||||
autocomplete="off" variant="floating" [required]="true" [maxLength]="8"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Currency Symbol is required.', maxlength: 'Currency Symbol cannot exceed 8 characters.' }" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<app-form-input formControlName="numericCode" inputId="currency-numeric-code" label="Numeric Code"
|
||||
type="number" inputMode="numeric" autocomplete="off" variant="floating" [required]="true" [min]="1"
|
||||
[max]="999" [step]="1" [submitAttempted]="currencySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Numeric Code is required.', min: 'Numeric Code must be at least 1.', max: 'Numeric Code cannot exceed 999.' }" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<app-form-input formControlName="decimalDigits" inputId="currency-decimal-digits" label="Decimal Digits"
|
||||
type="number" inputMode="numeric" autocomplete="off" variant="floating" [required]="true" [min]="0"
|
||||
[max]="4" [step]="1" [submitAttempted]="currencySubmitAttempted()"
|
||||
[validationMessages]="{ required: 'Decimal Digits is required.', min: 'Decimal Digits cannot be less than 0.', max: 'Decimal Digits cannot exceed 4.' }" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modal>
|
||||
<app-currency-form-modal
|
||||
[open]="tableStore.showModal()"
|
||||
[mode]="tableStore.modalMode()"
|
||||
[currencyId]="tableStore.selectedItem()?.id ?? null"
|
||||
(saved)="tableStore.refresh()"
|
||||
(closed)="tableStore.closeModal()"
|
||||
/>
|
||||
|
||||
@@ -1,45 +1,35 @@
|
||||
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import {
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
ConnectedOverlayPositionChange,
|
||||
ConnectedPosition
|
||||
} from '@angular/cdk/overlay';
|
||||
import {
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, catchError, finalize, of, switchMap } from 'rxjs';
|
||||
|
||||
import {
|
||||
CreateCurrencyRequest,
|
||||
CurrencyCountryFlag,
|
||||
CurrencyDto,
|
||||
CurrencyIso2Value,
|
||||
CurrencyModalMode,
|
||||
UpdateCurrencyRequest
|
||||
} from '../../models/currency.model';
|
||||
import { CurrencyService } from '../../data-access/currency.service';
|
||||
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTablePageEvent,
|
||||
DataTableQuery,
|
||||
DataTableRecord,
|
||||
DataTableSortEvent
|
||||
DataTableRecord
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { CurrencyFormModalComponent } from '../../components/currency-form-modal/currency-form-modal';
|
||||
|
||||
type Iso2TooltipPlacement = 'above' | 'below' | 'left' | 'right';
|
||||
|
||||
interface CurrencyTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -57,478 +47,145 @@ interface CurrencyTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'currency-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog, CdkOverlayOrigin, CdkConnectedOverlay],
|
||||
imports: [
|
||||
DataTable,
|
||||
DataTableCellDirective,
|
||||
ConfirmDialog,
|
||||
CdkOverlayOrigin,
|
||||
CdkConnectedOverlay,
|
||||
CurrencyFormModalComponent
|
||||
],
|
||||
providers: [DataTableStore],
|
||||
templateUrl: './currency-list.html',
|
||||
styleUrl: './currency-list.scss',
|
||||
})
|
||||
export class CurrencyList {
|
||||
export class CurrencyList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly currencyQueryRequests$ = new Subject<DataTableQuery>();
|
||||
readonly tableStore = inject(DataTableStore<CurrencyDto, CurrencyTableRow>);
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
|
||||
readonly currencies = signal<CurrencyTableRow[]>([]);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly filteredRecords = signal(0);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly showCurrencyModal = signal(false);
|
||||
readonly currencyModalMode = signal<CurrencyModalMode>('create');
|
||||
readonly selectedCurrencyId = signal<string | null>(null);
|
||||
readonly selectedCurrency = signal<CurrencyDto | null>(null);
|
||||
readonly currencySubmitAttempted = signal(false);
|
||||
readonly pendingDeleteCurrency = signal<CurrencyDto | null>(null);
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly pendingDeleteCurrency = signal<CurrencyTableRow | null>(null);
|
||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||
readonly openIso2TooltipCurrencyId = signal<string | null>(null);
|
||||
|
||||
readonly openIso2TooltipCurrencyId = signal<string | null>(null);
|
||||
readonly iso2TooltipPlacement = signal<Iso2TooltipPlacement>('right');
|
||||
readonly iso2TooltipPositions: ConnectedPosition[] = [
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'center',
|
||||
overlayX: 'start',
|
||||
overlayY: 'center',
|
||||
offsetX: 12
|
||||
}
|
||||
originX: 'end',
|
||||
originY: 'center',
|
||||
overlayX: 'start',
|
||||
overlayY: 'center',
|
||||
offsetX: 12
|
||||
}
|
||||
];
|
||||
private iso2TooltipCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly currencyForm = this.formBuilder.nonNullable.group({
|
||||
name: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(100)
|
||||
]
|
||||
],
|
||||
code: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(3),
|
||||
Validators.maxLength(3),
|
||||
Validators.pattern(/^[A-Za-z]{3}$/)
|
||||
]
|
||||
],
|
||||
symbol: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(8)
|
||||
]
|
||||
],
|
||||
numericCode: [
|
||||
0,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1),
|
||||
Validators.max(999)
|
||||
]
|
||||
],
|
||||
decimalDigits: [
|
||||
2,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
Validators.max(4)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
readonly currencyModalTitle = computed(() =>
|
||||
this.currencyModalMode() === 'create'
|
||||
? 'Add Currency'
|
||||
: 'Edit Currency'
|
||||
);
|
||||
|
||||
readonly currencySubmitLabel = computed(() =>
|
||||
this.currencyModalMode() === 'create'
|
||||
? 'Save'
|
||||
: 'Update'
|
||||
);
|
||||
|
||||
readonly currencyLoadingLabel = computed(() =>
|
||||
this.currencyModalMode() === 'create'
|
||||
? 'Saving...'
|
||||
: 'Updating...'
|
||||
);
|
||||
|
||||
readonly currencySubmitAction = computed<'save' | 'update'>(() =>
|
||||
this.currencyModalMode() === 'create'
|
||||
? 'save'
|
||||
: 'update'
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
|
||||
{ key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true , align: 'left'},
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
||||
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true },
|
||||
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true },
|
||||
{ key: 'decimalDigits', label: 'Decimal Digits', header: 'Decimal Digits', sortable: true },
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '90px' },
|
||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||
{ key: 'iso2', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'center', align: 'left' },
|
||||
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: false, headerAlign: 'center', align: 'center', width: '90px' },
|
||||
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true, headerAlign: 'center', align: 'center', width: '130px' },
|
||||
{
|
||||
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',
|
||||
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<CurrencyTableRow>[]>([
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
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: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ti ti-trash',
|
||||
className: 'text-danger',
|
||||
visible: row => row.isActive
|
||||
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: 'activate',
|
||||
label: 'Activate',
|
||||
icon: 'ti ti-check',
|
||||
className: 'text-success',
|
||||
visible: row => !row.isActive
|
||||
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
}
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.currencyQueryRequests$
|
||||
.pipe(
|
||||
switchMap(query =>
|
||||
this.currencyApi.getCurrencyDataTable(query).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load currencies.');
|
||||
this.clearCurrencyGrid();
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.queryState.getQuery();
|
||||
|
||||
if (response.draw !== query.draw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currenciesWithSerialNumbers: CurrencyTableRow[] = response.rows.map((currency, index) => ({
|
||||
...currency,
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
||||
}));
|
||||
|
||||
this.currencies.set(currenciesWithSerialNumbers);
|
||||
this.totalRecords.set(response.total);
|
||||
this.filteredRecords.set(response.filtered);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCurrencies(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
loadCurrencies(query: DataTableQuery): void {
|
||||
this.currencyQueryRequests$.next(query);
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
const query = this.queryState.setSearch(value.trim());
|
||||
this.loadCurrencies(query);
|
||||
}
|
||||
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
const query = this.queryState.setPage(event);
|
||||
this.loadCurrencies(query);
|
||||
}
|
||||
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
const query = this.queryState.setSort(event);
|
||||
this.loadCurrencies(query);
|
||||
}
|
||||
|
||||
onRefresh(): void {
|
||||
const currentQuery = this.queryState.getQuery();
|
||||
|
||||
this.loadCurrencies({
|
||||
...currentQuery,
|
||||
draw: currentQuery.draw + 1
|
||||
this.tableStore.initialize({
|
||||
fetcher: query => this.currencyApi.getCurrencyDataTable(query)
|
||||
});
|
||||
}
|
||||
|
||||
onReset(): void {
|
||||
const query = this.queryState.reset();
|
||||
this.loadCurrencies(query);
|
||||
onAddCurrency(): void {
|
||||
this.tableStore.openCreateModal();
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<CurrencyTableRow>): 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.requestDeleteCurrency(event.row);
|
||||
if (event.action.type === 'activate') this.changeCurrencyStatus(event.row, true);
|
||||
if (event.action.type === 'deactivate') this.changeCurrencyStatus(event.row, false);
|
||||
}
|
||||
|
||||
onDeleteConfirmed(): void {
|
||||
const currency = this.pendingDeleteCurrency();
|
||||
|
||||
if (!currency) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currency) return;
|
||||
this.pendingDeleteCurrency.set(null);
|
||||
this.deleteCurrency(currency);
|
||||
this.deletingId.set(currency.id);
|
||||
|
||||
this.currencyApi.delete(currency.id).pipe(
|
||||
finalize(() => this.deletingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Currency deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
let errorMsg = 'Unable to delete currency.';
|
||||
if (err?.status === 409) {
|
||||
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete currency because it is currently in use or referenced by other records.';
|
||||
} else if (err?.status === 404) {
|
||||
errorMsg = err?.error?.message || 'Currency not found or has already been deleted.';
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteCurrency.set(null);
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<CurrencyTableRow>): void {
|
||||
const currency = this.toCurrencyDto(event.row);
|
||||
|
||||
switch (event.action.type) {
|
||||
case 'view':
|
||||
this.viewCurrency(currency);
|
||||
break;
|
||||
case 'edit':
|
||||
this.openEditCurrency(currency);
|
||||
break;
|
||||
case 'delete':
|
||||
this.requestDeleteCurrency(currency);
|
||||
break;
|
||||
case 'activate':
|
||||
this.activateCurrency(currency);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onAddCurrency(): void {
|
||||
this.currencyModalMode.set('create');
|
||||
this.selectedCurrencyId.set(null);
|
||||
this.selectedCurrency.set(null);
|
||||
this.currencySubmitAttempted.set(false);
|
||||
|
||||
this.currencyForm.reset({
|
||||
name: '',
|
||||
code: '',
|
||||
symbol: '',
|
||||
numericCode: 0,
|
||||
decimalDigits: 2
|
||||
});
|
||||
this.resetCurrencyFormState();
|
||||
|
||||
this.showCurrencyModal.set(true);
|
||||
}
|
||||
|
||||
closeCurrencyModal(): void {
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showCurrencyModal.set(false);
|
||||
this.selectedCurrencyId.set(null);
|
||||
this.selectedCurrency.set(null);
|
||||
this.currencySubmitAttempted.set(false);
|
||||
}
|
||||
|
||||
saveCurrency(): void {
|
||||
if (this.currencyForm.invalid) {
|
||||
this.currencySubmitAttempted.set(true);
|
||||
this.currencyForm.markAllAsTouched();
|
||||
this.focusFirstInvalidCurrencyControl();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
|
||||
if (this.currencyModalMode() === 'create') {
|
||||
this.currencyApi
|
||||
.createCurrency(this.buildCreateCurrencyRequest())
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Currency saved successfully.');
|
||||
this.finishCurrencySave();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const currencyId = this.selectedCurrencyId();
|
||||
|
||||
if (!currencyId) {
|
||||
this.saving.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currencyApi
|
||||
.updateCurrency(
|
||||
currencyId,
|
||||
this.buildUpdateCurrencyRequest(this.selectedCurrency()?.isActive ?? true)
|
||||
)
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Currency updated successfully.');
|
||||
this.finishCurrencySave();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private viewCurrency(currency: CurrencyDto): void {
|
||||
this.openEditCurrency(currency);
|
||||
}
|
||||
|
||||
private openEditCurrency(currency: CurrencyDto): void {
|
||||
this.currencyModalMode.set('edit');
|
||||
this.selectedCurrencyId.set(currency.id);
|
||||
this.selectedCurrency.set(currency);
|
||||
this.currencySubmitAttempted.set(false);
|
||||
|
||||
this.currencyApi
|
||||
.getCurrencyById(currency.id)
|
||||
.subscribe({
|
||||
next: currencyDetails => {
|
||||
this.selectedCurrency.set(currencyDetails);
|
||||
this.currencyForm.reset({
|
||||
name: currencyDetails.name ?? '',
|
||||
code: currencyDetails.code ?? '',
|
||||
symbol: currencyDetails.symbol ?? '',
|
||||
numericCode: currencyDetails.numericCode ?? '0',
|
||||
decimalDigits: currencyDetails.decimalDigits ?? 2
|
||||
});
|
||||
this.resetCurrencyFormState();
|
||||
|
||||
this.showCurrencyModal.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private requestDeleteCurrency(currency: CurrencyDto): void {
|
||||
private requestDeleteCurrency(currency: CurrencyTableRow): void {
|
||||
this.pendingDeleteCurrency.set(currency);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
private deleteCurrency(currency: CurrencyDto): void {
|
||||
this.updateCurrencyStatus(currency, false);
|
||||
}
|
||||
private changeCurrencyStatus(currency: CurrencyTableRow, activate: boolean): void {
|
||||
this.statusChangingId.set(currency.id);
|
||||
|
||||
private activateCurrency(currency: CurrencyDto): void {
|
||||
this.updateCurrencyStatus(currency, true);
|
||||
}
|
||||
|
||||
private buildCreateCurrencyRequest(): CreateCurrencyRequest {
|
||||
const value = this.currencyForm.getRawValue();
|
||||
|
||||
return {
|
||||
name: value.name.trim(),
|
||||
code: value.code.trim().toUpperCase(),
|
||||
symbol: value.symbol.trim(),
|
||||
numericCode: value.numericCode,
|
||||
decimalDigits: value.decimalDigits
|
||||
};
|
||||
}
|
||||
|
||||
private buildUpdateCurrencyRequest(isActive: boolean): UpdateCurrencyRequest {
|
||||
return {
|
||||
...this.buildCreateCurrencyRequest(),
|
||||
isActive
|
||||
};
|
||||
}
|
||||
|
||||
private currencyToUpdateRequest(currency: CurrencyDto, isActive: boolean): UpdateCurrencyRequest {
|
||||
return {
|
||||
name: currency.name?.trim() ?? '',
|
||||
code: currency.code?.trim().toUpperCase() ?? '',
|
||||
symbol: currency.symbol?.trim() ?? '',
|
||||
numericCode: currency.numericCode,
|
||||
decimalDigits: currency.decimalDigits,
|
||||
isActive
|
||||
};
|
||||
}
|
||||
|
||||
private updateCurrencyStatus(currency: CurrencyDto, isActive: boolean): void {
|
||||
this.currencyApi
|
||||
.updateCurrency(currency.id, this.currencyToUpdateRequest(currency, isActive))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(
|
||||
isActive
|
||||
? 'Currency activated successfully.'
|
||||
: 'Currency deactivated successfully.'
|
||||
);
|
||||
this.loadCurrencies(this.queryState.getQuery());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetCurrencyFormState(): void {
|
||||
this.currencyForm.markAsPristine();
|
||||
this.currencyForm.markAsUntouched();
|
||||
this.currencyForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
private finishCurrencySave(): void {
|
||||
this.showCurrencyModal.set(false);
|
||||
this.selectedCurrencyId.set(null);
|
||||
this.selectedCurrency.set(null);
|
||||
this.currencySubmitAttempted.set(false);
|
||||
this.loadCurrencies(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
private clearCurrencyGrid(): void {
|
||||
this.currencies.set([]);
|
||||
this.totalRecords.set(0);
|
||||
this.filteredRecords.set(0);
|
||||
}
|
||||
|
||||
private focusFirstInvalidCurrencyControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const firstInvalidControl =
|
||||
this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
|
||||
firstInvalidControl?.focus();
|
||||
firstInvalidControl?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
this.currencyApi.updateStatus(currency.id, { isActive: activate }).pipe(
|
||||
finalize(() => this.statusChangingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`Currency ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} currency.`;
|
||||
this.toastr.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toCurrencyDto(row: CurrencyTableRow): CurrencyDto {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
iso2: row.iso2,
|
||||
name: row.name,
|
||||
symbol: row.symbol,
|
||||
numericCode: row.numericCode,
|
||||
decimalDigits: row.decimalDigits,
|
||||
isActive: row.isActive,
|
||||
createdOn: row.createdOn,
|
||||
modifiedOn: row.modifiedOn
|
||||
};
|
||||
}
|
||||
|
||||
visibleCountries(row: CurrencyTableRow): readonly CurrencyCountryFlag[] {
|
||||
return this.normalizeIso2Codes(row.iso2).slice(0, 1);
|
||||
}
|
||||
@@ -589,7 +246,7 @@ export class CurrencyList {
|
||||
const rawCode = typeof item === 'string'
|
||||
? item
|
||||
: item && typeof item === 'object' && 'iso2' in item
|
||||
? String(item.iso2)
|
||||
? String((item as { iso2?: unknown }).iso2)
|
||||
: '';
|
||||
const iso2 = rawCode.trim().toUpperCase();
|
||||
|
||||
@@ -615,6 +272,7 @@ export class CurrencyList {
|
||||
? `https://flagcdn.com/24x18/${code}.png`
|
||||
: '';
|
||||
}
|
||||
|
||||
onFlagError(event: Event): void {
|
||||
const image = event.target as HTMLImageElement;
|
||||
image.classList.add('hidden');
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<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)="saveLanguage()">
|
||||
@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 language...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="languageForm" (ngSubmit)="saveLanguage()" 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="code" inputId="language-code" variant="floating" label="Language Code"
|
||||
placeholder="e.g. en-US" [required]="true" [readonly]="isViewMode() || mode() === 'edit'" [maxLength]="35"
|
||||
[submitAttempted]="submitAttempted()" [validationMessages]="{ required: 'Language code is required.' }" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="name" inputId="language-name" variant="floating" label="Language Name"
|
||||
placeholder="Name" [required]="true" [readonly]="isViewMode()" [maxLength]="100"
|
||||
[submitAttempted]="submitAttempted()" [validationMessages]="{ required: 'Language name is required.' }" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="nativeName" inputId="language-native-name" variant="floating"
|
||||
label="Native Name" placeholder="Native Name" [required]="true" [readonly]="isViewMode()" [maxLength]="100"
|
||||
[submitAttempted]="submitAttempted()" [validationMessages]="{ required: 'Native name is required.' }" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6 flex items-center pt-3">
|
||||
<label for="language-rtl" class="inline-flex cursor-pointer items-center gap-2">
|
||||
<input id="language-rtl" type="checkbox" formControlName="isRightToLeft" class="form-check-input" />
|
||||
<span>Right To Left Language</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
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 { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CreateLanguageRequest,
|
||||
LanguageDto,
|
||||
LanguageModalMode,
|
||||
UpdateLanguageRequest
|
||||
} from '../../models/language.model';
|
||||
import { LanguageService } from '../../data-access/language.service';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { FormCheckbox } from '../../../../../shared/components/form/form-checkbox/form-checkbox';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-language-form-modal',
|
||||
standalone: true,
|
||||
imports: [Modal, ReactiveFormsModule, FormInput],
|
||||
templateUrl: './language-form-modal.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class LanguageFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly languageApi = inject(LanguageService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<LanguageModalMode>('create');
|
||||
readonly languageId = input<string | null>(null);
|
||||
|
||||
readonly saved = output<void>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly selectedLanguage = signal<LanguageDto | null>(null);
|
||||
|
||||
readonly languageForm = this.formBuilder.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
|
||||
code: ['', [Validators.required, Validators.maxLength(35), Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/)]],
|
||||
nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
|
||||
isRightToLeft: [false]
|
||||
});
|
||||
|
||||
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.mode()) {
|
||||
case 'create': return 'Add Language';
|
||||
case 'edit': return 'Edit Language';
|
||||
case 'view': return 'View Language';
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.open()) {
|
||||
this.prepareModal(this.languageId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prepareModal(id: string | null): void {
|
||||
this.submitAttempted.set(false);
|
||||
this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
|
||||
|
||||
if (!id || this.mode() === 'create') {
|
||||
this.selectedLanguage.set(null);
|
||||
this.modalLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalLoading.set(true);
|
||||
this.languageApi.getById(id).pipe(
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: language => {
|
||||
this.selectedLanguage.set(language);
|
||||
this.languageForm.patchValue({
|
||||
name: language.name,
|
||||
code: language.code,
|
||||
nativeName: language.nativeName,
|
||||
isRightToLeft: language.isRightToLeft
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load language details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveLanguage(): void {
|
||||
if (this.isViewMode()) {
|
||||
this.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitAttempted.set(true);
|
||||
if (this.languageForm.invalid || this.saving()) return;
|
||||
|
||||
this.saving.set(true);
|
||||
if (this.mode() === 'create') {
|
||||
const request: CreateLanguageRequest = {
|
||||
code: this.languageForm.controls.code.value.trim(),
|
||||
name: this.languageForm.controls.name.value.trim(),
|
||||
nativeName: this.languageForm.controls.nativeName.value.trim(),
|
||||
isRightToLeft: this.languageForm.controls.isRightToLeft.value
|
||||
};
|
||||
|
||||
this.languageApi.create(request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Language created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'create')
|
||||
});
|
||||
} else {
|
||||
const id = this.languageId();
|
||||
if (!id) return;
|
||||
|
||||
const request: UpdateLanguageRequest = {
|
||||
code: this.languageForm.controls.code.value.trim(),
|
||||
name: this.languageForm.controls.name.value.trim(),
|
||||
nativeName: this.languageForm.controls.nativeName.value.trim(),
|
||||
isRightToLeft: this.languageForm.controls.isRightToLeft.value,
|
||||
isActive: this.selectedLanguage()?.isActive ?? true
|
||||
};
|
||||
|
||||
this.languageApi.update(id, request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Language 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.toastr.error('A language with this code already exists.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} language. Please try again.`);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@ export const LANGUAGE_ENDPOINTS = {
|
||||
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
|
||||
update: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
|
||||
delete: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
|
||||
changeStatus: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}/status`),
|
||||
autocomplete: buildApiUrl('masterAdmin', '/v1/languages/autocomplete')
|
||||
} as const;
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
CreateLanguageRequest,
|
||||
LanguageDto,
|
||||
LanguageLookupDto,
|
||||
UpdateLanguageRequest
|
||||
UpdateLanguageRequest,
|
||||
UpdateLanguageStatusRequest
|
||||
} from '../models/language.model';
|
||||
import {
|
||||
DataTableQuery,
|
||||
@@ -34,6 +35,14 @@ export class LanguageService {
|
||||
return this.http.put<LanguageDto>(LANGUAGE_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
updateStatus(id: string, request: UpdateLanguageStatusRequest): Observable<LanguageDto> {
|
||||
return this.http.patch<LanguageDto>(LANGUAGE_ENDPOINTS.changeStatus(id), request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(LANGUAGE_ENDPOINTS.delete(id));
|
||||
}
|
||||
|
||||
autocomplete(
|
||||
term: string | null,
|
||||
limit = 10
|
||||
|
||||
@@ -28,4 +28,9 @@ export interface UpdateLanguageRequest extends CreateLanguageRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type LanguageModalMode = 'create' | 'edit';
|
||||
export interface UpdateLanguageStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type LanguageModalMode = 'create' | 'edit' | 'view';
|
||||
|
||||
|
||||
@@ -1,67 +1,37 @@
|
||||
<app-data-table [columns]="columns()" [rows]="languages()" [actions]="actions()" [totalRecords]="totalRecords()"
|
||||
[pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()" tableTitle="Languages" buttonTitle="Add"
|
||||
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search languages..." [searchDebounceTime]="300"
|
||||
toolTip="Add Language" (addClicked)="onAddLanguage()" (searchChanged)="onSearch($event)"
|
||||
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)">
|
||||
<ng-template appDataTableCell="name" let-value="value">
|
||||
<span class="font-semibold">{{ value }}</span>
|
||||
</ng-template>
|
||||
<ng-template appDataTableCell="code" let-value="value">
|
||||
<span class="badge bg-primary/10 text-primary">{{ value }}</span>
|
||||
</ng-template>
|
||||
</app-data-table>
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="tableStore.rows()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="tableStore.totalRecords()"
|
||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||
[pageSize]="tableStore.queryState.pageSize()"
|
||||
tableTitle="Languages"
|
||||
buttonTitle="Add"
|
||||
[showSearch]="true"
|
||||
[showAddButton]="true"
|
||||
searchPlaceholder="Search languages..."
|
||||
[searchDebounceTime]="300"
|
||||
toolTip="Add Language"
|
||||
(addClicked)="onAddLanguage()"
|
||||
(searchChanged)="tableStore.onSearch($event)"
|
||||
(pageChanged)="tableStore.onPageChange($event)"
|
||||
(sortChanged)="tableStore.onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
/>
|
||||
|
||||
<app-confirm-dialog title="Delete Language" text="Do you really want to delete this language?"
|
||||
confirmButtonText="Delete" cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()"
|
||||
(cancelled)="onDeleteCancelled()" />
|
||||
<app-confirm-dialog
|
||||
title="Delete Language"
|
||||
text="Do you really want to delete this language?"
|
||||
confirmButtonText="Delete"
|
||||
cancelButtonText="Cancel"
|
||||
(confirmed)="onDeleteConfirmed()"
|
||||
(cancelled)="onDeleteCancelled()"
|
||||
/>
|
||||
|
||||
<modal [open]="showModal()" [title]="modalTitle()" size="md" [submitAction]="submitAction()"
|
||||
[submitLabel]="submitLabel()" [loadingLabel]="loadingLabel()" [loading]="saving() || modalLoading()"
|
||||
(closed)="closeModal()" (submitted)="saveLanguage()">
|
||||
@if (modalLoading()) {
|
||||
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
|
||||
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
|
||||
<span class="ms-2">Loading language...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="languageForm" (ngSubmit)="saveLanguage()" 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="language-name" variant="floating" label="Language Name"
|
||||
placeholder="e.g.: English" autocomplete="off" [required]="true" [maxLength]="100"
|
||||
[submitAttempted]="submitAttempted()" [validationMessages]="{
|
||||
required: 'Language Name is required.',
|
||||
maxlength: 'Language Name cannot exceed 100 characters.',
|
||||
pattern: 'Language Name cannot contain only whitespace.'
|
||||
}" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="code" inputId="language-code" variant="floating" label="Language Code"
|
||||
placeholder="e.g.: en-US" autocomplete="off" [required]="true" [maxLength]="35"
|
||||
[submitAttempted]="submitAttempted()" [validationMessages]="{
|
||||
required: 'Language Code is required.',
|
||||
maxlength: 'Language Code cannot exceed 35 characters.',
|
||||
pattern: 'Use a valid language code, for example en-US or hi-IN.'
|
||||
}" />
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="nativeName" inputId="language-native-name" variant="floating"
|
||||
label="Native Name" placeholder="e.g.: English" autocomplete="off" [required]="true" [maxLength]="100"
|
||||
[submitAttempted]="submitAttempted()" [validationMessages]="{
|
||||
required: 'Native Name is required.',
|
||||
maxlength: 'Native Name cannot exceed 100 characters.',
|
||||
pattern: 'Native Name cannot contain only whitespace.'
|
||||
}" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-span-12 md:col-span-6 flex items-center pt-3">
|
||||
<label for="language-rtl" class="inline-flex cursor-pointer items-center gap-2">
|
||||
<input id="language-rtl" type="checkbox" formControlName="isRightToLeft" class="form-check-input" />
|
||||
<span>Right To Left Language</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
<app-language-form-modal
|
||||
[open]="tableStore.showModal()"
|
||||
[mode]="tableStore.modalMode()"
|
||||
[languageId]="tableStore.selectedItem()?.id ?? null"
|
||||
(saved)="tableStore.refresh()"
|
||||
(closed)="tableStore.closeModal()"
|
||||
/>
|
||||
@@ -1,32 +1,20 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, catchError, finalize, of, switchMap } from 'rxjs';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CreateLanguageRequest,
|
||||
LanguageDto,
|
||||
LanguageModalMode,
|
||||
UpdateLanguageRequest
|
||||
} from '../../models/language.model';
|
||||
import { LanguageDto, UpdateLanguageRequest } from '../../models/language.model';
|
||||
import { LanguageService } from '../../data-access/language.service';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
|
||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTablePageEvent,
|
||||
DataTableQuery,
|
||||
DataTableRecord,
|
||||
DataTableSortEvent
|
||||
DataTableRecord
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { LanguageFormModalComponent } from '../../components/language-form-modal/language-form-modal';
|
||||
|
||||
interface LanguageTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
@@ -43,314 +31,125 @@ interface LanguageTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'language-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
|
||||
imports: [DataTable, ConfirmDialog, LanguageFormModalComponent],
|
||||
providers: [DataTableStore],
|
||||
templateUrl: './language-list.html',
|
||||
styleUrl: './language-list.scss'
|
||||
})
|
||||
export class LanguageList {
|
||||
export class LanguageList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly languageApi = inject(LanguageService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly queryRequests$ = new Subject<DataTableQuery>();
|
||||
readonly tableStore = inject(DataTableStore<LanguageDto, LanguageTableRow>);
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
readonly languages = signal<LanguageTableRow[]>([]);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
readonly showModal = signal(false);
|
||||
readonly modalMode = signal<LanguageModalMode>('create');
|
||||
readonly selectedLanguageId = signal<string | null>(null);
|
||||
readonly selectedLanguage = signal<LanguageDto | null>(null);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly pendingDeleteLanguageId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly pendingDeleteLanguage = signal<LanguageTableRow | null>(null);
|
||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||
|
||||
readonly languageForm = this.formBuilder.nonNullable.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
|
||||
code: ['', [
|
||||
Validators.required,
|
||||
Validators.maxLength(35),
|
||||
Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/)
|
||||
]],
|
||||
nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
|
||||
isRightToLeft: [false]
|
||||
});
|
||||
|
||||
readonly modalTitle = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Add Language' : 'Edit Language'
|
||||
);
|
||||
readonly submitLabel = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Save' : 'Update'
|
||||
);
|
||||
readonly loadingLabel = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
|
||||
);
|
||||
readonly submitAction = computed<'save' | 'update'>(() =>
|
||||
this.modalMode() === 'create' ? 'save' : 'update'
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<LanguageTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
||||
{ key: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left' },
|
||||
{ key: 'code', label: 'Language Code', header: 'Language Code', sortable: true },
|
||||
{ key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true },
|
||||
{
|
||||
key: 'isRightToLeft', label: 'Direction', header: 'Direction', sortable: true,
|
||||
formatter: value => value ? 'RTL' : 'LTR'
|
||||
key: 'isRightToLeft', label: 'RTL', header: 'RTL', sortable: false, badge: true,
|
||||
badgeClass: value => value === true ? 'badge bg-info/10 text-info' : 'badge bg-light text-defaulttextcolor',
|
||||
formatter: value => value ? 'Yes' : 'No'
|
||||
},
|
||||
{
|
||||
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',
|
||||
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<LanguageTableRow>[]>([
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
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: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ti ti-trash',
|
||||
className: 'text-danger',
|
||||
visible: row => row.isActive,
|
||||
disabled: row => this.statusChangingId() === row.id
|
||||
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
|
||||
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
},
|
||||
{
|
||||
type: 'activate',
|
||||
label: 'Activate',
|
||||
icon: 'ti ti-check',
|
||||
className: 'text-success',
|
||||
visible: row => !row.isActive,
|
||||
disabled: row => this.statusChangingId() === row.id
|
||||
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
}
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.queryRequests$.pipe(
|
||||
switchMap(query => this.languageApi.getDataTable(query).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load languages.');
|
||||
this.languages.set([]);
|
||||
this.totalRecords.set(0);
|
||||
return of(null);
|
||||
})
|
||||
)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(response => {
|
||||
if (!response || response.draw !== this.queryState.getQuery().draw) {
|
||||
return;
|
||||
}
|
||||
const query = this.queryState.getQuery();
|
||||
this.languages.set(response.rows.map((language, index) => ({
|
||||
...language,
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
||||
})));
|
||||
this.totalRecords.set(response.total);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadLanguages(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
loadLanguages(query: DataTableQuery): void { this.queryRequests$.next(query); }
|
||||
onSearch(value: string): void { this.loadLanguages(this.queryState.setSearch(value.trim())); }
|
||||
onPageChange(event: DataTablePageEvent): void { this.loadLanguages(this.queryState.setPage(event)); }
|
||||
onSortChange(event: DataTableSortEvent): void { this.loadLanguages(this.queryState.setSort(event)); }
|
||||
|
||||
onDeleteConfirmed(): void {
|
||||
const languageId = this.pendingDeleteLanguageId();
|
||||
|
||||
if (!languageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingDeleteLanguageId.set(null);
|
||||
this.changeLanguageStatus(languageId, false);
|
||||
}
|
||||
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteLanguageId.set(null);
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<LanguageTableRow>): void {
|
||||
if (event.action.type === 'edit') {
|
||||
this.openEditLanguage(event.row.id);
|
||||
} else if (event.action.type === 'delete') {
|
||||
this.requestDeleteLanguage(event.row.id);
|
||||
} else if (event.action.type === 'activate') {
|
||||
this.changeLanguageStatus(event.row.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
private requestDeleteLanguage(id: string): void {
|
||||
this.pendingDeleteLanguageId.set(id);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
this.tableStore.initialize({
|
||||
fetcher: query => this.languageApi.getDataTable(query)
|
||||
});
|
||||
}
|
||||
|
||||
onAddLanguage(): void {
|
||||
this.modalMode.set('create');
|
||||
this.selectedLanguageId.set(null);
|
||||
this.selectedLanguage.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
|
||||
this.resetFormState();
|
||||
this.showModal.set(true);
|
||||
this.tableStore.openCreateModal();
|
||||
}
|
||||
|
||||
openEditLanguage(id: string): void {
|
||||
this.modalMode.set('edit');
|
||||
this.selectedLanguageId.set(id);
|
||||
this.selectedLanguage.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
|
||||
this.resetFormState();
|
||||
this.modalLoading.set(true);
|
||||
this.showModal.set(true);
|
||||
|
||||
this.languageApi.getById(id).pipe(
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: language => {
|
||||
if (this.selectedLanguageId() !== language.id || !this.showModal()) {
|
||||
return;
|
||||
}
|
||||
this.selectedLanguage.set(language);
|
||||
this.languageForm.reset({
|
||||
name: language.name,
|
||||
code: language.code,
|
||||
nativeName: language.nativeName,
|
||||
isRightToLeft: language.isRightToLeft
|
||||
});
|
||||
this.resetFormState();
|
||||
},
|
||||
error: () => this.showModal.set(false)
|
||||
});
|
||||
onActionClick(event: DataTableActionEvent<LanguageTableRow>): 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.requestDeleteLanguage(event.row);
|
||||
if (event.action.type === 'activate') this.changeLanguageStatus(event.row, true);
|
||||
if (event.action.type === 'deactivate') this.changeLanguageStatus(event.row, false);
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
if (this.saving()) return;
|
||||
this.showModal.set(false);
|
||||
this.selectedLanguageId.set(null);
|
||||
this.selectedLanguage.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
}
|
||||
onDeleteConfirmed(): void {
|
||||
const lang = this.pendingDeleteLanguage();
|
||||
if (!lang) return;
|
||||
this.pendingDeleteLanguage.set(null);
|
||||
this.deletingId.set(lang.id);
|
||||
|
||||
saveLanguage(): void {
|
||||
if (this.languageForm.invalid) {
|
||||
this.submitAttempted.set(true);
|
||||
this.languageForm.markAllAsTouched();
|
||||
this.focusFirstInvalidControl();
|
||||
return;
|
||||
}
|
||||
if (this.saving() || this.modalLoading()) return;
|
||||
|
||||
this.saving.set(true);
|
||||
const request = this.buildCreateRequest();
|
||||
const operation = this.modalMode() === 'create'
|
||||
? this.languageApi.create(request)
|
||||
: this.languageApi.update(
|
||||
this.selectedLanguageId() ?? '',
|
||||
{ ...request, isActive: this.selectedLanguage()?.isActive ?? true }
|
||||
);
|
||||
|
||||
operation.pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
this.languageApi.delete(lang.id).pipe(
|
||||
finalize(() => this.deletingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(
|
||||
this.modalMode() === 'create'
|
||||
? 'Language saved successfully.'
|
||||
: 'Language updated successfully.'
|
||||
);
|
||||
this.finishSave();
|
||||
this.toastr.success('Language deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => this.handleSaveError(error)
|
||||
error: (err) => {
|
||||
let errorMsg = 'Unable to delete language.';
|
||||
if (err?.status === 409) {
|
||||
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete language because it is currently in use or referenced by other records.';
|
||||
} else if (err?.status === 404) {
|
||||
errorMsg = err?.error?.message || 'Language not found or has already been deleted.';
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildCreateRequest(): CreateLanguageRequest {
|
||||
const value = this.languageForm.getRawValue();
|
||||
return {
|
||||
code: this.normalizeCode(value.code),
|
||||
name: value.name.trim(),
|
||||
nativeName: value.nativeName.trim(),
|
||||
isRightToLeft: value.isRightToLeft
|
||||
};
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteLanguage.set(null);
|
||||
}
|
||||
|
||||
private normalizeCode(code: string): string {
|
||||
return code.trim().split('-').map((part, index) =>
|
||||
index === 0 ? part.toLowerCase() : part.toUpperCase()
|
||||
).join('-');
|
||||
private requestDeleteLanguage(language: LanguageTableRow): void {
|
||||
this.pendingDeleteLanguage.set(language);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
private changeLanguageStatus(id: string, isActive: boolean): void {
|
||||
if (this.statusChangingId()) return;
|
||||
this.statusChangingId.set(id);
|
||||
this.languageApi.getById(id).pipe(
|
||||
switchMap(language => this.languageApi.update(id, {
|
||||
code: language.code,
|
||||
name: language.name,
|
||||
nativeName: language.nativeName,
|
||||
isRightToLeft: language.isRightToLeft,
|
||||
isActive
|
||||
})),
|
||||
private changeLanguageStatus(language: LanguageTableRow, activate: boolean): void {
|
||||
this.statusChangingId.set(language.id);
|
||||
|
||||
this.languageApi.updateStatus(language.id, { isActive: activate }).pipe(
|
||||
finalize(() => this.statusChangingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(isActive
|
||||
? 'Language activated successfully.'
|
||||
: 'Language deleted successfully.');
|
||||
this.loadLanguages(this.queryState.getQuery());
|
||||
this.toastr.success(`Language ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
if (error.status === 404) this.toastr.error('The language is no longer available.');
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} language.`;
|
||||
this.toastr.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A language with this code already exists.', 'Duplicate language code');
|
||||
}
|
||||
}
|
||||
|
||||
private finishSave(): void {
|
||||
this.showModal.set(false);
|
||||
this.selectedLanguageId.set(null);
|
||||
this.selectedLanguage.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
this.loadLanguages(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
private resetFormState(): void {
|
||||
this.languageForm.markAsPristine();
|
||||
this.languageForm.markAsUntouched();
|
||||
this.languageForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
control?.focus();
|
||||
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<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)="saveState()"
|
||||
>
|
||||
@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 state...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="state-country-id"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="Country"
|
||||
placeholder="Select country"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[searchFn]="countrySearchFn"
|
||||
[valueWith]="countryValueFn"
|
||||
[displayWith]="countryDisplayFn"
|
||||
[selectedItem]="selectedFormCountry()"
|
||||
(itemSelected)="selectedFormCountry.set($event)"
|
||||
[validationMessages]="{ required: 'Country is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="name"
|
||||
inputId="state-name"
|
||||
variant="floating"
|
||||
label="State Name"
|
||||
placeholder="Name"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="150"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'State name is required.' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="code"
|
||||
inputId="state-code"
|
||||
variant="floating"
|
||||
label="State Code"
|
||||
placeholder="Code"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="16"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'State code is required.' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
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 { ToastrService } from 'ngx-toastr';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
||||
import {
|
||||
CreateStateRequest,
|
||||
StateDto,
|
||||
StateModalMode,
|
||||
UpdateStateRequest
|
||||
} from '../../models/state.model';
|
||||
import { StateService } from '../../data-access/state.service';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-state-form-modal',
|
||||
standalone: true,
|
||||
imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete],
|
||||
templateUrl: './state-form-modal.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class StateFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<StateModalMode>('create');
|
||||
readonly stateId = input<string | null>(null);
|
||||
|
||||
readonly saved = output<void>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly selectedState = signal<StateDto | null>(null);
|
||||
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||
|
||||
readonly stateForm = this.formBuilder.nonNullable.group({
|
||||
countryId: ['', Validators.required],
|
||||
name: ['', [Validators.required, Validators.maxLength(150)]],
|
||||
code: ['', [Validators.required, Validators.maxLength(16), Validators.pattern(/^[A-Za-z0-9_-]+$/)]]
|
||||
});
|
||||
|
||||
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.mode()) {
|
||||
case 'create': return 'Add State';
|
||||
case 'edit': return 'Edit State';
|
||||
case 'view': return 'View State';
|
||||
}
|
||||
});
|
||||
|
||||
readonly countrySearchFn: AutocompleteSearchFn<CountryLookupDto> = (term, page) =>
|
||||
this.countryApi.autocomplete(term, page).pipe(catchError(() => of([])));
|
||||
readonly countryValueFn: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||
readonly countryDisplayFn: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.open()) {
|
||||
this.prepareModal(this.stateId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prepareModal(id: string | null): void {
|
||||
this.submitAttempted.set(false);
|
||||
this.stateForm.reset({ countryId: '', name: '', code: '' });
|
||||
this.selectedFormCountry.set(null);
|
||||
|
||||
if (!id || this.mode() === 'create') {
|
||||
this.selectedState.set(null);
|
||||
this.modalLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalLoading.set(true);
|
||||
this.stateApi.getStateById(id).pipe(
|
||||
switchMap(state => {
|
||||
this.selectedState.set(state);
|
||||
if (!state.countryId) return of({ state, country: null });
|
||||
return this.countryApi.getCountryById(state.countryId).pipe(
|
||||
map(country => ({ state, country })),
|
||||
catchError(() => of({ state, country: null }))
|
||||
);
|
||||
}),
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: ({ state, country }) => {
|
||||
if (country) {
|
||||
this.selectedFormCountry.set({ id: country.id, name: country.name, iso2: country.iso2 });
|
||||
}
|
||||
this.stateForm.patchValue({
|
||||
countryId: state.countryId,
|
||||
name: state.name,
|
||||
code: state.code ?? ''
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load state details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveState(): void {
|
||||
if (this.isViewMode()) {
|
||||
this.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitAttempted.set(true);
|
||||
if (this.stateForm.invalid || this.saving()) return;
|
||||
|
||||
this.saving.set(true);
|
||||
if (this.mode() === 'create') {
|
||||
const request: CreateStateRequest = {
|
||||
countryId: this.stateForm.controls.countryId.value,
|
||||
name: this.stateForm.controls.name.value.trim(),
|
||||
code: this.stateForm.controls.code.value.trim().toUpperCase()
|
||||
};
|
||||
|
||||
this.stateApi.createState(request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('State created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: err => this.handleSaveError(err, 'create')
|
||||
});
|
||||
} else {
|
||||
const id = this.stateId();
|
||||
if (!id) return;
|
||||
|
||||
const request: UpdateStateRequest = {
|
||||
countryId: this.stateForm.controls.countryId.value || null,
|
||||
name: this.stateForm.controls.name.value.trim(),
|
||||
code: this.stateForm.controls.code.value.trim().toUpperCase(),
|
||||
isActive: this.selectedState()?.isActive ?? true
|
||||
};
|
||||
|
||||
this.stateApi.updateState(id, request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('State 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.toastr.error('A state with this code already exists in this country.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} state. Please try again.`);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
CreateStateRequest,
|
||||
StateDto,
|
||||
StateLookupDto,
|
||||
UpdateStateRequest
|
||||
UpdateStateRequest,
|
||||
UpdateStateStatusRequest
|
||||
} from "../models/state.model";
|
||||
|
||||
@Injectable({
|
||||
@@ -30,6 +31,14 @@ export class StateService {
|
||||
return this.http.put<StateDto>(STATE_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
updateStatus(id: string, request: UpdateStateStatusRequest): Observable<StateDto> {
|
||||
return this.http.patch<StateDto>(STATE_ENDPOINTS.changeStatus(id), request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(STATE_ENDPOINTS.delete(id));
|
||||
}
|
||||
|
||||
getStateById(id: string): Observable<StateDto> {
|
||||
return this.http.get<StateDto>(STATE_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
@@ -21,10 +21,15 @@ export interface CreateStateRequest {
|
||||
}
|
||||
|
||||
export interface UpdateStateRequest {
|
||||
countryId: null;
|
||||
countryId: string | null;
|
||||
name: string;
|
||||
code: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type StateModalMode = 'create' | 'edit';
|
||||
export interface UpdateStateStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type StateModalMode = 'create' | 'edit' | 'view';
|
||||
|
||||
|
||||
@@ -1,52 +1,61 @@
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||
<form [formGroup]="countryFilterForm" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="state-country-filter"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="Country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[selectedItem]="selectedCountryLookup()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onCountryLookupSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
||||
<app-button
|
||||
action="custom"
|
||||
label="Filter"
|
||||
icon="ti ti-filter"
|
||||
variant="primary-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
||||
(buttonClicked)="applyCountryFilter()"
|
||||
></app-button>
|
||||
</div>
|
||||
</form>
|
||||
</app-filter-card>
|
||||
</div>
|
||||
</div>
|
||||
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||
<form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="state-country-filter"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="Country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[selectedItem]="selectedCountryLookup()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onFilterCountrySelected($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
||||
<app-button
|
||||
action="custom"
|
||||
label="Filter"
|
||||
icon="ti ti-filter"
|
||||
variant="primary-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
||||
(buttonClicked)="onApplyFilter()"
|
||||
></app-button>
|
||||
</div>
|
||||
</form>
|
||||
</app-filter-card>
|
||||
|
||||
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
|
||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||
tableTitle="States" buttonTitle="Add" [showSearch]="true"
|
||||
[showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
|
||||
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
||||
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="tableStore.rows()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="tableStore.totalRecords()"
|
||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||
[pageSize]="tableStore.queryState.pageSize()"
|
||||
tableTitle="States"
|
||||
buttonTitle="Add"
|
||||
[showSearch]="true"
|
||||
[showAddButton]="true"
|
||||
searchPlaceholder="Search states..."
|
||||
[searchDebounceTime]="300"
|
||||
toolTip="Add State"
|
||||
(addClicked)="onAddState()"
|
||||
(searchChanged)="tableStore.onSearch($event)"
|
||||
(pageChanged)="tableStore.onPageChange($event)"
|
||||
(sortChanged)="tableStore.onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
/>
|
||||
|
||||
<app-confirm-dialog
|
||||
title="Delete State"
|
||||
@@ -57,54 +66,10 @@
|
||||
(cancelled)="onDeleteCancelled()"
|
||||
/>
|
||||
|
||||
<modal [open]="showStateModal()" [title]="stateModalTitle()" size="md" [submitAction]="stateSubmitAction()"
|
||||
[submitLabel]="stateSubmitLabel()" [loadingLabel]="stateLoadingLabel()" [loading]="saving()"
|
||||
(closed)="closeStateModal()" (submitted)="saveState()">
|
||||
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="state-country"
|
||||
variant="floating"
|
||||
label="Country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[resolveValueFn]="resolveCountry"
|
||||
[selectedItem]="selectedFormCountry()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="stateModalMode() === 'create'"
|
||||
[required]="true"
|
||||
[clearable]="true"
|
||||
[readonly]="stateModalMode() !== 'create'"
|
||||
[validationMessages]="{ required: 'Country is required.' }"
|
||||
[submitAttempted]="stateSubmitAttempted()"
|
||||
wrapperClass="w-full"
|
||||
(itemSelected)="onFormCountrySelected($event)"
|
||||
(cleared)="onFormCountryCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name" variant="floating"
|
||||
autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
|
||||
required: 'State Name is required.',
|
||||
maxlength: 'State Name cannot exceed 150 characters.'
|
||||
}" [submitAttempted]="stateSubmitAttempted()" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="code" inputId="state-code" label="State Code" placeholder="e.g.: CA" variant="floating"
|
||||
autocomplete="off" [required]="true" [maxLength]="16" [validationMessages]="{
|
||||
required: 'State Code is required.',
|
||||
maxlength: 'State Code cannot exceed 16 characters.',
|
||||
pattern: 'State Code can contain letters, numbers, hyphens, and underscores only.'
|
||||
}" [submitAttempted]="stateSubmitAttempted()" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modal>
|
||||
<app-state-form-modal
|
||||
[open]="tableStore.showModal()"
|
||||
[mode]="tableStore.modalMode()"
|
||||
[stateId]="tableStore.selectedItem()?.id ?? null"
|
||||
(saved)="tableStore.refresh(); tableStore.closeModal()"
|
||||
(closed)="tableStore.closeModal()"
|
||||
/>
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, catchError, distinctUntilChanged, finalize, map, of, switchMap } from 'rxjs';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, finalize, map } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CountryLookupDto
|
||||
} from '../../../countries/public-api';
|
||||
import {
|
||||
CreateStateRequest,
|
||||
StateDto,
|
||||
StateModalMode,
|
||||
UpdateStateRequest
|
||||
} from '../../models/state.model';
|
||||
import { CountryService } from '../../../countries/public-api';
|
||||
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
||||
import { StateDto, UpdateStateRequest } from '../../models/state.model';
|
||||
import { StateService } from '../../data-access/state.service';
|
||||
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTablePageEvent,
|
||||
DataTableQuery,
|
||||
DataTableRecord,
|
||||
DataTableSortEvent
|
||||
DataTableRecord
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
@@ -37,11 +23,10 @@ import {
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||
import { Button as AppButton } from '../../../../../shared/components/button/button';
|
||||
import { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal';
|
||||
|
||||
interface StateTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
@@ -57,515 +42,165 @@ interface StateTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'state-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard, AppButton],
|
||||
imports: [
|
||||
DataTable,
|
||||
ReactiveFormsModule,
|
||||
Autocomplete,
|
||||
ConfirmDialog,
|
||||
FilterCard,
|
||||
AppButton,
|
||||
StateFormModalComponent
|
||||
],
|
||||
providers: [DataTableStore],
|
||||
templateUrl: './state-list.html',
|
||||
styleUrl: './state-list.scss',
|
||||
})
|
||||
export class StateList {
|
||||
|
||||
export class StateList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly stateQueryRequests$ = new Subject<DataTableQuery>();
|
||||
readonly tableStore = inject(DataTableStore<StateDto, StateTableRow>);
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
|
||||
readonly states = signal<StateTableRow[]>([]);
|
||||
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedCountryId = signal<string | null>(null);
|
||||
readonly appliedCountryId = signal<string | null>(null);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly filteredRecords = signal(0);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly showStateModal = signal(false);
|
||||
readonly stateModalMode = signal<StateModalMode>('create');
|
||||
readonly selectedStateId = signal<string | null>(null);
|
||||
readonly selectedState = signal<StateDto | null>(null);
|
||||
readonly stateSubmitAttempted = signal(false);
|
||||
readonly pendingDeleteState = signal<StateDto | null>(null);
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly pendingDeleteState = signal<StateTableRow | null>(null);
|
||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||
|
||||
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
||||
countryId: ['']
|
||||
});
|
||||
|
||||
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> =
|
||||
(term, limit) => this.countryApi.autocomplete(term, limit);
|
||||
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> = (term, limit) =>
|
||||
this.countryApi.autocomplete(term, limit);
|
||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||
value => this.countryApi.getCountryById(value).pipe(
|
||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> = value =>
|
||||
this.countryApi.getCountryById(value).pipe(
|
||||
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||
);
|
||||
|
||||
readonly stateForm = this.formBuilder.nonNullable.group({
|
||||
countryId: [
|
||||
'',
|
||||
[
|
||||
Validators.required
|
||||
]
|
||||
],
|
||||
name: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(150)
|
||||
]
|
||||
],
|
||||
code: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(16),
|
||||
Validators.pattern(/^[A-Za-z0-9_-]+$/)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
readonly emptyMessage = computed(() =>
|
||||
this.appliedCountryId()
|
||||
? 'No states found'
|
||||
: 'No records found'
|
||||
);
|
||||
|
||||
readonly emptyDescription = computed(() =>
|
||||
this.appliedCountryId()
|
||||
? 'There are no states available for the selected country.'
|
||||
: 'There is currently no data to display.'
|
||||
);
|
||||
|
||||
readonly stateModalTitle = computed(() =>
|
||||
this.stateModalMode() === 'create'
|
||||
? 'Add State'
|
||||
: 'Edit State'
|
||||
);
|
||||
|
||||
readonly stateSubmitLabel = computed(() =>
|
||||
this.stateModalMode() === 'create'
|
||||
? 'Save'
|
||||
: 'Update'
|
||||
);
|
||||
|
||||
readonly stateLoadingLabel = computed(() =>
|
||||
this.stateModalMode() === 'create'
|
||||
? 'Saving...'
|
||||
: 'Updating...'
|
||||
);
|
||||
|
||||
readonly stateSubmitAction = computed<'save' | 'update'>(() =>
|
||||
this.stateModalMode() === 'create'
|
||||
? 'save'
|
||||
: 'update'
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<StateTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' },
|
||||
{ key: 'name', header: 'Name', label: 'Name', sortable: true, align: 'left' },
|
||||
{ key: 'code', header: 'Code', label: 'Code', sortable: true },
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||
{ key: 'name', label: 'State Name', header: 'State Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||
{
|
||||
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',
|
||||
width: '100px',
|
||||
key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true,
|
||||
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary',
|
||||
formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—'
|
||||
},
|
||||
{
|
||||
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
|
||||
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
|
||||
formatter: value => value ? 'Active' : 'Inactive'
|
||||
}
|
||||
]);
|
||||
|
||||
readonly actions = signal<DataTableAction<StateTableRow>[]>([
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
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: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ti ti-trash',
|
||||
className: 'text-danger',
|
||||
visible: row => row.isActive
|
||||
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: 'activate',
|
||||
label: 'Activate',
|
||||
icon: 'ti ti-check',
|
||||
className: 'text-success',
|
||||
visible: row => !row.isActive
|
||||
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
}
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.countryFilterForm.controls.countryId.valueChanges
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(countryId => {
|
||||
this.onCountrySelected(countryId || null);
|
||||
});
|
||||
|
||||
this.stateQueryRequests$
|
||||
.pipe(
|
||||
switchMap(query => {
|
||||
const countryId = this.appliedCountryId();
|
||||
|
||||
return this.stateApi.getStateDataTable(query, countryId).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load states.');
|
||||
this.clearStateGrid();
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.queryState.getQuery();
|
||||
|
||||
if (response.draw !== query.draw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statesWithSerialNumbers: StateTableRow[] = response.rows.map((state, index) => ({
|
||||
...state,
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
||||
}));
|
||||
|
||||
this.states.set(statesWithSerialNumbers);
|
||||
this.totalRecords.set(response.total);
|
||||
this.filteredRecords.set(response.filtered);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStates(this.queryState.getQuery());
|
||||
this.tableStore.initialize({
|
||||
fetcher: query => {
|
||||
const countryId = this.countryFilterForm.controls.countryId.value || null;
|
||||
return this.stateApi.getStateDataTable(query, countryId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadStates(query: DataTableQuery): void {
|
||||
this.stateQueryRequests$.next(query);
|
||||
}
|
||||
|
||||
onCountryLookupSelected(country: CountryLookupDto): void {
|
||||
onFilterCountrySelected(country: CountryLookupDto | null): void {
|
||||
this.selectedCountryLookup.set(country);
|
||||
this.countryFilterForm.controls.countryId.setValue(country ? country.id : '');
|
||||
}
|
||||
|
||||
applyCountryFilter(): void {
|
||||
const countryId = this.countryFilterForm.controls.countryId.value || null;
|
||||
this.appliedCountryId.set(countryId);
|
||||
|
||||
this.loadStates(this.queryState.setPage({
|
||||
pageIndex: 1,
|
||||
pageSize: this.queryState.pageSize()
|
||||
}));
|
||||
onApplyFilter(): void {
|
||||
this.tableStore.refresh();
|
||||
}
|
||||
|
||||
onFormCountrySelected(country: CountryLookupDto): void {
|
||||
this.selectedFormCountry.set(country);
|
||||
onResetFilter(): void {
|
||||
this.countryFilterForm.reset({ countryId: '' });
|
||||
this.selectedCountryLookup.set(null);
|
||||
this.tableStore.reset();
|
||||
}
|
||||
|
||||
onFormCountryCleared(): void {
|
||||
this.selectedFormCountry.set(null);
|
||||
onAddState(): void {
|
||||
this.tableStore.openCreateModal();
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
const query = this.queryState.setSearch(value.trim());
|
||||
this.loadStates(query);
|
||||
}
|
||||
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
const query = this.queryState.setPage(event);
|
||||
this.loadStates(query);
|
||||
}
|
||||
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
const query = this.queryState.setSort(event);
|
||||
this.loadStates(query);
|
||||
}
|
||||
|
||||
onRefresh(): void {
|
||||
const currentQuery = this.queryState.getQuery();
|
||||
|
||||
const query: DataTableQuery = {
|
||||
...currentQuery,
|
||||
draw: currentQuery.draw + 1
|
||||
};
|
||||
|
||||
this.loadStates(query);
|
||||
}
|
||||
|
||||
onReset(): void {
|
||||
const query = this.queryState.reset();
|
||||
this.loadStates(query);
|
||||
onActionClick(event: DataTableActionEvent<StateTableRow>): 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.requestDeleteState(event.row);
|
||||
if (event.action.type === 'activate') this.changeStateStatus(event.row, true);
|
||||
if (event.action.type === 'deactivate') this.changeStateStatus(event.row, false);
|
||||
}
|
||||
|
||||
onDeleteConfirmed(): void {
|
||||
const state = this.pendingDeleteState();
|
||||
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state) return;
|
||||
this.pendingDeleteState.set(null);
|
||||
this.deleteState(state);
|
||||
this.deletingId.set(state.id);
|
||||
|
||||
this.stateApi.delete(state.id).pipe(
|
||||
finalize(() => this.deletingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('State deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
let errorMsg = 'Unable to delete state.';
|
||||
if (err?.status === 409) {
|
||||
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete state because it is currently in use or referenced by other records.';
|
||||
} else if (err?.status === 404) {
|
||||
errorMsg = err?.error?.message || 'State not found or has already been deleted.';
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteState.set(null);
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<StateTableRow>): void {
|
||||
const action = event.action.type;
|
||||
const state = this.toStateDto(event.row);
|
||||
|
||||
switch (action) {
|
||||
case 'view':
|
||||
this.viewState(state);
|
||||
break;
|
||||
case 'edit':
|
||||
this.openEditState(state);
|
||||
break;
|
||||
case 'delete':
|
||||
this.requestDeleteState(state);
|
||||
break;
|
||||
case 'activate':
|
||||
this.activateState(state);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onAddState(): void {
|
||||
this.stateModalMode.set('create');
|
||||
this.selectedStateId.set(null);
|
||||
this.selectedState.set(null);
|
||||
this.stateSubmitAttempted.set(false);
|
||||
|
||||
this.selectedFormCountry.set(null);
|
||||
this.stateForm.reset({
|
||||
countryId: '',
|
||||
name: '',
|
||||
code: ''
|
||||
});
|
||||
this.resetStateFormState();
|
||||
|
||||
this.showStateModal.set(true);
|
||||
}
|
||||
|
||||
closeStateModal(): void {
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showStateModal.set(false);
|
||||
this.selectedStateId.set(null);
|
||||
this.selectedState.set(null);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.stateSubmitAttempted.set(false);
|
||||
}
|
||||
|
||||
saveState(): void {
|
||||
if (this.stateForm.invalid) {
|
||||
this.stateSubmitAttempted.set(true);
|
||||
this.stateForm.markAllAsTouched();
|
||||
this.focusFirstInvalidStateControl();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
|
||||
if (this.stateModalMode() === 'create') {
|
||||
this.stateApi
|
||||
.createState(this.buildCreateStateRequest())
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('State saved successfully.');
|
||||
this.finishStateSave();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const stateId = this.selectedStateId();
|
||||
|
||||
if (!stateId) {
|
||||
this.saving.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateApi
|
||||
.updateState(
|
||||
stateId,
|
||||
this.buildUpdateStateRequest(this.selectedState()?.isActive ?? true)
|
||||
)
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('State updated successfully.');
|
||||
this.finishStateSave();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private onCountrySelected(countryId: string | null): void {
|
||||
this.selectedCountryId.set(countryId);
|
||||
|
||||
if (!countryId || this.selectedCountryLookup()?.id !== countryId) {
|
||||
this.selectedCountryLookup.set(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private clearStateGrid(): void {
|
||||
this.states.set([]);
|
||||
this.totalRecords.set(0);
|
||||
this.filteredRecords.set(0);
|
||||
}
|
||||
|
||||
private viewState(state: StateDto): void {
|
||||
this.openEditState(state);
|
||||
}
|
||||
|
||||
private openEditState(state: StateDto): void {
|
||||
this.stateModalMode.set('edit');
|
||||
this.selectedStateId.set(state.id);
|
||||
this.selectedState.set(state);
|
||||
this.stateSubmitAttempted.set(false);
|
||||
|
||||
this.stateApi
|
||||
.getStateById(state.id)
|
||||
.subscribe({
|
||||
next: stateDetails => {
|
||||
this.selectedState.set(stateDetails);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.stateForm.reset({
|
||||
countryId: stateDetails.countryId ?? '',
|
||||
name: stateDetails.name ?? '',
|
||||
code: stateDetails.code ?? ''
|
||||
});
|
||||
this.resetStateFormState();
|
||||
|
||||
this.showStateModal.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private requestDeleteState(state: StateDto): void {
|
||||
private requestDeleteState(state: StateTableRow): void {
|
||||
this.pendingDeleteState.set(state);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
private deleteState(state: StateDto): void {
|
||||
this.updateStateStatus(state, false);
|
||||
}
|
||||
private changeStateStatus(state: StateTableRow, activate: boolean): void {
|
||||
this.statusChangingId.set(state.id);
|
||||
|
||||
private activateState(state: StateDto): void {
|
||||
this.updateStateStatus(state, true);
|
||||
}
|
||||
|
||||
private buildCreateStateRequest(): CreateStateRequest {
|
||||
const value = this.stateForm.getRawValue();
|
||||
|
||||
return {
|
||||
countryId: value.countryId,
|
||||
name: value.name.trim(),
|
||||
code: value.code.trim().toUpperCase()
|
||||
};
|
||||
}
|
||||
|
||||
private buildUpdateStateRequest(isActive: boolean): UpdateStateRequest {
|
||||
const value = this.stateForm.getRawValue();
|
||||
|
||||
return {
|
||||
countryId: null,
|
||||
name: value.name.trim(),
|
||||
code: value.code.trim().toUpperCase(),
|
||||
isActive
|
||||
};
|
||||
}
|
||||
|
||||
private stateToUpdateRequest(state: StateDto, isActive: boolean): UpdateStateRequest {
|
||||
return {
|
||||
countryId: null,
|
||||
name: state.name?.trim() ?? '',
|
||||
code: state.code?.trim().toUpperCase() ?? '',
|
||||
isActive
|
||||
};
|
||||
}
|
||||
|
||||
private updateStateStatus(state: StateDto, isActive: boolean): void {
|
||||
this.stateApi
|
||||
.updateState(state.id, this.stateToUpdateRequest(state, isActive))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(
|
||||
isActive
|
||||
? 'State activated successfully.'
|
||||
: 'State deactivated successfully.'
|
||||
);
|
||||
this.loadStates(this.queryState.getQuery());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetStateFormState(): void {
|
||||
this.stateForm.markAsPristine();
|
||||
this.stateForm.markAsUntouched();
|
||||
this.stateForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
private finishStateSave(): void {
|
||||
this.showStateModal.set(false);
|
||||
this.selectedStateId.set(null);
|
||||
this.selectedState.set(null);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.stateSubmitAttempted.set(false);
|
||||
this.loadStates(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
private focusFirstInvalidStateControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const firstInvalidControl =
|
||||
this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal [data-form-control][aria-invalid="true"]'
|
||||
);
|
||||
|
||||
firstInvalidControl?.focus();
|
||||
firstInvalidControl?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
this.stateApi.updateStatus(state.id, { isActive: activate }).pipe(
|
||||
finalize(() => this.statusChangingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(`State ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} state.`;
|
||||
this.toastr.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toStateDto(row: StateTableRow): StateDto {
|
||||
return {
|
||||
id: row.id,
|
||||
countryId: row.countryId,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
isActive: row.isActive,
|
||||
createdOn: row.createdOn,
|
||||
modifiedOn: row.modifiedOn
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<modal
|
||||
[open]="open()"
|
||||
[title]="modalTitle()"
|
||||
size="md"
|
||||
[submitAction]="submitAction()"
|
||||
[submitLabel]="submitLabel()"
|
||||
[loadingLabel]="loadingLabel()"
|
||||
[loading]="saving() || modalLoading()"
|
||||
[showSubmitButton]="!isViewMode()"
|
||||
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||
(closed)="closeModal()"
|
||||
(submitted)="saveTimezone()"
|
||||
>
|
||||
@if (modalLoading()) {
|
||||
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
|
||||
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
|
||||
<span class="ms-2">Loading timezone...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="timezoneForm" (ngSubmit)="saveTimezone()" 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="ianaId"
|
||||
inputId="timezone-iana-id"
|
||||
variant="floating"
|
||||
label="IANA Timezone ID"
|
||||
placeholder="Id"
|
||||
help="e.g.: Asia/Kolkata"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="64"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Timezone ID is required.',
|
||||
maxlength: 'Timezone ID must be 64 characters or fewer.',
|
||||
pattern: 'Use a valid IANA timezone ID, for example Asia/Kolkata or America/New_York.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="displayName"
|
||||
inputId="timezone-display-name"
|
||||
variant="floating"
|
||||
label="Display Name"
|
||||
placeholder="Name"
|
||||
help="e.g.: India Standard Time"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="128"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Timezone display name is required.',
|
||||
maxlength: 'Timezone display name must be 128 characters or fewer.',
|
||||
pattern: 'Timezone display name cannot contain only whitespace.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="utcOffsetMinutes"
|
||||
inputId="timezone-utc-offset"
|
||||
variant="floating"
|
||||
label="UTC Offset (minutes)"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Minutes"
|
||||
help="Enter an offset from -720 (-12:00) to 840 (+14:00)."
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="-720"
|
||||
[max]="840"
|
||||
[step]="1"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'UTC offset is required.',
|
||||
min: 'UTC offset must be between -12:00 and +14:00.',
|
||||
max: 'UTC offset must be between -12:00 and +14:00.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
@if (isViewMode() && selectedTimezone(); as timezone) {
|
||||
<div class="col-span-12 md:col-span-6 pt-1">
|
||||
<span class="block text-sm text-textmuted">Formatted UTC Offset</span>
|
||||
<span class="mt-2 block font-semibold">{{ formatUtcOffset(timezone.utcOffsetMinutes) }}</span>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<span class="block text-sm text-textmuted">Status</span>
|
||||
<span class="badge mt-2" [class.bg-success]="timezone.isActive" [class.bg-danger]="!timezone.isActive">
|
||||
{{ timezone.isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
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 { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CreateTimezoneRequest,
|
||||
TimezoneDto,
|
||||
TimezoneModalMode,
|
||||
UpdateTimezoneRequest
|
||||
} from '../../models/timezone.model';
|
||||
import { TimezoneService } from '../../data-access/timezone.service';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
|
||||
@Component({
|
||||
selector: 'app-timezone-form-modal',
|
||||
standalone: true,
|
||||
imports: [Modal, ReactiveFormsModule, FormInput],
|
||||
templateUrl: './timezone-form-modal.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TimezoneFormModalComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<TimezoneModalMode>('create');
|
||||
readonly timezoneId = input<string | null>(null);
|
||||
|
||||
readonly saved = output<void>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly selectedTimezone = signal<TimezoneDto | null>(null);
|
||||
|
||||
readonly timezoneForm = this.formBuilder.nonNullable.group({
|
||||
ianaId: ['', [
|
||||
Validators.required,
|
||||
Validators.maxLength(64),
|
||||
Validators.pattern(/^[A-Za-z]+(?:[._+-]?[A-Za-z0-9]+)*(?:\/[A-Za-z0-9._+-]+)+$/)
|
||||
]],
|
||||
displayName: ['', [Validators.required, Validators.maxLength(128), Validators.pattern(/.*\S.*/)]],
|
||||
utcOffsetMinutes: [0, [Validators.required, Validators.min(-720), Validators.max(840)]]
|
||||
});
|
||||
|
||||
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.mode()) {
|
||||
case 'create': return 'Add Timezone';
|
||||
case 'edit': return 'Edit Timezone';
|
||||
case 'view': return 'View Timezone';
|
||||
}
|
||||
});
|
||||
readonly submitLabel = computed(() => this.mode() === 'create' ? 'Save' : 'Update');
|
||||
readonly loadingLabel = computed(() => this.mode() === 'create' ? 'Saving...' : 'Updating...');
|
||||
readonly submitAction = computed<'save' | 'update'>(() => this.mode() === 'create' ? 'save' : 'update');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.open()) {
|
||||
this.prepareModal(this.timezoneId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prepareModal(id: string | null): void {
|
||||
this.submitAttempted.set(false);
|
||||
this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 });
|
||||
|
||||
if (!id || this.mode() === 'create') {
|
||||
this.selectedTimezone.set(null);
|
||||
this.modalLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalLoading.set(true);
|
||||
this.timezoneApi.getById(id).pipe(
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: timezone => {
|
||||
this.selectedTimezone.set(timezone);
|
||||
this.timezoneForm.patchValue({
|
||||
ianaId: timezone.ianaId,
|
||||
displayName: timezone.displayName,
|
||||
utcOffsetMinutes: timezone.utcOffsetMinutes
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.toastr.error('Unable to load timezone details.');
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveTimezone(): void {
|
||||
if (this.isViewMode()) {
|
||||
this.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitAttempted.set(true);
|
||||
if (this.timezoneForm.invalid || this.saving()) return;
|
||||
|
||||
this.saving.set(true);
|
||||
if (this.mode() === 'create') {
|
||||
const request: CreateTimezoneRequest = {
|
||||
ianaId: this.timezoneForm.controls.ianaId.value.trim(),
|
||||
displayName: this.timezoneForm.controls.displayName.value.trim(),
|
||||
utcOffsetMinutes: this.timezoneForm.controls.utcOffsetMinutes.value
|
||||
};
|
||||
|
||||
this.timezoneApi.create(request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Timezone created successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => this.handleSaveError(error, 'create')
|
||||
});
|
||||
} else {
|
||||
const id = this.timezoneId();
|
||||
if (!id) return;
|
||||
|
||||
const request: UpdateTimezoneRequest = {
|
||||
ianaId: this.timezoneForm.controls.ianaId.value.trim(),
|
||||
displayName: this.timezoneForm.controls.displayName.value.trim(),
|
||||
utcOffsetMinutes: this.timezoneForm.controls.utcOffsetMinutes.value,
|
||||
isActive: this.selectedTimezone()?.isActive ?? true
|
||||
};
|
||||
|
||||
this.timezoneApi.update(id, request).pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toastr.success('Timezone updated successfully.');
|
||||
this.saved.emit();
|
||||
this.closed.emit();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => this.handleSaveError(error, 'update')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
if (this.saving()) return;
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
formatUtcOffset(totalMinutes: number): string {
|
||||
const isNegative = totalMinutes < 0;
|
||||
const absMinutes = Math.abs(totalMinutes);
|
||||
const hours = Math.floor(absMinutes / 60);
|
||||
const minutes = absMinutes % 60;
|
||||
const formattedHours = String(hours).padStart(2, '0');
|
||||
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||
const prefix = isNegative ? '-' : '+';
|
||||
return `UTC${prefix}${formattedHours}:${formattedMinutes}`;
|
||||
}
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A timezone with this IANA ID already exists.');
|
||||
return;
|
||||
}
|
||||
this.toastr.error(`Unable to ${action} timezone. Please try again.`);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,9 @@ export const TIMEZONE_ENDPOINTS = {
|
||||
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`),
|
||||
update: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`),
|
||||
delete: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`),
|
||||
changeStatus: (id: string) =>
|
||||
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}/status`),
|
||||
autocomplete: buildApiUrl('masterAdmin', '/v1/timezones/autocomplete')
|
||||
} as const;
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
CreateTimezoneRequest,
|
||||
TimezoneDto,
|
||||
TimezoneLookupDto,
|
||||
UpdateTimezoneRequest
|
||||
UpdateTimezoneRequest,
|
||||
UpdateTimezoneStatusRequest
|
||||
} from '../models/timezone.model';
|
||||
import {
|
||||
DataTableQuery,
|
||||
@@ -34,6 +35,14 @@ export class TimezoneService {
|
||||
return this.http.put<TimezoneDto>(TIMEZONE_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
updateStatus(id: string, request: UpdateTimezoneStatusRequest): Observable<TimezoneDto> {
|
||||
return this.http.patch<TimezoneDto>(TIMEZONE_ENDPOINTS.changeStatus(id), request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(TIMEZONE_ENDPOINTS.delete(id));
|
||||
}
|
||||
|
||||
autocomplete(term: string | null, limit = 10): Observable<readonly TimezoneLookupDto[]> {
|
||||
const normalizedTerm = term?.trim() || null;
|
||||
let params = new HttpParams().set('limit', limit);
|
||||
|
||||
@@ -24,4 +24,9 @@ export interface UpdateTimezoneRequest extends CreateTimezoneRequest {
|
||||
readonly isActive: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTimezoneStatusRequest {
|
||||
readonly isActive: boolean;
|
||||
}
|
||||
|
||||
export type TimezoneModalMode = 'create' | 'edit' | 'view';
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="timezones()"
|
||||
[rows]="tableStore.rows()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="totalRecords()"
|
||||
[pageIndex]="queryState.pageIndex()"
|
||||
[pageSize]="queryState.pageSize()"
|
||||
[totalRecords]="tableStore.totalRecords()"
|
||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||
[pageSize]="tableStore.queryState.pageSize()"
|
||||
tableTitle="Timezones"
|
||||
buttonTitle="Add"
|
||||
[showSearch]="true"
|
||||
@@ -13,9 +13,9 @@
|
||||
[searchDebounceTime]="300"
|
||||
toolTip="Add Timezone"
|
||||
(addClicked)="onAddTimezone()"
|
||||
(searchChanged)="onSearch($event)"
|
||||
(pageChanged)="onPageChange($event)"
|
||||
(sortChanged)="onSortChange($event)"
|
||||
(searchChanged)="tableStore.onSearch($event)"
|
||||
(pageChanged)="tableStore.onPageChange($event)"
|
||||
(sortChanged)="tableStore.onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
>
|
||||
<ng-template appDataTableCell="ianaId" let-value="value">
|
||||
@@ -35,103 +35,10 @@
|
||||
(cancelled)="onDeleteCancelled()"
|
||||
/>
|
||||
|
||||
<modal
|
||||
[open]="showModal()"
|
||||
[title]="modalTitle()"
|
||||
size="md"
|
||||
[submitAction]="submitAction()"
|
||||
[submitLabel]="submitLabel()"
|
||||
[loadingLabel]="loadingLabel()"
|
||||
[loading]="saving() || modalLoading()"
|
||||
[showSubmitButton]="!isViewMode()"
|
||||
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||
(closed)="closeModal()"
|
||||
(submitted)="saveTimezone()"
|
||||
>
|
||||
@if (modalLoading()) {
|
||||
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
|
||||
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
|
||||
<span class="ms-2">Loading timezone...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="timezoneForm" (ngSubmit)="saveTimezone()" 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="ianaId"
|
||||
inputId="timezone-iana-id"
|
||||
variant="floating"
|
||||
label="IANA Timezone ID"
|
||||
placeholder="Id"
|
||||
help="e.g.: Asia/Kolkata"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="64"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Timezone ID is required.',
|
||||
maxlength: 'Timezone ID must be 64 characters or fewer.',
|
||||
pattern: 'Use a valid IANA timezone ID, for example Asia/Kolkata or America/New_York.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="displayName"
|
||||
inputId="timezone-display-name"
|
||||
variant="floating"
|
||||
label="Display Name"
|
||||
placeholder="Name"
|
||||
help="e.g.: India Standard Time"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[maxLength]="128"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Timezone display name is required.',
|
||||
maxlength: 'Timezone display name must be 128 characters or fewer.',
|
||||
pattern: 'Timezone display name cannot contain only whitespace.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="utcOffsetMinutes"
|
||||
inputId="timezone-utc-offset"
|
||||
variant="floating"
|
||||
label="UTC Offset (minutes)"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="Minutes"
|
||||
help="Enter an offset from -720 (-12:00) to 840 (+14:00)."
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="-720"
|
||||
[max]="840"
|
||||
[step]="1"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'UTC offset is required.',
|
||||
min: 'UTC offset must be between -12:00 and +14:00.',
|
||||
max: 'UTC offset must be between -12:00 and +14:00.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
@if (isViewMode() && selectedTimezone(); as timezone) {
|
||||
<div class="col-span-12 md:col-span-6 pt-1">
|
||||
<span class="block text-sm text-textmuted">Formatted UTC Offset</span>
|
||||
<span class="mt-2 block font-semibold">{{ formatUtcOffset(timezone.utcOffsetMinutes) }}</span>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<span class="block text-sm text-textmuted">Status</span>
|
||||
<span class="badge mt-2" [class.bg-success]="timezone.isActive" [class.bg-danger]="!timezone.isActive">
|
||||
{{ timezone.isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</modal>
|
||||
<app-timezone-form-modal
|
||||
[open]="tableStore.showModal()"
|
||||
[mode]="tableStore.modalMode()"
|
||||
[timezoneId]="tableStore.selectedItem()?.id ?? null"
|
||||
(saved)="tableStore.refresh()"
|
||||
(closed)="tableStore.closeModal()"
|
||||
/>
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, ElementRef, OnInit, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, catchError, finalize, of, switchMap } from 'rxjs';
|
||||
|
||||
import {
|
||||
CreateTimezoneRequest,
|
||||
TimezoneDto,
|
||||
TimezoneModalMode,
|
||||
UpdateTimezoneRequest
|
||||
} from '../../models/timezone.model';
|
||||
Component,
|
||||
DestroyRef,
|
||||
OnInit,
|
||||
inject,
|
||||
signal,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { TimezoneDto, UpdateTimezoneRequest } from '../../models/timezone.model';
|
||||
import { TimezoneService } from '../../data-access/timezone.service';
|
||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
||||
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
|
||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTablePageEvent,
|
||||
DataTableQuery,
|
||||
DataTableRecord,
|
||||
DataTableSortEvent
|
||||
DataTableRecord
|
||||
} from '../../../../../shared/components/data-table/data-table.types';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { TimezoneFormModalComponent } from '../../components/timezone-form-modal/timezone-form-modal';
|
||||
|
||||
interface TimezoneTableRow extends DataTableRecord {
|
||||
readonly id: string;
|
||||
@@ -42,54 +38,27 @@ interface TimezoneTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'timezone-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
|
||||
imports: [
|
||||
DataTable,
|
||||
DataTableCellDirective,
|
||||
ConfirmDialog,
|
||||
TimezoneFormModalComponent
|
||||
],
|
||||
providers: [DataTableStore],
|
||||
templateUrl: './timezone-list.html',
|
||||
styleUrl: './timezone-list.scss'
|
||||
})
|
||||
export class TimezoneList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly queryRequests$ = new Subject<DataTableQuery>();
|
||||
readonly tableStore = inject(DataTableStore<TimezoneDto, TimezoneTableRow>);
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
readonly timezones = signal<TimezoneTableRow[]>([]);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly modalLoading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly statusChangingId = signal<string | null>(null);
|
||||
readonly showModal = signal(false);
|
||||
readonly modalMode = signal<TimezoneModalMode>('create');
|
||||
readonly selectedTimezoneId = signal<string | null>(null);
|
||||
readonly selectedTimezone = signal<TimezoneDto | null>(null);
|
||||
readonly submitAttempted = signal(false);
|
||||
readonly pendingDeleteTimezoneId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly pendingDeleteTimezone = signal<TimezoneTableRow | null>(null);
|
||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||
|
||||
readonly timezoneForm = this.formBuilder.nonNullable.group({
|
||||
ianaId: ['', [
|
||||
Validators.required,
|
||||
Validators.maxLength(64),
|
||||
Validators.pattern(/^[A-Za-z]+(?:[._+-]?[A-Za-z0-9]+)*(?:\/[A-Za-z0-9._+-]+)+$/)
|
||||
]],
|
||||
displayName: ['', [Validators.required, Validators.maxLength(128), Validators.pattern(/.*\S.*/)]],
|
||||
utcOffsetMinutes: [0, [Validators.required, Validators.min(-720), Validators.max(840)]]
|
||||
});
|
||||
|
||||
readonly isViewMode = computed(() => this.modalMode() === 'view');
|
||||
readonly modalTitle = computed(() => {
|
||||
switch (this.modalMode()) {
|
||||
case 'create': return 'Add Timezone';
|
||||
case 'edit': return 'Edit Timezone';
|
||||
case 'view': return 'View Timezone';
|
||||
}
|
||||
});
|
||||
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save' : 'Update');
|
||||
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving...' : 'Updating...');
|
||||
readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update');
|
||||
|
||||
readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||
{ key: 'ianaId', label: 'IANA ID', header: 'IANA ID', sortable: true, headerAlign: 'center', align: 'left' },
|
||||
@@ -115,234 +84,111 @@ export class TimezoneList implements OnInit {
|
||||
className: 'text-primary'
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ti ti-trash',
|
||||
className: 'text-danger',
|
||||
type: 'deactivate',
|
||||
label: 'Deactivate',
|
||||
icon: 'ti ti-toggle-right',
|
||||
className: 'text-warning',
|
||||
visible: row => row.isActive,
|
||||
disabled: row => this.statusChangingId() === row.id
|
||||
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||
},
|
||||
{
|
||||
type: 'activate',
|
||||
label: 'Activate',
|
||||
icon: 'ti ti-check',
|
||||
icon: 'ti ti-toggle-left',
|
||||
className: 'text-success',
|
||||
visible: row => !row.isActive,
|
||||
disabled: row => this.statusChangingId() === row.id
|
||||
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
|
||||
}
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.queryRequests$.pipe(
|
||||
switchMap(query => this.timezoneApi.getDataTable(query).pipe(
|
||||
catchError(() => {
|
||||
this.timezones.set([]);
|
||||
this.totalRecords.set(0);
|
||||
return of(null);
|
||||
})
|
||||
)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(response => {
|
||||
if (!response || response.draw !== this.queryState.getQuery().draw) return;
|
||||
const query = this.queryState.getQuery();
|
||||
this.timezones.set(response.rows.map((timezone, index) => ({
|
||||
...timezone,
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
||||
})));
|
||||
this.totalRecords.set(response.total);
|
||||
ngOnInit(): void {
|
||||
this.tableStore.initialize({
|
||||
fetcher: query => this.timezoneApi.getDataTable(query)
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void { this.loadTimezones(this.queryState.getQuery()); }
|
||||
loadTimezones(query: DataTableQuery): void { this.queryRequests$.next(query); }
|
||||
onSearch(value: string): void { this.loadTimezones(this.queryState.setSearch(value.trim())); }
|
||||
onPageChange(event: DataTablePageEvent): void { this.loadTimezones(this.queryState.setPage(event)); }
|
||||
onSortChange(event: DataTableSortEvent): void { this.loadTimezones(this.queryState.setSort(event)); }
|
||||
|
||||
onDeleteConfirmed(): void {
|
||||
const timezoneId = this.pendingDeleteTimezoneId();
|
||||
|
||||
if (!timezoneId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingDeleteTimezoneId.set(null);
|
||||
this.changeTimezoneStatus(timezoneId, false);
|
||||
}
|
||||
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteTimezoneId.set(null);
|
||||
onAddTimezone(): void {
|
||||
this.tableStore.openCreateModal();
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<TimezoneTableRow>): void {
|
||||
if (event.action.type === 'view') this.openTimezone(event.row.id, 'view');
|
||||
if (event.action.type === 'edit') this.openTimezone(event.row.id, 'edit');
|
||||
if (event.action.type === 'delete') this.requestDeleteTimezone(event.row.id);
|
||||
if (event.action.type === 'activate') this.changeTimezoneStatus(event.row.id, true);
|
||||
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.requestDeleteTimezone(event.row);
|
||||
if (event.action.type === 'activate') this.changeTimezoneStatus(event.row, true);
|
||||
if (event.action.type === 'deactivate') this.changeTimezoneStatus(event.row, false);
|
||||
}
|
||||
|
||||
private requestDeleteTimezone(id: string): void {
|
||||
this.pendingDeleteTimezoneId.set(id);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
onDeleteConfirmed(): void {
|
||||
const timezone = this.pendingDeleteTimezone();
|
||||
if (!timezone) return;
|
||||
this.pendingDeleteTimezone.set(null);
|
||||
this.deletingId.set(timezone.id);
|
||||
|
||||
onAddTimezone(): void {
|
||||
this.modalMode.set('create');
|
||||
this.prepareModal(null);
|
||||
this.showModal.set(true);
|
||||
}
|
||||
|
||||
openTimezone(id: string, mode: 'edit' | 'view'): void {
|
||||
this.modalMode.set(mode);
|
||||
this.prepareModal(id);
|
||||
this.modalLoading.set(true);
|
||||
this.showModal.set(true);
|
||||
this.timezoneApi.getById(id).pipe(
|
||||
finalize(() => this.modalLoading.set(false)),
|
||||
this.timezoneApi.delete(timezone.id).pipe(
|
||||
finalize(() => this.deletingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: timezone => {
|
||||
if (this.selectedTimezoneId() !== timezone.id || !this.showModal()) return;
|
||||
this.selectedTimezone.set(timezone);
|
||||
this.timezoneForm.reset({
|
||||
ianaId: timezone.ianaId,
|
||||
displayName: timezone.displayName,
|
||||
utcOffsetMinutes: timezone.utcOffsetMinutes
|
||||
});
|
||||
if (mode === 'view') this.timezoneForm.disable();
|
||||
this.resetFormState();
|
||||
next: () => {
|
||||
this.toastr.success('Timezone deleted successfully.');
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.showModal.set(false);
|
||||
if (error.status === 404) this.toastr.error('The timezone is no longer available.');
|
||||
error: (err) => {
|
||||
let errorMsg = 'Unable to delete timezone.';
|
||||
if (err?.status === 409) {
|
||||
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete timezone because it is currently in use or referenced by other records.';
|
||||
} else if (err?.status === 404) {
|
||||
errorMsg = err?.error?.message || 'Timezone not found or has already been deleted.';
|
||||
} else if (err?.error?.message || err?.error?.title) {
|
||||
errorMsg = err.error.message || err.error.title;
|
||||
}
|
||||
this.toastr.error(errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
if (this.saving()) return;
|
||||
this.showModal.set(false);
|
||||
this.selectedTimezoneId.set(null);
|
||||
this.selectedTimezone.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
this.timezoneForm.enable();
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteTimezone.set(null);
|
||||
}
|
||||
|
||||
saveTimezone(): void {
|
||||
if (this.isViewMode() || this.saving() || this.modalLoading()) return;
|
||||
if (this.timezoneForm.invalid) {
|
||||
this.submitAttempted.set(true);
|
||||
this.timezoneForm.markAllAsTouched();
|
||||
this.focusFirstInvalidControl();
|
||||
return;
|
||||
}
|
||||
const selected = this.selectedTimezone();
|
||||
const id = this.selectedTimezoneId();
|
||||
if (this.modalMode() === 'edit' && (!selected || !id)) return;
|
||||
|
||||
this.saving.set(true);
|
||||
const createRequest = this.buildCreateRequest();
|
||||
const operation = this.modalMode() === 'create'
|
||||
? this.timezoneApi.create(createRequest)
|
||||
: this.timezoneApi.update(id!, { ...createRequest, isActive: selected!.isActive });
|
||||
operation.pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(this.modalMode() === 'create'
|
||||
? 'Timezone saved successfully.'
|
||||
: 'Timezone updated successfully.');
|
||||
this.finishSave();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => this.handleSaveError(error)
|
||||
});
|
||||
private requestDeleteTimezone(timezone: TimezoneTableRow): void {
|
||||
this.pendingDeleteTimezone.set(timezone);
|
||||
this.deleteConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
formatUtcOffset(minutes: number): string {
|
||||
const sign = minutes >= 0 ? '+' : '-';
|
||||
const absolute = Math.abs(minutes);
|
||||
return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`;
|
||||
}
|
||||
private changeTimezoneStatus(timezone: TimezoneTableRow, activate: boolean): void {
|
||||
this.statusChangingId.set(timezone.id);
|
||||
|
||||
private prepareModal(id: string | null): void {
|
||||
this.selectedTimezoneId.set(id);
|
||||
this.selectedTimezone.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
this.timezoneForm.enable();
|
||||
this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 });
|
||||
this.resetFormState();
|
||||
}
|
||||
|
||||
private buildCreateRequest(): CreateTimezoneRequest {
|
||||
const value = this.timezoneForm.getRawValue();
|
||||
return {
|
||||
ianaId: value.ianaId.trim(),
|
||||
displayName: value.displayName.trim(),
|
||||
utcOffsetMinutes: value.utcOffsetMinutes
|
||||
};
|
||||
}
|
||||
|
||||
private changeTimezoneStatus(id: string, isActive: boolean): void {
|
||||
if (this.statusChangingId()) return;
|
||||
this.statusChangingId.set(id);
|
||||
this.timezoneApi.getById(id).pipe(
|
||||
switchMap(timezone => this.timezoneApi.update(id, {
|
||||
ianaId: timezone.ianaId,
|
||||
displayName: timezone.displayName,
|
||||
utcOffsetMinutes: timezone.utcOffsetMinutes,
|
||||
isActive
|
||||
})),
|
||||
this.timezoneApi.updateStatus(timezone.id, { isActive: activate }).pipe(
|
||||
finalize(() => this.statusChangingId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastr.success(isActive
|
||||
? 'Timezone activated successfully.'
|
||||
: 'Timezone deleted successfully.');
|
||||
this.loadTimezones(this.queryState.getQuery());
|
||||
this.toastr.success(`Timezone ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||
this.tableStore.refresh();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
if (error.status === 404) this.toastr.error('The timezone is no longer available.');
|
||||
error: (err) => {
|
||||
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} timezone.`;
|
||||
this.toastr.error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse): void {
|
||||
if (error.status === 409) {
|
||||
const message = this.apiErrorMessage(error) ?? 'A timezone with this IANA ID already exists.';
|
||||
this.toastr.error(message, 'Duplicate IANA timezone ID');
|
||||
} else if (error.status === 404) {
|
||||
this.toastr.error('The timezone is no longer available.');
|
||||
this.closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
private apiErrorMessage(error: HttpErrorResponse): string | null {
|
||||
const body: unknown = error.error;
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
if ('detail' in body && typeof body.detail === 'string') return body.detail;
|
||||
if ('message' in body && typeof body.message === 'string') return body.message;
|
||||
return null;
|
||||
}
|
||||
|
||||
private finishSave(): void {
|
||||
this.showModal.set(false);
|
||||
this.selectedTimezoneId.set(null);
|
||||
this.selectedTimezone.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
this.loadTimezones(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
private resetFormState(): void {
|
||||
this.timezoneForm.markAsPristine();
|
||||
this.timezoneForm.markAsUntouched();
|
||||
this.timezoneForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => this.elementRef.nativeElement
|
||||
.querySelector<HTMLElement>('modal [data-form-control][aria-invalid="true"]')
|
||||
?.focus());
|
||||
formatUtcOffset(totalMinutes: number): string {
|
||||
const isNegative = totalMinutes < 0;
|
||||
const absMinutes = Math.abs(totalMinutes);
|
||||
const hours = Math.floor(absMinutes / 60);
|
||||
const minutes = absMinutes % 60;
|
||||
const formattedHours = String(hours).padStart(2, '0');
|
||||
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||
const prefix = isNegative ? '-' : '+';
|
||||
return `UTC${prefix}${formattedHours}:${formattedMinutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user