Merge pull request 'Feature/onboarding workflow sc' (#7) from feature/onboarding-workflow-sc into dev

Reviewed-on: sc/syscom-master-admin-ui#7
This commit is contained in:
2026-07-28 06:55:02 +00:00
193 changed files with 7699 additions and 10363 deletions
-133
View File
@@ -1,133 +0,0 @@
import { computed, Injectable, inject, signal } from '@angular/core';
import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model';
import { API_CONFIG } from '../config/api.config';
import { TokenStorageService } from './token-storage.service';
import { AppContextService } from '../services/app-context.service';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly tokenStorage = inject(TokenStorageService);
private readonly appContextService = inject(AppContextService);
private readonly userSubject = new BehaviorSubject<UserProfile | null>(null);
readonly user$ = this.userSubject.asObservable();
readonly currentUserSignal = signal<UserProfile | null>(null);
private readonly accessTokenSignal = signal<string | null>(null);
readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal());
private refreshRequest$: Observable<LoginResponse> | null = null;
constructor() {
this.restoreAuthState();
}
login(payload: LoginRequest, rememberMe: boolean): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/login`, payload).pipe(
map((response) => {
const user = this.normalizeUserProfile(response);
this.tokenStorage.saveAuth(response, rememberMe);
this.setAuthState(user, this.tokenStorage.getAccessToken());
return response;
})
);
}
refreshAccessToken(): Observable<LoginResponse> {
if (this.refreshRequest$) {
return this.refreshRequest$;
}
const refreshToken = this.tokenStorage.getRefreshToken();
if (!refreshToken) {
this.logout();
return throwError(() => new HttpErrorResponse({ status: 401, statusText: 'Refresh token missing' }));
}
this.refreshRequest$ = this.http.post<LoginResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe(
map((response) => {
const user = this.normalizeUserProfile(response, this.currentUserSignal());
const storageType = this.tokenStorage.getStorageType();
const rememberMe = storageType === 'local';
this.tokenStorage.saveAuth(response, rememberMe);
this.setAuthState(user, this.tokenStorage.getAccessToken());
return response;
}),
catchError((error) => {
this.logout();
return throwError(() => error);
}),
finalize(() => {
this.refreshRequest$ = null;
}),
shareReplay(1)
);
return this.refreshRequest$;
}
logout(): void {
this.tokenStorage.clearAuth();
this.appContextService.clearContext();
this.setAuthState(null, null);
this.refreshRequest$ = null;
}
get accessToken(): string | null {
return this.tokenStorage.getAccessToken();
}
get refreshToken(): string | null {
return this.tokenStorage.getRefreshToken();
}
get isAuthenticated(): boolean {
return this.isAuthenticatedSignal();
}
get isLoggedIn(): boolean {
return this.isAuthenticatedSignal();
}
get currentUser(): UserProfile | null {
return this.currentUserSignal();
}
private restoreAuthState(): void {
const storedUser = this.tokenStorage.getUser();
const storedAccessToken = this.tokenStorage.getAccessToken();
if (storedUser && storedAccessToken) {
this.setAuthState(storedUser, storedAccessToken);
return;
}
this.setAuthState(null, storedAccessToken ?? null);
}
private setAuthState(user: UserProfile | null, token: string | null): void {
this.userSubject.next(user);
this.currentUserSignal.set(user);
this.accessTokenSignal.set(token);
}
private normalizeUserProfile(response: LoginResponse, fallbackUser: UserProfile | null = null): UserProfile | null {
if (response.user) {
return response.user;
}
const userId = response.userId?.trim();
const email = response.email?.trim();
if (userId || email) {
return {
id: userId ?? fallbackUser?.id ?? '',
email: email ?? fallbackUser?.email ?? '',
roles: response.roles,
};
}
return fallbackUser;
}
}
-152
View File
@@ -1,152 +0,0 @@
import { Injectable } from '@angular/core';
import { LoginResponse, UserProfile } from '../models/auth.model';
type StorageType = 'local' | 'session';
@Injectable({ providedIn: 'root' })
export class TokenStorageService {
private readonly accessTokenKey = 'master-admin-access-token';
private readonly refreshTokenKey = 'master-admin-refresh-token';
private readonly accessTokenExpiresOnKey = 'master-admin-access-token-expires-on';
private readonly refreshTokenExpiresOnKey = 'master-admin-refresh-token-expires-on';
private readonly userKey = 'master-admin-user';
saveAuth(response: LoginResponse, rememberMe: boolean): void {
this.clearAuth();
const selectedStorage = rememberMe ? localStorage : sessionStorage;
if (response.accessToken) {
selectedStorage.setItem(this.accessTokenKey, response.accessToken);
}
if (response.refreshToken) {
selectedStorage.setItem(this.refreshTokenKey, response.refreshToken);
}
if (response.accessTokenExpiresOn) {
selectedStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn);
}
if (response.refreshTokenExpiresOn) {
selectedStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn);
}
const user = this.buildUserProfile(response);
if (user) {
selectedStorage.setItem(this.userKey, JSON.stringify(user));
}
}
clearAuth(): void {
this.removeFromStorage(localStorage);
this.removeFromStorage(sessionStorage);
}
getAccessToken(): string | null {
return this.getByPriority(this.accessTokenKey);
}
getRefreshToken(): string | null {
return this.getByPriority(this.refreshTokenKey);
}
getAccessTokenExpiresOn(): string | null {
return this.getByPriority(this.accessTokenExpiresOnKey);
}
getRefreshTokenExpiresOn(): string | null {
return this.getByPriority(this.refreshTokenExpiresOnKey);
}
getUser(): UserProfile | null {
const raw = this.getByPriority(this.userKey);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as UserProfile;
} catch {
this.clearAuth();
return null;
}
}
getStorageType(): 'local' | 'session' | null {
if (this.hasAnyAuthKey(sessionStorage)) {
return 'session';
}
if (this.hasAnyAuthKey(localStorage)) {
return 'local';
}
return null;
}
isAccessTokenExpired(): boolean {
return this.isExpired(this.getAccessTokenExpiresOn());
}
isRefreshTokenExpired(): boolean {
return this.isExpired(this.getRefreshTokenExpiresOn());
}
private getByPriority(key: string): string | null {
const fromSession = sessionStorage.getItem(key);
if (fromSession !== null) {
return fromSession;
}
return localStorage.getItem(key);
}
private hasAnyAuthKey(storage: Storage): boolean {
return [
this.accessTokenKey,
this.refreshTokenKey,
this.accessTokenExpiresOnKey,
this.refreshTokenExpiresOnKey,
this.userKey,
].some((key) => storage.getItem(key) !== null);
}
private removeFromStorage(storage: Storage): void {
storage.removeItem(this.accessTokenKey);
storage.removeItem(this.refreshTokenKey);
storage.removeItem(this.accessTokenExpiresOnKey);
storage.removeItem(this.refreshTokenExpiresOnKey);
storage.removeItem(this.userKey);
}
private isExpired(expiresOn: string | null): boolean {
debugger;
if (!expiresOn) {
return true;
}
const parsedExpiry = Date.parse(expiresOn);
if (Number.isNaN(parsedExpiry)) {
return true;
}
return parsedExpiry <= Date.now();
}
private buildUserProfile(response: LoginResponse): UserProfile | null {
if (response.user) {
return response.user;
}
if (response.userId || response.email) {
return {
id: response.userId ?? '',
email: response.email ?? '',
roles: response.roles,
};
}
return null;
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { inject } from '@angular/core';
import { CanActivateChildFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { TokenStorageService } from '../auth/token-storage.service';
import { AuthService } from '../../features/authentication/data-access/auth.service';
import { TokenStorageService } from '../services/auth/token-storage.service';
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
const authService = inject(AuthService);
+1 -1
View File
@@ -1,7 +1,7 @@
import { inject } from '@angular/core';
import { CanActivateChildFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../../services/auth/auth.service';
import { AuthService } from '../../../features/authentication/data-access/auth.service';
import { TokenStorageService } from '../../services/auth/token-storage.service';
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../../services/auth/auth.service';
import { AuthService } from '../../../features/authentication/data-access/auth.service';
import { TokenStorageService } from '../../services/auth/token-storage.service';
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
@@ -1,11 +1,11 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../../services/auth/auth.service';
import { AuthService } from '../../../features/authentication/data-access/auth.service';
export const superAdminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return (auth.currentUser?.roles ?? []).includes('super_admin')
return (auth.currentUser()?.roles ?? []).includes('super_admin')
? true
: router.createUrlTree(['/dashboards/crm']);
};
+2 -2
View File
@@ -1,8 +1,8 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { TokenStorageService } from '../auth/token-storage.service';
import { AuthService } from '../../features/authentication/data-access/auth.service';
import { TokenStorageService } from '../services/auth/token-storage.service';
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
@@ -2,7 +2,7 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth/auth.service';
import { AuthService } from '../../features/authentication/data-access/auth.service';
import { API_CONFIG } from '../config/api.config';
import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints';
@@ -12,7 +12,7 @@ export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
: req;
if (!shouldSkipLoader) {
loadingService.show();
// loadingService.show();
}
return next(request).pipe(
-12
View File
@@ -1,12 +0,0 @@
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
}
export interface ProblemDetails {
title?: string;
status?: number;
detail?: string;
errors?: Record<string, string[]>;
}
-24
View File
@@ -1,24 +0,0 @@
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken?: string;
accessTokenExpiresOn?: string;
refreshTokenExpiresOn?: string;
expiresIn?: number;
user?: UserProfile;
userId?: string;
email?: string;
roles?: string[];
}
export interface UserProfile {
id: string;
email: string;
displayName?: string;
roles?: string[];
tenantId?: string;
}
-26
View File
@@ -1,26 +0,0 @@
export interface CityDto {
id: string;
stateId: string;
name: string;
code: string | null;
timezoneId: string | null;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface CreateCityRequest {
stateId: string;
name: string;
code: string;
timezoneId: string | null;
}
export interface UpdateCityRequest {
name: string;
code: string;
timezoneId: string | null;
isActive: boolean;
}
export type CityModalMode = 'create' | 'edit' ;
-33
View File
@@ -1,33 +0,0 @@
import { UserProfile } from './auth.model';
import { Menu } from '../../shared/services/nav.service';
export interface CurrentUserContext extends UserProfile {
fullName?: string;
defaultLandingPage?: string;
}
export interface TenantContext {
tenantId?: string;
companyId?: string;
companyName?: string;
tenantName?: string;
defaultLandingPage?: string;
}
export interface PermissionContext {
roles: string[];
permissions: string[];
defaultLandingPage?: string;
}
export interface MenuContext {
items: Menu[];
defaultLandingPage?: string;
}
export interface AppContextState {
user: CurrentUserContext;
tenant: TenantContext;
permissions: PermissionContext;
menu: MenuContext;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { UserProfile } from '../auth/auth.model';
import { UserProfile } from '../../../features/authentication/models/auth.model';
import { Menu } from '../../services/common/nav.service';
export interface CurrentUserContext extends UserProfile {
@@ -1,31 +0,0 @@
export interface CountryDto {
id: string;
iso2: string;
iso3: string;
name: string;
phoneCode: string | null;
defaultCurrencyId: string | null;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface CountryLookupDto {
id: string;
iso2: string;
name: string;
}
export interface CreateCountryRequest {
iso2: string;
iso3: string;
name: string;
phoneCode: string | null;
defaultCurrencyId: string | null;
}
export interface UpdateCountryRequest extends CreateCountryRequest {
isActive: boolean;
}
export type CountryModalMode = 'create' | 'edit';
@@ -1,34 +0,0 @@
export interface CurrencyDto {
id: string;
code: string;
iso2: string;
name: string;
symbol: string;
numericCode: number;
decimalDigits: number;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface CurrencyLookupDto {
readonly id: string;
readonly code: string;
readonly name: string;
readonly symbol: string;
}
export interface CreateCurrencyRequest {
code: string;
name: string;
symbol: string;
numericCode: number;
decimalDigits: number;
}
export interface UpdateCurrencyRequest extends CreateCurrencyRequest {
isActive: boolean;
}
export type CurrencyModalMode = 'create' | 'edit';
@@ -1,31 +0,0 @@
export interface LanguageDto {
id: string;
code: string;
name: string;
nativeName: string;
isRightToLeft: boolean;
isActive: boolean;
createdOn: string;
modifiedOn: string | null;
}
export interface LanguageLookupDto {
readonly id: string;
readonly code: string;
readonly name: string;
readonly nativeName: string;
readonly isRightToLeft: boolean;
}
export interface CreateLanguageRequest {
code: string;
name: string;
nativeName: string;
isRightToLeft: boolean;
}
export interface UpdateLanguageRequest extends CreateLanguageRequest {
isActive: boolean;
}
export type LanguageModalMode = 'create' | 'edit';
-30
View File
@@ -1,30 +0,0 @@
export interface StateDto {
id: string;
countryId: string;
name: string;
code: string | null;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface StateLookupDto {
id: string;
name: string;
code: string;
}
export interface CreateStateRequest {
countryId: string;
name: string;
code: string;
}
export interface UpdateStateRequest {
countryId: null;
name: string;
code: string;
isActive: boolean;
}
export type StateModalMode = 'create' | 'edit';
@@ -1,53 +0,0 @@
import { DataTableRecord } from '../../../shared/components/data-table/data-table.types';
export interface TenantCurrencyDto {
id: string;
tenantId: string;
tenantName: string;
currencyId: string;
currencyName: string;
isBaseCurrency: boolean;
isReporting: boolean;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface TenantCurrencyLookupDto {
id: string;
tenantId: string;
currencyId: string;
isBaseCurrency: boolean;
isReporting: boolean;
}
export interface CreateTenantCurrencyRequest {
tenantId: string;
currencyId: string;
isBaseCurrency: boolean;
isReporting: boolean;
}
export interface UpdateTenantCurrencyRequest {
isBaseCurrency: boolean;
isReporting: boolean;
isActive: boolean;
}
export interface TenantCurrencyTableRow extends DataTableRecord {
readonly id: string;
readonly tenantId: string;
readonly tenantName: string;
readonly currencyId: string;
readonly currencyName: string;
readonly isBaseCurrency: boolean;
readonly isReporting: boolean;
readonly isActive: boolean;
readonly serialNumber: number;
readonly createdOn?: string;
readonly modifiedOn?: string | null;
}
export type TenantCurrencyModalMode = 'create' | 'edit';
@@ -1,68 +0,0 @@
import { DataTableRecord } from '../../../shared/components/data-table/data-table.types';
export enum TenantStatus { Trial = 0, Active = 1, Suspended = 2, Cancelled = 3 }
export interface TenantDto {
id: string;
code: string;
name: string;
status: TenantStatus;
defaultLanguageId: string;
defaultLanguageName: string | null;
defaultDbConnectionId: string | null;
defaultDbConnectionName: string | null;
defaultCurrencyId: string;
defaultCurrencyName: string | null;
defaultTimezoneId: string;
defaultTimezoneName: string | null;
dataRegion: string;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface TenantLookupDto {
id: string;
name: string;
code: string;
}
export interface CreateTenantRequest {
code: string;
name: string;
status: TenantStatus;
defaultLanguageId: string;
defaultCurrencyId: string;
defaultTimezoneId: string;
dataRegion: string;
}
export interface UpdateTenantRequest {
code: string;
name: string;
status: TenantStatus;
defaultLanguageId: string;
defaultCurrencyId: string;
defaultTimezoneId: string;
defaultDbConnectionId: string | null;
dataRegion: string;
isActive: boolean;
}
export interface TenantTableRow extends DataTableRecord {
readonly id: string;
readonly code: string;
readonly name: string;
readonly status: TenantStatus;
readonly dataRegion: string;
readonly isActive: boolean;
readonly serialNumber: number;
readonly createdOn?: string;
readonly modifiedOn?: string | null;
readonly defaultLanguageName: string | null;
readonly defaultCurrencyName: string | null;
readonly defaultTimezoneName: string | null;
}
export type TenantModalMode = 'create' | 'edit';
@@ -1,27 +0,0 @@
export interface TimezoneDto {
readonly id: string;
readonly ianaId: string;
readonly displayName: string;
readonly utcOffsetMinutes: number;
readonly isActive: boolean;
readonly createdOn: string;
readonly modifiedOn: string | null;
}
export interface TimezoneLookupDto {
readonly id: string;
readonly ianaId: string;
readonly displayName: string;
}
export interface CreateTimezoneRequest {
readonly ianaId: string;
readonly displayName: string;
readonly utcOffsetMinutes: number;
}
export interface UpdateTimezoneRequest extends CreateTimezoneRequest {
readonly isActive: boolean;
}
export type TimezoneModalMode = 'create' | 'edit' | 'view';
-29
View File
@@ -1,29 +0,0 @@
export enum UserStatus { Pending = 0, Active = 1, Suspended = 2, Locked = 3, Disabled = 4 }
export interface CreateUserRequest {
email: string;
password: string;
roleCodes: string[];
}
export interface UserDto {
id: string;
email: string;
status: UserStatus;
roles: string[];
isActive: boolean;
createdOn: string;
updatedOn?: string | null;
lastLoginOn: string | null;
}
export interface UserLookupDto {
readonly id: string;
readonly ianaId: string;
readonly displayName: string;
}
export interface UpdateUserRequest extends CreateUserRequest {
readonly isActive: boolean;
}
export type UserModalMode = 'create' | 'edit';
@@ -1,104 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { finalize, forkJoin, Observable, of, shareReplay, tap } from 'rxjs';
import { AppContextState, MenuContext } from '../models/context.model';
import { MenuService } from './menu.service';
import { PermissionService } from './permission.service';
import { TenantContextService } from './tenant-context.service';
import { UserContextService } from './user-context.service';
import { NavService } from '../../shared/services/nav.service';
@Injectable({ providedIn: 'root' })
export class AppContextService {
private readonly userContextService = inject(UserContextService);
private readonly tenantContextService = inject(TenantContextService);
private readonly permissionService = inject(PermissionService);
private readonly menuService = inject(MenuService);
private readonly navService = inject(NavService);
private loadRequest$: Observable<AppContextState> | null = null;
private menuLoadRequest$: Observable<MenuContext> | null = null;
private loaded = false;
ensureMenuInitialized(forceReload = false): Observable<MenuContext> {
const currentMenu = this.menuService.menuContext();
if (currentMenu && !forceReload) {
this.navService.setMenuItems(this.menuService.getNavigationMenu());
return of(currentMenu);
}
if (this.menuLoadRequest$ && !forceReload) {
return this.menuLoadRequest$;
}
this.menuLoadRequest$ = this.menuService.loadMenu().pipe(
tap(() => {
this.navService.setMenuItems(this.menuService.getNavigationMenu());
}),
finalize(() => {
this.menuLoadRequest$ = null;
}),
shareReplay(1)
);
return this.menuLoadRequest$;
}
loadAppContext(forceReload = false): Observable<AppContextState> {
if (this.loaded && !forceReload) {
return of(this.getCurrentState());
}
if (this.loadRequest$ && !forceReload) {
return this.loadRequest$;
}
this.loadRequest$ = forkJoin({
user: this.userContextService.loadCurrentUserProfile(),
tenant: this.tenantContextService.loadTenantContext(),
permissions: this.permissionService.loadPermissions(),
menu: this.menuService.loadMenu(),
}).pipe(
tap((context) => {
this.navService.setMenuItems(this.menuService.getNavigationMenu());
this.loaded = true;
}),
finalize(() => {
this.loadRequest$ = null;
}),
shareReplay(1)
);
return this.loadRequest$;
}
ensureContextLoaded(): Observable<AppContextState> {
return this.loadAppContext();
}
clearContext(): void {
this.userContextService.clear();
this.tenantContextService.clear();
this.permissionService.clear();
this.menuService.clear();
this.navService.clearMenuItems();
this.loaded = false;
this.loadRequest$ = null;
}
getDefaultLandingPage(): string {
return this.userContextService.currentUserContext()?.defaultLandingPage
?? this.permissionService.permissionContext()?.defaultLandingPage
?? this.tenantContextService.tenantContext()?.defaultLandingPage
?? this.menuService.getDefaultLandingPage()
?? '/dashboards/crm';
}
private getCurrentState(): AppContextState {
return {
user: this.userContextService.currentUserContext() ?? { id: '', email: '', roles: [] },
tenant: this.tenantContextService.tenantContext() ?? {},
permissions: this.permissionService.permissionContext() ?? { roles: [], permissions: [] },
menu: this.menuService.menuContext() ?? { items: [] },
};
}
}
@@ -5,7 +5,7 @@ import { Router } from '@angular/router';
import { merge, fromEvent, Subscription } from 'rxjs';
import { throttleTime } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
import { AuthService } from '../auth/auth.service';
import { AuthService } from '../../../features/authentication/data-access/auth.service';
@Injectable()
export class SessionTimeoutService implements OnDestroy {
@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { LoginResponse, UserProfile } from '../../models/auth/auth.model';
import { LoginResponse, UserProfile } from '../../../features/authentication/models/auth.model';
type StorageType = 'local' | 'session';
-34
View File
@@ -1,34 +0,0 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { API_CONFIG } from '../config/api.config';
import { ApiResponse } from '../models/api-response.model';
@Injectable({ providedIn: 'root' })
export class BaseApiService {
private readonly http = inject(HttpClient);
protected get<T>(resource: string, params?: Record<string, string | number | boolean>): Observable<T> {
let httpParams = new HttpParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
httpParams = httpParams.set(key, String(value));
});
}
return this.http.get<T>(`${API_CONFIG.baseUrl}${resource}`, { params: httpParams });
}
protected post<T, R = unknown>(resource: string, body: T): Observable<R> {
return this.http.post<R>(`${API_CONFIG.baseUrl}${resource}`, body);
}
protected put<T, R = unknown>(resource: string, body: T): Observable<R> {
return this.http.put<R>(`${API_CONFIG.baseUrl}${resource}`, body);
}
protected delete<R = unknown>(resource: string): Observable<R> {
return this.http.delete<R>(`${API_CONFIG.baseUrl}${resource}`);
}
}
+104 -141
View File
@@ -1,55 +1,56 @@
import { DOCUMENT, ElementRef, inject, Injectable, Renderer2 } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { DOCUMENT, inject, Injectable } from '@angular/core';
import { signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
interface StateType {
export interface StateType {
direction: string;
theme: string;
navigationStyles: string, // vertical, horizontal
menuStyles: string, // menu-click, menu-hover, icon-click, icon-hover
layoutStyles: string, // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu
pageStyles: string, // regular, classic, modern
widthStyles: string, // fullwidth, boxed
menuPosition: string, // fixed, scrollable
headerPosition: string, // fixed, scrollable
menuColor: string, // light, dark, color, gradient, transparent
headerColor: string, // light, dark, color, gradient, transparent
themePrimary: string, // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90'
themeBackground: string,
backgroundImage: string,
};
navigationStyles: string;
menuStyles: string;
layoutStyles: string;
pageStyles: string;
widthStyles: string;
menuPosition: string;
headerPosition: string;
menuColor: string;
headerColor: string;
themePrimary: string;
themeBackground: any;
backgroundImage: string;
}
@Injectable({
providedIn: 'root'
})
export class AppStateService {
private readonly localStorageKey = 'Ynex-ng'; // Customize this key
private initialState: StateType = {
theme: 'light', // light, dark
direction: 'ltr', // ltr, rtl
navigationStyles: 'vertical', // vertical, horizontal
menuStyles: '', // menu-click, menu-hover, icon-click, icon-hover
layoutStyles: 'default', // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu
pageStyles: 'regular', // regular, classic, modern
widthStyles: 'fullwidth', // fullwidth, boxed
menuPosition: 'fixed', // fixed, scrollable
headerPosition: 'fixed', // fixed, scrollable
menuColor: 'dark', // light, dark, color, gradient, transparent
headerColor: 'light', // light, dark, color, gradient, transparent
themePrimary: '', // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90'
private readonly localStorageKey = 'Ynex-ng';
private readonly initialState: StateType = {
theme: 'light',
direction: 'ltr',
navigationStyles: 'vertical',
menuStyles: '',
layoutStyles: 'default',
pageStyles: 'regular',
widthStyles: 'fullwidth',
menuPosition: 'fixed',
headerPosition: 'fixed',
menuColor: 'dark',
headerColor: 'light',
themePrimary: '',
themeBackground: '',
backgroundImage: '', // bgimg1, bgimg2, bgimg3, bgimg4, bgimg5
} // Store initial state
private stateSubject = new BehaviorSubject<StateType>(this.initialState); // Use any for initial null value
state$ = this.stateSubject.asObservable();
backgroundImage: '',
};
private readonly stateSignal = signal<StateType>(this.getInitialStateFromLocalStorage());
readonly state = this.stateSignal.asReadonly();
readonly state$ = toObservable(this.stateSignal);
private document = inject(DOCUMENT);
navigationStyles: any;
private html = this.document.documentElement;
constructor() {
const initialState: StateType = this.getInitialStateFromLocalStorage();
// this.initializeState();
this.stateSubject.next(initialState);
this.updateStateAndEmit(this.stateSignal());
}
private getInitialStateFromLocalStorage(): StateType {
@@ -64,40 +65,33 @@ export class AppStateService {
return this.initialState;
}
getupdateState() {
const currentState = this.stateSubject.getValue();
return currentState
getupdateState(): StateType {
return this.stateSignal();
}
updateState(newState?: Partial<any>) { // Use any for partial updates
const currentState = this.stateSubject.getValue(); // Get current state
updateState(newState?: Partial<StateType>) {
const currentState = this.stateSignal();
if (!currentState) {
// Handle initial update case (no state emitted yet)
this.updateStateAndEmit(newState);
if (newState) {
this.updateStateAndEmit({ ...this.initialState, ...newState });
}
return;
}
if (newState) {
const updatedState = { ...currentState, ...newState }; // Merge updates
this.updateStateAndEmit(updatedState); // Update and emit combined state
const updatedState = { ...currentState, ...newState };
this.updateStateAndEmit(updatedState);
} else {
this.updateStateAndEmit(currentState);
return;
}
}
private state: { [key: string]: any } = {};
private stateStore: { [key: string]: any } = {};
getState(menuStyles: string): any {
return this.state[menuStyles];
return this.stateStore[menuStyles];
}
private applyThemeBackgroundSpecificChanges(background: any) {
private applyThemeBackgroundSpecificChanges(background: any) {
this.html?.style.setProperty('--color-bodybg', background.main);
this.html?.style.setProperty('--color-bodybg2', background.secondary);
this.html?.style.setProperty('--color-light', background.accent);
@@ -107,48 +101,30 @@ export class AppStateService {
this.applythemeSpecificChanges(background.theme);
}
private applyDirectionSpecificChanges(direction: string) {
this.html?.setAttribute('dir', direction);
}
private applythemeSpecificChanges(theme: string) {
this.html?.setAttribute('class', theme); //setting theme style
this.html?.setAttribute('data-header-styles', theme); //setting header style
this.html?.setAttribute('class', theme);
this.html?.setAttribute('data-header-styles', theme);
}
private applyNavigationStylesSpecificChanges(navigationStyles: string) {
this.html?.setAttribute('data-nav-layout', navigationStyles);
if (navigationStyles == 'horizontal') {
if (navigationStyles === 'horizontal') {
this.html?.setAttribute('data-nav-style', 'menu-click');
this.html?.removeAttribute('data-vertical-style');
}
}
private applyMenuStylesSpecificChanges(menuStyles: string) {
this.html?.setAttribute('data-nav-style', menuStyles);
this.html?.setAttribute('data-toggled', menuStyles + '-closed');
this.html?.removeAttribute('data-vertical-style');
}
private applyLayoutStylesSpecificChanges(layoutStyles: string) {
private applyLayoutStylesSpecificChanges(layoutStyles: string) {
this.html?.setAttribute('data-vertical-style', layoutStyles);
this.html?.removeAttribute('data-nav-style');
switch (layoutStyles) {
@@ -175,64 +151,57 @@ export class AppStateService {
if (layoutStyles === 'icon-text') {
this.html?.setAttribute('icon-text', 'open');
} else {
// If not 'icon-text', remove the icon-text attribute
this.html?.removeAttribute('icon-text');
}
}
private applypageStylesSpecificChanges(pageStyles: string) {
private applypageStylesSpecificChanges(pageStyles: string) {
this.html?.setAttribute('data-page-style', pageStyles);
const slideRight = document.querySelector('.slide-right') as HTMLElement | null;
if (slideRight) {
// If the element exists, toggle the 'd-none' class
if (slideRight.classList.contains('d-none')) {
slideRight.classList.remove('d-none');
} else {
slideRight.classList.add('d-none');
}
} else {
// If the element does not exist (is null), create a safe fallback by adding 'd-none'
const dummySlideRight = document.createElement('div');
dummySlideRight.classList.add('slide-right', 'd-none'); // Add classes to the new element
document.body.appendChild(dummySlideRight); // Append it to the DOM as a fallback
dummySlideRight.classList.add('slide-right', 'd-none');
document.body.appendChild(dummySlideRight);
}
}
private applywidthStylesSpecificChanges(widthStyles: string) {
private applywidthStylesSpecificChanges(widthStyles: string) {
this.html?.setAttribute('data-width', widthStyles);
}
private applymenuPositionSpecificChanges(menuPosition: string) {
private applymenuPositionSpecificChanges(menuPosition: string) {
this.html?.setAttribute('data-menu-position', menuPosition);
}
private applyheaderPositionSpecificChanges(headerPosition: string) {
private applyheaderPositionSpecificChanges(headerPosition: string) {
this.html?.setAttribute('data-header-position', headerPosition);
}
private applyheaderColorSpecificChanges(headerColor: string) {
private applyheaderColorSpecificChanges(headerColor: string) {
this.html?.setAttribute('data-header-styles', headerColor);
}
private applymenuColorSpecificChanges(menuColor: string) {
private applymenuColorSpecificChanges(menuColor: string) {
this.html?.setAttribute('data-menu-styles', menuColor);
}
private applyPrimarySpecificChanges(primary: string) {
private applyPrimarySpecificChanges(primary: string) {
this.html?.style.setProperty('--color-primaryrgb', primary);
this.html?.style.setProperty('--color-primary', primary);
}
private applybackgroundImageSpecificChanges(backgroundImage: string) {
private applybackgroundImageSpecificChanges(backgroundImage: string) {
this.html?.setAttribute('bg-img', backgroundImage);
}
public applyReset() {
if (this.html) {
this.html?.style.removeProperty('--color-bodybg');
this.html?.style.removeProperty('--color-gray3');
@@ -242,76 +211,70 @@ export class AppStateService {
this.html?.style.removeProperty('--color-inputborder');
this.html?.style.removeProperty('--color-primary');
this.html?.style.removeProperty('--color-primaryrgb');
}
this.html?.removeAttribute('bg-img');
this.html?.setAttribute('data-vertical-style', 'overlay');
this.stateSubject.next(this.initialState);
this.updateStateAndEmit(this.initialState);
localStorage.clear();
try {
localStorage.removeItem(this.localStorageKey);
} catch (error) {
console.error('Error resetting local storage:', error);
}
if (window.innerWidth <= 992) {
this.html?.setAttribute('data-toggled', 'close');
}
}
private updateStateAndEmit(state: any) {
// Conditional logic based on direction changes
const currentState = this.stateSubject.getValue(); // Get current state
// Conditional logic based on theme changes
if (state['theme']) {
this.applythemeSpecificChanges(state['theme']);
private updateStateAndEmit(state: StateType) {
if (state.theme) {
this.applythemeSpecificChanges(state.theme);
}
if (state['direction']) {
this.applyDirectionSpecificChanges(state['direction']);
if (state.direction) {
this.applyDirectionSpecificChanges(state.direction);
}
// Conditional logic based on theme changes
if (state['navigationStyles']) {
this.applyNavigationStylesSpecificChanges(state['navigationStyles']);
if (state.navigationStyles) {
this.applyNavigationStylesSpecificChanges(state.navigationStyles);
}
// Conditional logic based on theme changes
if (state['menuStyles'] && !state['layoutStyles']) {
this.applyMenuStylesSpecificChanges(state['menuStyles']);
if (state.menuStyles && !state.layoutStyles) {
this.applyMenuStylesSpecificChanges(state.menuStyles);
}
if (state['layoutStyles'] && !state['menuStyles']) {
this.applyLayoutStylesSpecificChanges(state['layoutStyles']);
if (state.layoutStyles && !state.menuStyles) {
this.applyLayoutStylesSpecificChanges(state.layoutStyles);
}
if (state['pageStyles']) {
this.applypageStylesSpecificChanges(state['pageStyles']);
if (state.pageStyles) {
this.applypageStylesSpecificChanges(state.pageStyles);
}
if (state['widthStyles']) {
this.applywidthStylesSpecificChanges(state['widthStyles']);
if (state.widthStyles) {
this.applywidthStylesSpecificChanges(state.widthStyles);
}
if (state['menuPosition']) {
this.applymenuPositionSpecificChanges(state['menuPosition']);
if (state.menuPosition) {
this.applymenuPositionSpecificChanges(state.menuPosition);
}
if (state['headerPosition']) {
this.applyheaderPositionSpecificChanges(state['headerPosition']);
if (state.headerPosition) {
this.applyheaderPositionSpecificChanges(state.headerPosition);
}
if (state['themePrimary']) {
this.applyPrimarySpecificChanges(state['themePrimary']);
if (state.themePrimary) {
this.applyPrimarySpecificChanges(state.themePrimary);
}
if (state['themeBackground']) {
this.applyThemeBackgroundSpecificChanges(state['themeBackground']);
if (state.themeBackground) {
this.applyThemeBackgroundSpecificChanges(state.themeBackground);
}
if (state['headerColor']) {
this.applyheaderColorSpecificChanges(state['headerColor']);
if (state.headerColor) {
this.applyheaderColorSpecificChanges(state.headerColor);
}
if (state['menuColor']) {
this.applymenuColorSpecificChanges(state['menuColor']);
if (state.menuColor) {
this.applymenuColorSpecificChanges(state.menuColor);
}
if (state['backgroundImage']) {
this.applybackgroundImageSpecificChanges(state['backgroundImage']);
if (state.backgroundImage) {
this.applybackgroundImageSpecificChanges(state.backgroundImage);
}
this.stateSubject.next(state);
this.stateSignal.set(state);
this.updateLocalStorage(state);
}
private updateLocalStorage(state: any) {
private updateLocalStorage(state: StateType) {
try {
localStorage.setItem(this.localStorageKey, JSON.stringify(state));
} catch (error) {
+32 -52
View File
@@ -24,67 +24,47 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false,
dirchange: false,
children: [
{
title: 'Global Master',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false },
{ path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false },
{ path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false },
{ path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false },
{ path: '/global-masters/states', title: 'State', type: 'link', dirchange: false },
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
],
},
{
title: 'Tenant Master',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
{ path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false }
],
},
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
{
title: 'Configuration',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false },
{ path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false },
{ path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false },
{ path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false },
{ path: '/global-masters/states', title: 'State', type: 'link', dirchange: false },
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
{ path: '/localization', title: 'Localization', type: 'link', dirchange: false },
{
title: 'Branding',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/theming', title: 'Theming', type: 'link', dirchange: false },
{ path: '/platform', title: 'Platform', type: 'link', dirchange: false },
],
},
],
},
// {
// title: 'Tenant Master',
// type: 'sub',
// active: false,
// selected: false,
// dirchange: false,
// children: [
// { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
// { path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false }
// ],
// }
],
},
{
title: 'Operations',
icon: '<i class="bx bx-line-chart side-menu__icon"></i>',
title: 'Organizations',
icon: '<i class="bx bx-store-alt side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/billing', title: 'Billing', type: 'link', dirchange: false },
{ path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false },
{ path: '/organizations', title: 'Dashboard', type: 'link', dirchange: false },
{ path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false },
],
},
{
title: 'Settings',
icon: '<i class="bx bx-cog side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/settings/branding', title: 'Branding', type: 'link', dirchange: false },
],
},
],
@@ -1,74 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { TokenStorageService } from '../auth/token-storage.service';
import { MenuService } from './menu.service';
describe('MenuService dependency and authorization', () => {
let menuService: MenuService;
let tokenStorage: TokenStorageService;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideRouter([])],
});
menuService = TestBed.inject(MenuService);
tokenStorage = TestBed.inject(TokenStorageService);
});
afterEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('constructs MenuService and AuthService without a circular dependency', () => {
expect(menuService).toBeTruthy();
expect(TestBed.inject(AuthService)).toBeTruthy();
});
it('hides the Users menu when the stored user is not a super administrator', async () => {
tokenStorage.saveAuth(
{ accessToken: 'token', user: { id: '1', email: 'user@example.com', roles: ['admin'] } },
false,
);
const context = await firstValueFrom(menuService.loadMenu());
expect(hasPath(context.items, '/users')).toBe(false);
});
it('shows the Users menu when the stored user is a super administrator', async () => {
tokenStorage.saveAuth(
{
accessToken: 'token',
user: { id: '1', email: 'super@example.com', roles: ['super_admin'] },
},
false,
);
const context = await firstValueFrom(menuService.loadMenu());
expect(hasPath(context.items, '/users')).toBe(true);
});
it('clears menu state during context cleanup', async () => {
await firstValueFrom(menuService.loadMenu());
menuService.clear();
expect(menuService.menuContext()).toBeNull();
});
});
function hasPath(items: ReturnType<MenuService['getNavigationMenu']>, path: string): boolean {
return items.some(
(item) =>
item.path === path ||
(item.children ? hasPath(item.children, path) : false) ||
(item.children2 ? hasPath(item.children2, path) : false),
);
}
+20 -18
View File
@@ -1,8 +1,9 @@
import { Injectable, OnDestroy } from '@angular/core';
import { Subject, BehaviorSubject, fromEvent } from 'rxjs';
import { Injectable, OnDestroy, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { Subject, fromEvent } from 'rxjs';
import { takeUntil, debounceTime } from 'rxjs/operators';
import { Router } from '@angular/router';
// Menu
export interface Menu {
headTitle?: string;
headTitle2?: string;
@@ -20,10 +21,9 @@ export interface Menu {
children2?: Menu[];
Menusub?: boolean;
target?: boolean;
menutype?: string,
dirchange?: boolean,
nochild?: any
menutype?: string;
dirchange?: boolean;
nochild?: any;
}
@Injectable({
@@ -31,9 +31,14 @@ export interface Menu {
})
export class NavService implements OnDestroy {
private unsubscriber: Subject<any> = new Subject();
public screenWidth: BehaviorSubject<number> = new BehaviorSubject(
window.innerWidth
);
private readonly screenWidthSignal = signal<number>(window.innerWidth);
readonly screenWidth = this.screenWidthSignal.asReadonly();
readonly screenWidth$ = toObservable(this.screenWidthSignal);
private readonly itemsSignal = signal<Menu[]>([]);
readonly items = this.itemsSignal.asReadonly();
readonly items$ = toObservable(this.itemsSignal);
// Search Box
public search = false;
@@ -72,8 +77,7 @@ export class NavService implements OnDestroy {
}
});
if (window.innerWidth < 991) {
// Detect Route change sidebar close
this.router.events.subscribe((event) => {
this.router.events.subscribe(() => {
this.collapseSidebar = true;
this.megaMenu = false;
this.levelMenu = false;
@@ -82,21 +86,19 @@ export class NavService implements OnDestroy {
}
ngOnDestroy() {
this.unsubscriber.next;
this.unsubscriber.next(true);
this.unsubscriber.complete();
}
private setScreenWidth(width: number): void {
this.screenWidth.next(width);
this.screenWidthSignal.set(width);
}
items = new BehaviorSubject<Menu[]>([]);
setMenuItems(menuItems: Menu[]): void {
this.items.next(menuItems);
this.itemsSignal.set(menuItems);
}
clearMenuItems(): void {
this.items.next([]);
this.itemsSignal.set([]);
}
}
-24
View File
@@ -1,24 +0,0 @@
import { Injectable, signal} from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class LoadingService {
private requestCount = 0;
readonly isLoading = signal(false);
show(): void {
this.requestCount++;
this.isLoading.set(true);
}
hide(): void {
this.requestCount--;
if (this.requestCount <= 0) {
this.requestCount = 0;
this.isLoading.set(false);
}
}
}
-66
View File
@@ -1,66 +0,0 @@
import { MenuContext } from '../models/context.model';
export const SAAS_MENU_DATA: MenuContext = {
defaultLandingPage: '/dashboards/crm',
items: [
{ headTitle: 'MAIN' },
{
title: 'Dashboards',
icon: '<i class="bx bx-home side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false },
],
},
{ headTitle: 'SAAS ADMIN' },
{
title: 'Management',
icon: '<i class="bx bx-buildings side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
{
title: 'Configuration',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/global-masters', title: 'Global Masters', type: 'link', dirchange: false },
{ path: '/localization', title: 'Localization', type: 'link', dirchange: false },
{
title: 'Branding',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/theming', title: 'Theming', type: 'link', dirchange: false },
{ path: '/platform', title: 'Platform', type: 'link', dirchange: false },
],
},
],
},
],
},
{
title: 'Operations',
icon: '<i class="bx bx-line-chart side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/billing', title: 'Billing', type: 'link', dirchange: false },
{ path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false },
],
},
],
};
-42
View File
@@ -1,42 +0,0 @@
import { Injectable, signal } from '@angular/core';
import { Observable, of, tap } from 'rxjs';
import { MenuContext } from '../models/context.model';
import { SAAS_MENU_DATA } from './menu.data';
import { Menu } from '../../shared/services/nav.service';
@Injectable({ providedIn: 'root' })
export class MenuService {
readonly menuContext = signal<MenuContext | null>(null);
loadMenu(): Observable<MenuContext> {
const context = this.cloneMenuContext(SAAS_MENU_DATA);
return of(context).pipe(
tap((menuContext) => {
this.menuContext.set(menuContext);
})
);
}
getNavigationMenu(): Menu[] {
return this.cloneMenuItems(this.menuContext()?.items ?? []);
}
getDefaultLandingPage(): string | null {
return this.menuContext()?.defaultLandingPage ?? null;
}
clear(): void {
this.menuContext.set(null);
}
private cloneMenuContext(context: MenuContext): MenuContext {
return {
defaultLandingPage: context.defaultLandingPage,
items: this.cloneMenuItems(context.items),
};
}
private cloneMenuItems(items: Menu[]): Menu[] {
return JSON.parse(JSON.stringify(items)) as Menu[];
}
}
@@ -1,34 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { map, Observable, tap } from 'rxjs';
import { API_CONFIG } from '../config/api.config';
import { PermissionContext } from '../models/context.model';
interface PermissionResponse {
roles?: string[];
permissions?: string[];
defaultLandingPage?: string;
}
@Injectable({ providedIn: 'root' })
export class PermissionService {
private readonly http = inject(HttpClient);
readonly permissionContext = signal<PermissionContext | null>(null);
loadPermissions(): Observable<PermissionContext> {
return this.http.get<PermissionResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.permissionContext}`).pipe(
map((response) => ({
roles: response.roles ?? [],
permissions: response.permissions ?? [],
defaultLandingPage: response.defaultLandingPage?.trim() || undefined,
})),
tap((context) => {
this.permissionContext.set(context);
})
);
}
clear(): void {
this.permissionContext.set(null);
}
}
@@ -1,154 +0,0 @@
import { DOCUMENT } from '@angular/common';
import { DestroyRef, effect, inject, Injectable, OnDestroy, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { merge, fromEvent, Subscription } from 'rxjs';
import { throttleTime } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { AuthService } from '../auth/auth.service';
@Injectable()
export class SessionTimeoutService implements OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly router = inject(Router);
private readonly authService = inject(AuthService);
private readonly destroyRef = inject(DestroyRef);
readonly showWarning = signal(false);
readonly remainingSeconds = signal(0);
private readonly warningAfterMs = environment.sessionTimeout?.warningAfterMs ?? 25 * 60 * 1000;
private readonly logoutAfterMs = environment.sessionTimeout?.logoutAfterMs ?? 30 * 60 * 1000;
private activitySubscription: Subscription | null = null;
private warningTimer: ReturnType<typeof setTimeout> | null = null;
private logoutTimer: ReturnType<typeof setTimeout> | null = null;
private countdownTimer: ReturnType<typeof setInterval> | null = null;
private logoutDeadline = 0;
private trackingEnabled = false;
constructor() {
effect(() => {
if (this.authService.currentUserSignal()) {
this.start();
return;
}
this.stop();
});
}
start(): void {
if (this.trackingEnabled) {
this.resetTimers();
return;
}
this.trackingEnabled = true;
this.bindActivityTracking();
this.resetTimers();
}
stop(): void {
this.trackingEnabled = false;
this.showWarning.set(false);
this.remainingSeconds.set(0);
this.clearTimers();
this.activitySubscription?.unsubscribe();
this.activitySubscription = null;
}
staySignedIn(): void {
if (!this.trackingEnabled) {
return;
}
this.resetTimers();
}
logoutNow(): void {
this.handleTimeoutLogout();
}
ngOnDestroy(): void {
this.stop();
}
private bindActivityTracking(): void {
if (this.activitySubscription) {
return;
}
this.activitySubscription = merge(
fromEvent(this.document, 'mousemove'),
fromEvent(this.document, 'keydown'),
fromEvent(this.document, 'click'),
fromEvent(window, 'scroll')
)
.pipe(throttleTime(1000), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
if (!this.trackingEnabled || !this.authService.currentUserSignal()) {
return;
}
this.resetTimers();
});
}
private resetTimers(): void {
if (!this.trackingEnabled) {
return;
}
this.clearTimers();
this.showWarning.set(false);
this.remainingSeconds.set(0);
const safeWarningDelay = Math.max(Math.min(this.warningAfterMs, this.logoutAfterMs), 0);
const safeLogoutDelay = Math.max(this.logoutAfterMs, 0);
this.warningTimer = setTimeout(() => {
this.showWarning.set(true);
this.logoutDeadline = Date.now() + Math.max(safeLogoutDelay - safeWarningDelay, 0);
this.updateRemainingSeconds();
this.countdownTimer = setInterval(() => {
this.updateRemainingSeconds();
}, 1000);
}, safeWarningDelay);
this.logoutTimer = setTimeout(() => {
this.handleTimeoutLogout();
}, safeLogoutDelay);
}
private updateRemainingSeconds(): void {
const remainingMs = Math.max(this.logoutDeadline - Date.now(), 0);
this.remainingSeconds.set(Math.ceil(remainingMs / 1000));
}
private handleTimeoutLogout(): void {
const returnUrl = this.router.url.startsWith('/auth') ? '/' : this.router.url;
this.stop();
this.authService.logout();
void this.router.navigate(['/auth/login'], {
queryParams: returnUrl && returnUrl !== '/' ? { returnUrl } : undefined,
});
}
private clearTimers(): void {
if (this.warningTimer) {
clearTimeout(this.warningTimer);
this.warningTimer = null;
}
if (this.logoutTimer) {
clearTimeout(this.logoutTimer);
this.logoutTimer = null;
}
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
}
}
@@ -1,23 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { API_CONFIG } from '../config/api.config';
import { TenantContext } from '../models/context.model';
@Injectable({ providedIn: 'root' })
export class TenantContextService {
private readonly http = inject(HttpClient);
readonly tenantContext = signal<TenantContext | null>(null);
loadTenantContext(): Observable<TenantContext> {
return this.http.get<TenantContext>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`).pipe(
tap((context) => {
this.tenantContext.set(context);
})
);
}
clear(): void {
this.tenantContext.set(null);
}
}
@@ -1,32 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { map, Observable, tap } from 'rxjs';
import { API_CONFIG } from '../config/api.config';
import { CurrentUserContext } from '../models/context.model';
@Injectable({ providedIn: 'root' })
export class UserContextService {
private readonly http = inject(HttpClient);
readonly currentUserContext = signal<CurrentUserContext | null>(null);
loadCurrentUserProfile(): Observable<CurrentUserContext> {
return this.http.get<Partial<CurrentUserContext>>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.currentUserProfile}`).pipe(
map((response) => ({
id: response.id?.trim() ?? '',
email: response.email?.trim() ?? '',
displayName: response.displayName?.trim() || undefined,
fullName: response.fullName?.trim() || undefined,
tenantId: response.tenantId?.trim() || undefined,
roles: response.roles ?? [],
defaultLandingPage: response.defaultLandingPage?.trim() || undefined,
})),
tap((profile) => {
this.currentUserContext.set(profile);
})
);
}
clear(): void {
this.currentUserContext.set(null);
}
}
@@ -1,9 +1,10 @@
import { computed, Injectable, inject, signal } from '@angular/core';
import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';
import { Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { LoginRequest, LoginResponse, UserProfile } from '../../models/auth/auth.model';
import { TokenStorageService } from './token-storage.service';
import { AppContextService } from '../../services/context/app-context.service';
import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model';
import { TokenStorageService } from '../../../core/services/auth/token-storage.service';
import { AppContextService } from '../../../core/services/context/app-context.service';
import { AUTH_ENDPOINTS } from '../../../core/end-points/auth/auth.endpoints';
@Injectable({ providedIn: 'root' })
@@ -12,9 +13,10 @@ export class AuthService {
private readonly tokenStorage = inject(TokenStorageService);
private readonly appContextService = inject(AppContextService);
private readonly userSubject = new BehaviorSubject<UserProfile | null>(null);
readonly user$ = this.userSubject.asObservable();
readonly currentUserSignal = signal<UserProfile | null>(null);
readonly currentUser = this.currentUserSignal.asReadonly();
readonly user$ = toObservable(this.currentUserSignal);
private readonly accessTokenSignal = signal<string | null>(null);
readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal());
@@ -91,7 +93,7 @@ export class AuthService {
return this.isAuthenticatedSignal();
}
get currentUser(): UserProfile | null {
get currentUserValue(): UserProfile | null {
return this.currentUserSignal();
}
@@ -108,7 +110,6 @@ export class AuthService {
}
private setAuthState(user: UserProfile | null, token: string | null): void {
this.userSubject.next(user);
this.currentUserSignal.set(user);
this.accessTokenSignal.set(token);
}
@@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { AuthService } from '../../data-access/auth.service';
import { ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { catchError, finalize, of, switchMap, tap } from 'rxjs';
@@ -1,9 +0,0 @@
import { Routes } from '@angular/router';
export const billingRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/billing-list/billing-list').then((m) => m.BillingList),
data: { childTitle: 'Billing', parentTitle: 'Platform', subParentTitle: 'Subscriptions' },
},
];
@@ -1,5 +0,0 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Billing</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Plans, subscriptions, and tenant subscription upgrades will
be implemented here.</p>
</div>
@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-billing-list',
standalone: true,
imports: [CommonModule],
templateUrl: './billing-list.html',
styleUrl: './billing-list.scss',
})
export class BillingList {}
@@ -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,170 +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">
<button type="button" class="ti-btn ti-btn-sm ti-btn-primary-full !mb-0 flex min-h-8 w-full items-center justify-center gap-1.5 !px-3 md:!w-auto" (click)="applyCityFilters()">
<i class="ti ti-filter" aria-hidden="true"></i>
<span>Filter</span>
</button>
</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,53 +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;
@@ -59,6 +40,8 @@ interface CityTableRow extends DataTableRecord {
serialNumber: number;
state: string;
country: string;
stateName: string;
countryName: string;
createdOn?: string;
modifiedOn?: string | null;
}
@@ -66,495 +49,213 @@ interface CityTableRow extends DataTableRecord {
@Component({
selector: 'city-list',
standalone: true,
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard],
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
};
}
}
@@ -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>
@@ -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
};
}
}
@@ -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>
@@ -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');
@@ -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>
@@ -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' });
});
}
}
@@ -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>
@@ -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,46 +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">
<button type="button" class="ti-btn ti-btn-sm ti-btn-primary-full !mb-0 flex min-h-8 w-full items-center justify-center gap-1.5 !px-3 md:!w-auto" (click)="applyCountryFilter()">
<i class="ti ti-filter" aria-hidden="true"></i>
<span>Filter</span>
</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"
@@ -51,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,10 +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;
@@ -56,515 +42,165 @@ interface StateTableRow extends DataTableRecord {
@Component({
selector: 'state-list',
standalone: true,
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard],
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
};
}
}
@@ -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>
@@ -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}`;
}
}
@@ -0,0 +1,35 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { OnboardingLookupValue } from '../../organizations/organization-onboarding/constants/organization-onboarding.constants';
import { IndustryLookupDto } from '../models/industry.model';
import { INDUSTRY_ENDPOINTS } from './industry.endpoints';
import { DataTableQuery } from '../../../shared/components/data-table/data-table-query.types';
import { DataTableResult } from '../../../shared/components/data-table/data-table.types';
@Injectable({
providedIn: 'root'
})
export class IndustryApiService {
private readonly http = inject(HttpClient);
getIndustryById(id: string): Observable<IndustryLookupDto> {
return this.http.get<IndustryLookupDto>(INDUSTRY_ENDPOINTS.getById(id));
}
delete(id: string): Observable<void> {
return this.http.delete<void>(INDUSTRY_ENDPOINTS.delete(id));
}
autocomplete(term = '', limit = 50): Observable<IndustryLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<IndustryLookupDto[]>(INDUSTRY_ENDPOINTS.autocomplete, { params });
}
}
@@ -0,0 +1,21 @@
import { buildApiUrl } from '../../../core/config/api-url.util';
export const INDUSTRY_ENDPOINTS = {
dataTable: buildApiUrl('masterAdmin', '/v1/industries/datatable'),
create: buildApiUrl('masterAdmin', '/v1/industries'),
getById: (id: string) =>
buildApiUrl('masterAdmin', `/v1/industries/${encodeURIComponent(id)}`),
autocomplete: buildApiUrl('masterAdmin', '/v1/industries/autocomplete'),
update: (id: string) =>
buildApiUrl('masterAdmin', `/v1/industries/${encodeURIComponent(id)}`),
delete: (id: string) =>
buildApiUrl('masterAdmin', `/v1/industries/${encodeURIComponent(id)}`),
changeStatus: (id: string) =>
buildApiUrl('masterAdmin', `/v1/industries/${encodeURIComponent(id)}/status`),
} as const;
@@ -0,0 +1,7 @@
export interface IndustryLookupDto {
id: string;
tenantId?: string | null;
industryCode?: string;
industryName: string;
parentIndustryId?: string | null;
}
@@ -0,0 +1,2 @@
export { IndustryApiService } from './data-access/industery.service';
export type { IndustryLookupDto } from './models/industry.model';
@@ -1,9 +0,0 @@
import { Routes } from '@angular/router';
export const localizationRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/localization-list/localization-list').then((m) => m.LocalizationList),
data: { childTitle: 'Localization', parentTitle: 'Platform', subParentTitle: 'Translations' },
},
];
@@ -1,4 +0,0 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Localization</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Translation browser, editor, import/export, and missing translation reports will be added here.</p>
</div>
@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-localization-list',
standalone: true,
imports: [CommonModule],
templateUrl: './localization-list.html',
styleUrl: './localization-list.scss',
})
export class LocalizationList {}
@@ -1,9 +0,0 @@
import { Routes } from '@angular/router';
export const monitoringRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/monitoring-dashboard/monitoring-dashboard').then((m) => m.MonitoringDashboard),
data: { childTitle: 'Monitoring', parentTitle: 'Platform', subParentTitle: 'Observability' },
},
];
@@ -1,4 +0,0 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Tenant counts, subscription status, login audits, and recent failed logins will be added here.</p>
</div>
@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-monitoring-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './monitoring-dashboard.html',
styleUrl: './monitoring-dashboard.scss',
})
export class MonitoringDashboard {}
@@ -0,0 +1,78 @@
<div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
<div>
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome
back,
Json Taylor !</p>
<p class="font-normal text-[#8c9097] dark:text-white/50 text-[0.813rem]">Track your sales activity, leads and
deals
here.</p>
</div>
<div class="btn-list md:mt-0 mt-2">
<button type="button"
class="ti-btn bg-primary text-white btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
<i class="ri-filter-3-fill inline-block"></i>Filters
</button>
<button type="button"
class="ti-btn ti-btn-outline-secondary btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
<i class="ri-upload-cloud-line inline-block"></i>Export
</button>
</div>
</div>
<div class="grid grid-cols-12 gap-6">
@for (card of statCards(); track card.title) {
<div class="xxl:col-span-2 xl:col-span-2 md:col-span-6 col-span-12">
<div [class]="'box overflow-hidden border-t-[3px] ' + card.accentClass">
<div class="box-body">
<div class="flex items-start justify-between gap-4">
<div class="text-center">
<p class="mb-2 text-[0.813rem] text-[#8c9097] dark:text-white/50">{{ card.title }}</p>
<h4 class="mb-2 text-[1.75rem] font-semibold text-defaulttextcolor dark:text-white">
{{ card.value }}
</h4>
<span
[class]="'inline-flex rounded-md px-2 py-1 text-[0.6875rem] font-medium ' + card.helperClass">
{{ card.helper }}
</span>
</div>
<span
[class]="'inline-flex !h-[2.75rem] !w-[2.75rem] items-center justify-center rounded-full text-white ' + card.iconBackgroundClass">
<i [class]="card.iconClass + ' text-[1.1rem]'"></i>
</span>
</div>
</div>
</div>
</div>
}
<div class="xl:col-span-12 col-span-12">
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[actions]="actions()"
[totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Recently Created Organizations"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search..."
[searchDebounceTime]="300"
[emptyMessage]="emptyMessage()"
[emptyDescription]="emptyDescription()"
(addClicked)="onAddOrganization()"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)"
(actionClicked)="onActionClick($event)"
toolTip="Add Organization"
rowColorMode="none" />
</div>
</div>
@@ -0,0 +1,308 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { of } from 'rxjs';
import { DataTable } from '../../../shared/components/data-table/data-table';
import { DataTableStore } from '../../../shared/components/data-table/data-table.store';
import {
DataTableAction,
DataTableActionEvent,
DataTableColumn,
DataTableRecord,
DataTableResult,
} from '../../../shared/components/data-table/data-table.types';
type OrganizationPlan = 'Enterprise' | 'Standard' | 'Trial';
type OrganizationStatus = 'Active' | 'Trial' | 'Suspended';
interface DashboardStatCard {
title: string;
value: string;
helper: string;
accentClass: string;
iconClass: string;
iconBackgroundClass: string;
helperClass: string;
}
interface OrganizationListRow extends DataTableRecord {
id: string;
code: string;
organizationName: string;
countryId: string;
country: string;
plan: string;
status: OrganizationStatus;
expiry: string;
}
@Component({
selector: 'dashboard',
standalone: true,
imports: [DataTable],
providers: [DataTableStore],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
export class Dashboard implements OnInit {
private readonly router = inject(Router);
private readonly toastr = inject(ToastrService);
readonly tableStore = inject(DataTableStore<OrganizationListRow, OrganizationListRow>);
readonly statCards = signal<DashboardStatCard[]>([
{
title: 'Total Organizations',
value: '24',
helper: 'All registered',
accentClass: 'border-primary',
iconClass: 'ti ti-building-community',
iconBackgroundClass: 'bg-primary',
helperClass: 'bg-primary/10 text-primary',
},
{
title: 'Active',
value: '19',
helper: 'Currently operational',
accentClass: 'border-primary',
iconClass: 'ti ti-circle-check',
iconBackgroundClass: 'bg-primary',
helperClass: 'bg-primary/10 text-primary',
},
{
title: 'Trial',
value: '3',
helper: 'Trial subscriptions',
accentClass: 'border-primary',
iconClass: 'ri-wallet-2-line',
iconBackgroundClass: 'bg-primary',
helperClass: 'bg-primary/10 text-primary',
},
{
title: 'Suspended',
value: '1',
helper: 'Temporarily disabled',
accentClass: 'border-primary',
iconClass: 'ti ti-player-pause',
iconBackgroundClass: 'bg-primary',
helperClass: 'bg-primary/10 text-primary',
},
{
title: 'Expired License',
value: '1',
helper: 'Needs renewal action',
accentClass: 'border-primary',
iconClass: 'ri ri-pass-expired-line',
iconBackgroundClass: 'bg-primary',
helperClass: 'bg-primary/10 text-primary',
},
{
title: 'Active Users',
value: '482',
helper: 'Users with access',
accentClass: 'border-primary',
iconClass: 'ri ri-group-line',
iconBackgroundClass: 'bg-primary',
helperClass: 'bg-primary/10 text-primary',
},
]);
readonly recentOrganizations = signal<OrganizationListRow[]>([
{
id: '1',
code: 'ORG-0024',
organizationName: 'Syscom Group',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Enterprise',
status: 'Active',
expiry: '31-12-2026'
},
{
id: '2',
code: 'ORG-0023',
organizationName: 'Acme Trading',
countryId: 'c2',
country: 'India',
plan: 'Standard',
status: 'Active',
expiry: '31-03-2027'
},
{
id: '3',
code: 'ORG-0022',
organizationName: 'Falcon Retail LLC',
countryId: 'c3',
country: 'UAE',
plan: 'Trial',
status: 'Trial',
expiry: '28-07-2026'
},
{
id: '4',
code: 'ORG-0021',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '5',
code: 'ORG-0022',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '6',
code: 'ORG-0023',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '7',
code: 'ORG-0024',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '8',
code: 'ORG-0025',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '9',
code: 'ORG-0026',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
]);
readonly columns = signal<DataTableColumn<OrganizationListRow>[]>([
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
{
key: 'organizationName',
label: 'Name',
header: 'Name',
sortable: true,
align: 'left',
},
{ key: 'country', label: 'Country', header: 'Country', sortable: true },
{
key: 'plan',
label: 'Plan',
header: 'Plan',
sortable: true,
badge: true,
badgeClass: value =>
value === 'Trial'
? 'badge bg-warning/10 text-warning'
: 'badge bg-light text-defaulttextcolor',
},
{
key: 'status',
label: 'Status',
header: 'Status',
sortable: true,
badge: true,
badgeClass: value =>
value === 'Active'
? 'badge bg-success/10 text-success'
: value === 'Trial'
? 'badge bg-warning/10 text-warning'
: 'badge bg-danger/10 text-danger',
},
{ key: 'expiry', label: 'Expiry', header: 'Expiry', sortable: true },
]);
readonly emptyMessage = signal<string>('No Organizations');
readonly emptyDescription = signal<string>('Start by adding your first organization');
readonly actions = signal<DataTableAction<OrganizationListRow>[]>([
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
{
type: 'suspend',
label: 'Suspend',
icon: 'ti ti-player-pause',
className: 'text-warning',
visible: row => row.status !== 'Suspended',
},
]);
ngOnInit(): void {
this.tableStore.initialize({
fetcher: query => {
const search = (query.search || '').trim().toLowerCase();
const sortBy = query.sortBy;
const sortDir = query.sortDir;
let rows = this.recentOrganizations().filter(row => {
const matchesSearch = !search
|| row.code.toLowerCase().includes(search)
|| row.organizationName.toLowerCase().includes(search)
|| row.country.toLowerCase().includes(search)
|| row.plan.toLowerCase().includes(search)
|| row.status.toLowerCase().includes(search)
|| (row.expiry && row.expiry.toLowerCase().includes(search));
return matchesSearch;
});
if (sortBy) {
rows = [...rows].sort((left, right) => {
const leftVal = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
const rightVal = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
const compared = leftVal.localeCompare(rightVal);
return sortDir === 'asc' ? compared : -compared;
});
}
const total = rows.length;
const start = (query.page - 1) * query.pageSize;
const end = start + query.pageSize;
const result: DataTableResult<OrganizationListRow> = {
draw: query.draw,
total,
filtered: total,
rows: rows.slice(start, end)
};
return of(result);
}
});
}
onAddOrganization(): void {
void this.router.navigate(['/organizations/onboarding']);
}
onActionClick(event: DataTableActionEvent<OrganizationListRow>): void {
if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') {
void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } });
return;
}
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
}
}
@@ -1 +1,63 @@
<p>organization-list works!</p>
<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" (ngSubmit)="applyCountryFilter()" 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="organization-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()"
/>
</div>
</form>
</app-filter-card>
</div>
</div>
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[actions]="actions()"
[totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Organizations"
buttonTitle="Add"
[showSearch]="true"
rowColorMode="none"
[showAddButton]="true"
searchPlaceholder="Search..."
[searchDebounceTime]="300"
(addClicked)="onAddOrganization()"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)"
(actionClicked)="onActionClick($event)"
toolTip="Add Organization"
/>
@@ -1,11 +1,278 @@
import { Component } from '@angular/core';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { of } from 'rxjs';
import { DataTable } from '../../../shared/components/data-table/data-table';
import { DataTableStore } from '../../../shared/components/data-table/data-table.store';
import {
DataTableAction,
DataTableActionEvent,
DataTableColumn,
DataTableRecord,
DataTableResult
} from '../../../shared/components/data-table/data-table.types';
import { Autocomplete } from '../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn,
AutocompleteValueFn,
} from '../../../shared/components/form/autocomplete/autocomplete.types';
import { FilterCard } from '../../../shared/components/filter-card/filter-card';
import { Button } from '../../../shared/components/button/button';
import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api';
type OrganizationStatus = 'Active' | 'Trial' | 'Suspended';
interface OrganizationListRow extends DataTableRecord {
id: string;
code: string;
organizationName: string;
countryId: string;
country: string;
plan: string;
status: OrganizationStatus;
expiry: string;
}
@Component({
selector: 'organization-list',
imports: [],
standalone: true,
imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable, Button],
providers: [DataTableStore],
templateUrl: './organization-list.html',
styleUrl: './organization-list.scss',
})
export class OrganizationList {
export class OrganizationList implements OnInit {
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly toastr = inject(ToastrService);
private readonly countryApi = inject(CountryService);
readonly tableStore = inject(DataTableStore<OrganizationListRow, OrganizationListRow>);
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
readonly appliedCountryId = signal<string | null>(null);
readonly countryFilterForm = this.formBuilder.nonNullable.group({
countryId: [''],
});
readonly allOrganizations = signal<OrganizationListRow[]>([
{
id: '1',
code: 'ORG-0024',
organizationName: 'Syscom Group',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Enterprise',
status: 'Active',
expiry: '31-12-2026'
},
{
id: '2',
code: 'ORG-0023',
organizationName: 'Acme Trading',
countryId: 'c2',
country: 'India',
plan: 'Standard',
status: 'Active',
expiry: '31-03-2027'
},
{
id: '3',
code: 'ORG-0022',
organizationName: 'Falcon Retail LLC',
countryId: 'c3',
country: 'UAE',
plan: 'Trial',
status: 'Trial',
expiry: '28-07-2026'
},
{
id: '4',
code: 'ORG-0021',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '5',
code: 'ORG-0022',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '6',
code: 'ORG-0023',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '7',
code: 'ORG-0024',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '8',
code: 'ORG-0025',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
{
id: '9',
code: 'ORG-0026',
organizationName: 'Oasis Foods',
countryId: 'c1',
country: 'Saudi Arabia',
plan: 'Standard',
status: 'Suspended',
expiry: '15-06-2026'
},
]);
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 columns = signal<DataTableColumn<OrganizationListRow>[]>([
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
{
key: 'organizationName',
label: 'Organization Name',
header: 'Organization Name',
sortable: true,
align: 'left',
},
{ key: 'country', label: 'Country', header: 'Country', sortable: true },
{
key: 'plan',
label: 'Plan',
header: 'Plan',
sortable: true,
badge: true,
badgeClass: value =>
value === 'Trial'
? 'badge bg-warning/10 text-warning'
: 'badge bg-light text-defaulttextcolor',
},
{
key: 'status',
label: 'Status',
header: 'Status',
sortable: true,
badge: true,
badgeClass: value => {
if (value === 'Active') return 'badge bg-success/10 text-success';
if (value === 'Trial') return 'badge bg-warning/10 text-warning';
return 'badge bg-danger/10 text-danger';
},
},
{ key: 'expiry', label: 'Expiry', header: 'Expiry', sortable: true },
]);
readonly actions = signal<DataTableAction<OrganizationListRow>[]>([
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
{
type: 'suspend',
label: 'Suspend',
icon: 'ti ti-player-pause',
className: 'text-warning',
visible: row => row.status !== 'Suspended',
},
]);
ngOnInit(): void {
this.tableStore.initialize({
fetcher: query => {
const countryId = this.appliedCountryId();
const selectedCountryName = this.selectedCountryLookup()?.name?.toLowerCase();
const search = (query.search || '').trim().toLowerCase();
const sortBy = query.sortBy;
const sortDir = query.sortDir;
let rows = this.allOrganizations().filter(row => {
const matchesCountry = !countryId
|| row.countryId === countryId
|| (!!selectedCountryName && row.country.toLowerCase() === selectedCountryName);
const matchesSearch = !search
|| row.code.toLowerCase().includes(search)
|| row.organizationName.toLowerCase().includes(search)
|| row.country.toLowerCase().includes(search)
|| row.plan.toLowerCase().includes(search)
|| row.status.toLowerCase().includes(search)
|| row.expiry.toLowerCase().includes(search);
return matchesCountry && matchesSearch;
});
if (sortBy) {
rows = [...rows].sort((left, right) => {
const leftVal = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
const rightVal = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
const compared = leftVal.localeCompare(rightVal);
return sortDir === 'asc' ? compared : -compared;
});
}
const total = rows.length;
const start = (query.page - 1) * query.pageSize;
const end = start + query.pageSize;
const result: DataTableResult<OrganizationListRow> = {
draw: query.draw,
total,
filtered: total,
rows: rows.slice(start, end)
};
return of(result);
}
});
}
onCountryLookupSelected(country: CountryLookupDto | null): void {
this.selectedCountryLookup.set(country);
}
applyCountryFilter(): void {
const rawValue = this.countryFilterForm.controls.countryId.value;
const selectedCountryId = (rawValue || '').trim();
if (!selectedCountryId) {
this.selectedCountryLookup.set(null);
}
this.appliedCountryId.set(selectedCountryId || null);
this.tableStore.refresh();
}
onAddOrganization(): void {
void this.router.navigate(['/organizations/onboarding']);
}
onActionClick(event: DataTableActionEvent<OrganizationListRow>): void {
if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') {
void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } });
return;
}
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
}
}
@@ -1,239 +1,254 @@
<div class="w-full">
<!-- Stepper Nav -->
<nav aria-label="Organization onboarding progress">
<ul
class="relative flex-row gap-x-2 space-y-4
sm:flex sm:space-y-0"
>
@for (step of steps(); track step.key; let index = $index) {
<li
class="group flex flex-1 basis-0 shrink items-center gap-x-2"
[class.active]="isCurrentStep(index)"
[class.success]="isCompletedStep(index)"
[attr.data-hs-stepper-nav-item]="
'{&quot;index&quot;:' + (index + 1) + '}'
"
>
<button
type="button"
class="group inline-flex min-h-7 min-w-7 items-center
text-start text-xs align-middle
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-primary focus-visible:ring-offset-2"
[class.cursor-pointer]="canOpenStep(index)"
[class.cursor-default]="!canOpenStep(index)"
[attr.aria-current]="
isCurrentStep(index) ? 'step' : null
"
[attr.aria-disabled]="
canOpenStep(index) ? null : true
"
(click)="selectStep(index)"
>
<span
class="flex size-7 shrink-0 items-center justify-center
rounded-full bg-gray-100 font-medium text-gray-800
group-focus:bg-gray-200 dark:bg-bodybg dark:text-white
dark:group-focus:bg-gray-600
hs-stepper-active:!bg-primary
hs-stepper-active:!text-white
hs-stepper-success:!bg-primary
hs-stepper-success:!text-white
hs-stepper-completed:!bg-success
hs-stepper-completed:group-focus:!bg-success"
>
<span
class="hs-stepper-success:hidden
hs-stepper-completed:hidden"
<!-- Custom Stepper Navigation Card -->
<div class="stepper-header-card mb-4 sm:mb-6">
<!-- Mobile Current Step Indicator Banner (< 640px) -->
<div class="mb-3 flex items-center justify-between rounded-lg bg-primary/5 px-3 py-2 text-xs font-medium text-primary sm:hidden">
<span class="flex items-center gap-1.5 min-w-0 truncate">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-[0.65rem] font-bold text-white">
{{ currentStepIndex() + 1 }}
</span>
<span class="truncate">Step {{ currentStepIndex() + 1 }} of {{ steps().length }}: <strong class="font-semibold">{{ currentStepLabel() }}</strong></span>
</span>
<span class="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[0.6875rem] font-semibold text-primary ms-2">
{{ getProgressPercent() }}%
</span>
</div>
<!-- Stepper Navigation Track -->
<div class="py-1">
<nav aria-label="Organization onboarding progress" class="relative w-full">
<!-- Background Track Line -->
<div
class="absolute top-4.5 sm:top-8 h-1 bg-gray-200 dark:bg-slate-700/60 z-0 transition-all duration-300"
[style.left]="getTrackLineLeft()"
[style.right]="getTrackLineLeft()"
></div>
<!-- Gradient Progress Line -->
<div
class="stepper-progress-line absolute top-4.5 sm:top-8 h-1 z-0 transition-all duration-500 ease-in-out"
[style.left]="getTrackLineLeft()"
[style.width]="getProgressLineWidth()"
></div>
<!-- Stepper Nodes Grid -->
<ul class="relative z-10 flex w-full justify-between items-start">
@for (step of steps(); track step.key; let index = $index) {
<li class="flex flex-1 flex-col items-center text-center">
<button
type="button"
class="step-node-btn group flex flex-col items-center focus:outline-none"
[class.cursor-pointer]="canOpenStep(index)"
[class.cursor-default]="!canOpenStep(index)"
[disabled]="!canOpenStep(index)"
(click)="selectStep(index)"
>
{{ index + 1 }}
</span>
<!-- Step Circle Node -->
<div
class="step-circle"
[class.active]="isCurrentStep(index)"
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
[class.pending]="isPendingStep(index)"
>
<!-- Icons by Step Key / Index -->
@switch (step.key) {
@case ('basics') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 sm:size-7">
<path d="M19 4h-3.5c-.27-1.16-1.31-2-2.5-2h-2c-1.19 0-2.23.84-2.5 2H5c-1.1 0-2 .9-2 2v13c0 1.1.9 2 2 2h8.08c-.05-.33-.08-.66-.08-1 0-3.31 2.69-6 6-6 .34 0 .67.03 1 .08V6c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM7 9h7v2H7V9zm0 4h5v2H7v-2z"/>
<path d="M18 13c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm-1 7.2-2.2-2.2 1.4-1.4 0.8 0.8 3.2-3.2 1.4 1.4-4.6 4.6z"/>
</svg>
}
@case ('localization') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
<circle cx="10" cy="10" r="7.5"/>
<path d="M2.5 10h15"/>
<path d="M10 2.5a11 11 0 0 1 0 15a11 11 0 0 1 0-15"/>
<path d="M12 21.5v-7.5h3.5v7.5M15.5 21.5v-9.5h3.5v9.5M19 21.5v-6h3v6" fill="currentColor" stroke="none"/>
</svg>
}
@case ('plan-limits') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" stroke="none" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
<path d="M8.5 13.5l2.2-2.2 1.8 1.8 3-3.2m0 0h-2.5m2.5 0v2.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
}
@case ('admin-user') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 sm:size-7">
<path d="M12 6a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm10 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/>
<path d="M12 7.5c-3 0-8 1.5-8 4v2.5h8.5c-.32-.73-.5-1.54-.5-2.4 0-1.48.55-2.83 1.46-3.87C13.01 7.6 12.45 7.5 12 7.5zM4 11.5c-1.8 0-5 .9-5 2.5V16h4v-2c0-.94.32-1.8.85-2.5H4zm13.15-4c.45.64.75 1.4.81 2.22C19.06 10.28 20 11.23 20 12.5v1.5h4v-1.5c0-1.6-3.2-2.5-5-2.5h-1.85z"/>
<path d="M17 12c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm0 7c-1.5 0-2.8-.78-3.5-1.95.04-1.16 2.33-1.8 3.5-1.8s3.46.64 3.5 1.8c-.7 1.17-2 1.95-3.5 1.95z"/>
</svg>
}
@default {
<span>{{ index + 1 }}</span>
}
}
</div>
<svg
class="hidden size-3 shrink-0 hs-stepper-success:block"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<span class="sr-only">
@if (isCompletedStep(index)) {
Completed
} @else if (isCurrentStep(index)) {
Current step
} @else {
Pending
}
</span>
</span>
<span
class="ms-2 whitespace-nowrap text-sm font-medium
text-gray-800 dark:text-white
hs-stepper-active:!text-primary
hs-stepper-success:!text-primary
hs-stepper-completed:!text-success"
>
{{ step.label }}
</span>
</button>
@if (index < steps().length - 1) {
<div
class="hidden h-px w-full flex-1 bg-gray-200
dark:bg-bodybg sm:block
hs-stepper-success:!bg-primary
hs-stepper-completed:!bg-success"
aria-hidden="true"
></div>
<!-- Step Label -->
<span
class="step-label hidden sm:block"
[class.active]="isCurrentStep(index)"
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
[class.pending]="isPendingStep(index)"
>
{{ index + 1 }}. {{ step.label }}
</span>
</button>
</li>
}
</li>
}
</ul>
</nav>
<!-- End Stepper Nav -->
</ul>
</nav>
</div>
</div>
<!-- End Stepper Navigation Card -->
<!-- Stepper Content -->
<div class="mt-5 sm:mt-8">
<div
class="rounded-xl border border-dashed border-gray-200
bg-gray-50 p-6 dark:border-white/10 dark:bg-bodybg"
>
<!-- Stepper Form Content -->
<div>
<div class="rounded-xl border border-dashed border-gray-200 bg-white p-3.5 sm:p-6 dark:border-white/10 dark:bg-bodybg">
<ng-content />
</div>
<!-- Button Group -->
<footer
class="mt-5 flex flex-col-reverse gap-3
sm:flex-row sm:items-center sm:justify-between"
>
<!-- Cancel or Back -->
<div class="flex w-full sm:w-auto">
@if (isFirstStep()) {
<button
type="button"
class="ti-btn ti-btn-light h-10 w-full justify-center
gap-2 disabled:pointer-events-none
disabled:opacity-50 sm:w-44"
[disabled]="navigationDisabled()"
(click)="requestCancel()"
>
<i class="ri-close-line" aria-hidden="true"></i>
<span>Cancel</span>
</button>
} @else {
<button
type="button"
class="ti-btn ti-btn-light h-10 w-full justify-center
gap-2 disabled:pointer-events-none
disabled:opacity-50 sm:w-44"
[disabled]="navigationDisabled()"
(click)="requestBack()"
>
<i
class="ri-arrow-left-s-line rtl:rotate-180"
aria-hidden="true"
></i>
<!-- Button Group Footer -->
<footer class="modern-modal-footer mt-5 px-6">
<!-- Mobile View (< 640px) -->
<div class="flex flex-col gap-2.5 sm:hidden">
<!-- Back or Cancel button -->
<div class="w-full">
@if (isFirstStep()) {
<app-button
action="cancel"
label="Cancel"
[showIcon]="true"
[disabled]="navigationDisabled()"
[fullWidth]="true"
className="w-full"
(buttonClicked)="requestCancel()"
></app-button>
} @else {
<app-button
action="previous"
label="Back"
[showIcon]="true"
[disabled]="navigationDisabled()"
[fullWidth]="true"
className="onboarding-nav-btn w-full"
(buttonClicked)="requestBack()"
></app-button>
}
</div>
<span>Back</span>
</button>
}
<!-- Save Draft & Next/Finish side by side -->
<div class="grid grid-cols-2 gap-2.5 w-full max-[420px]:grid-cols-1">
<div class="min-w-0">
<app-button
action="save"
[variant]="'custom'"
label="Save Draft"
loadingLabel="Saving..."
[showIcon]="true"
[loading]="savingDraft()"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true"
className="onboarding-save-btn ti-btn-outline-primary whitespace-nowrap w-full"
(buttonClicked)="requestSaveDraft()"
></app-button>
</div>
<div class="min-w-0">
@if (!isLastStep()) {
<app-button
action="next"
label="Next"
[showIcon]="true"
[iconPosition]="'right'"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true"
className="onboarding-nav-btn w-full"
(buttonClicked)="requestNext()"
></app-button>
} @else {
<app-button
action="approve"
label="Finish & Provision"
loadingLabel="Preparing..."
[showIcon]="true"
[loading]="finishing()"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true"
className="w-full"
(buttonClicked)="requestFinish()"
></app-button>
}
</div>
</div>
</div>
<!-- Save and Next/Finish -->
<div
class="flex w-full flex-col gap-2
sm:w-auto sm:flex-row"
>
<button
type="button"
class="ti-btn ti-btn-outline-primary h-10 w-full
justify-center gap-2 disabled:pointer-events-none
disabled:opacity-50 sm:w-44"
[disabled]="
navigationDisabled() ||
savingDraft() ||
finishing()
"
(click)="requestSaveDraft()"
>
@if (savingDraft()) {
<span
class="size-4 animate-spin rounded-full border-2
border-current border-t-transparent"
aria-hidden="true"
></span>
<span>Saving...</span>
<!-- Desktop View (>= 640px) -->
<div class="hidden w-full sm:flex sm:items-center sm:justify-between sm:gap-3">
<!-- Left: Cancel or Back -->
<div>
@if (isFirstStep()) {
<app-button
action="cancel"
label="Cancel"
[showIcon]="true"
[disabled]="navigationDisabled()"
(buttonClicked)="requestCancel()"
></app-button>
} @else {
<i class="ri-save-line" aria-hidden="true"></i>
<span>Save Draft</span>
<app-button
action="previous"
label="Back"
[showIcon]="true"
[disabled]="navigationDisabled()"
className="onboarding-nav-btn"
(buttonClicked)="requestBack()"
></app-button>
}
</button>
</div>
@if (!isLastStep()) {
<button
type="button"
class="ti-btn ti-btn-primary-full h-10 w-full
justify-center gap-2 disabled:pointer-events-none
disabled:opacity-50 sm:w-44"
[disabled]="
navigationDisabled() ||
savingDraft() ||
finishing()
"
(click)="requestNext()"
>
<span>Next</span>
<!-- Right: Save Draft & Next/Finish -->
<div class="flex items-center gap-2.5 sm:gap-3">
<app-button
action="save"
[variant]="'custom'"
label="Save Draft"
loadingLabel="Saving..."
[showIcon]="true"
[loading]="savingDraft()"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
className="onboarding-save-btn ti-btn-outline-primary whitespace-nowrap px-4"
(buttonClicked)="requestSaveDraft()"
></app-button>
<i
class="ri-arrow-right-s-line rtl:rotate-180"
aria-hidden="true"
></i>
</button>
} @else {
<button
type="button"
class="ti-btn ti-btn-success-full h-10 w-full
justify-center gap-2 disabled:pointer-events-none
disabled:opacity-50 sm:w-44"
[disabled]="
navigationDisabled() ||
savingDraft() ||
finishing()
"
(click)="requestFinish()"
>
@if (finishing()) {
<span
class="size-4 animate-spin rounded-full border-2
border-current border-t-transparent"
aria-hidden="true"
></span>
<span>Provisioning...</span>
} @else {
<i
class="ri-checkbox-circle-line"
aria-hidden="true"
></i>
<span>Finish & Provision</span>
}
</button>
}
@if (!isLastStep()) {
<app-button
action="next"
label="Next"
[showIcon]="true"
[iconPosition]="'right'"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
className="onboarding-nav-btn"
(buttonClicked)="requestNext()"
></app-button>
} @else {
<app-button
action="approve"
label="Finish & Provision"
loadingLabel="Preparing..."
[showIcon]="true"
[loading]="finishing()"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
className="sm:w-44"
(buttonClicked)="requestFinish()"
></app-button>
}
</div>
</div>
</footer>
<!-- End Button Group -->
<!-- End Button Group Footer -->
</div>
<!-- End Stepper Content -->
<!-- End Stepper Form Content -->
</div>

Some files were not shown because too many files have changed in this diff Show More