orgnanization onboarding stepper form changes, code refactor for custom data-table grid
This commit is contained in:
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateChildFn, Router } from '@angular/router';
|
import { CanActivateChildFn, Router } from '@angular/router';
|
||||||
import { catchError, map, of } from 'rxjs';
|
import { catchError, map, of } from 'rxjs';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../../features/authentication/data-access/auth.service';
|
||||||
import { TokenStorageService } from '../auth/token-storage.service';
|
import { TokenStorageService } from '../services/auth/token-storage.service';
|
||||||
|
|
||||||
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
|
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
|
||||||
const authService = inject(AuthService);
|
const authService = inject(AuthService);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateChildFn, Router } from '@angular/router';
|
import { CanActivateChildFn, Router } from '@angular/router';
|
||||||
import { catchError, map, of } from 'rxjs';
|
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';
|
import { TokenStorageService } from '../../services/auth/token-storage.service';
|
||||||
|
|
||||||
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
|
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
import { catchError, map, of } from 'rxjs';
|
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';
|
import { TokenStorageService } from '../../services/auth/token-storage.service';
|
||||||
|
|
||||||
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
|
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
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 = () => {
|
export const superAdminGuard: CanActivateFn = () => {
|
||||||
const auth = inject(AuthService);
|
const auth = inject(AuthService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
return (auth.currentUser?.roles ?? []).includes('super_admin')
|
return (auth.currentUser()?.roles ?? []).includes('super_admin')
|
||||||
? true
|
? true
|
||||||
: router.createUrlTree(['/dashboards/crm']);
|
: router.createUrlTree(['/dashboards/crm']);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
import { catchError, map, of } from 'rxjs';
|
import { catchError, map, of } from 'rxjs';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../../features/authentication/data-access/auth.service';
|
||||||
import { TokenStorageService } from '../auth/token-storage.service';
|
import { TokenStorageService } from '../services/auth/token-storage.service';
|
||||||
|
|
||||||
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
|
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { catchError, switchMap, throwError } from 'rxjs';
|
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 { API_CONFIG } from '../config/api.config';
|
||||||
import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints';
|
import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints';
|
||||||
|
|
||||||
|
|||||||
@@ -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[]>;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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' ;
|
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
import { UserProfile } from '../auth/auth.model';
|
import { UserProfile } from '../../../features/authentication/models/auth.model';
|
||||||
import { Menu } from '../../services/common/nav.service';
|
import { Menu } from '../../services/common/nav.service';
|
||||||
|
|
||||||
export interface CurrentUserContext extends UserProfile {
|
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';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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 { merge, fromEvent, Subscription } from 'rxjs';
|
||||||
import { throttleTime } from 'rxjs/operators';
|
import { throttleTime } from 'rxjs/operators';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../../../features/authentication/data-access/auth.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionTimeoutService implements OnDestroy {
|
export class SessionTimeoutService implements OnDestroy {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core';
|
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';
|
type StorageType = 'local' | 'session';
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +1,56 @@
|
|||||||
import { DOCUMENT, ElementRef, inject, Injectable, Renderer2 } from '@angular/core';
|
import { DOCUMENT, inject, Injectable } from '@angular/core';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { signal } from '@angular/core';
|
||||||
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
interface StateType {
|
export interface StateType {
|
||||||
direction: string;
|
direction: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
navigationStyles: string, // vertical, horizontal
|
navigationStyles: string;
|
||||||
menuStyles: string, // menu-click, menu-hover, icon-click, icon-hover
|
menuStyles: string;
|
||||||
layoutStyles: string, // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu
|
layoutStyles: string;
|
||||||
pageStyles: string, // regular, classic, modern
|
pageStyles: string;
|
||||||
widthStyles: string, // fullwidth, boxed
|
widthStyles: string;
|
||||||
menuPosition: string, // fixed, scrollable
|
menuPosition: string;
|
||||||
headerPosition: string, // fixed, scrollable
|
headerPosition: string;
|
||||||
menuColor: string, // light, dark, color, gradient, transparent
|
menuColor: string;
|
||||||
headerColor: string, // light, dark, color, gradient, transparent
|
headerColor: string;
|
||||||
themePrimary: string, // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90'
|
themePrimary: string;
|
||||||
themeBackground: string,
|
themeBackground: any;
|
||||||
backgroundImage: string,
|
backgroundImage: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class AppStateService {
|
export class AppStateService {
|
||||||
private readonly localStorageKey = 'Ynex-ng'; // Customize this key
|
private readonly localStorageKey = 'Ynex-ng';
|
||||||
private initialState: StateType = {
|
private readonly initialState: StateType = {
|
||||||
theme: 'light', // light, dark
|
theme: 'light',
|
||||||
direction: 'ltr', // ltr, rtl
|
direction: 'ltr',
|
||||||
navigationStyles: 'vertical', // vertical, horizontal
|
navigationStyles: 'vertical',
|
||||||
menuStyles: '', // menu-click, menu-hover, icon-click, icon-hover
|
menuStyles: '',
|
||||||
layoutStyles: 'default', // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu
|
layoutStyles: 'default',
|
||||||
pageStyles: 'regular', // regular, classic, modern
|
pageStyles: 'regular',
|
||||||
widthStyles: 'fullwidth', // fullwidth, boxed
|
widthStyles: 'fullwidth',
|
||||||
menuPosition: 'fixed', // fixed, scrollable
|
menuPosition: 'fixed',
|
||||||
headerPosition: 'fixed', // fixed, scrollable
|
headerPosition: 'fixed',
|
||||||
menuColor: 'dark', // light, dark, color, gradient, transparent
|
menuColor: 'dark',
|
||||||
headerColor: 'light', // light, dark, color, gradient, transparent
|
headerColor: 'light',
|
||||||
themePrimary: '', // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90'
|
themePrimary: '',
|
||||||
themeBackground: '',
|
themeBackground: '',
|
||||||
backgroundImage: '', // bgimg1, bgimg2, bgimg3, bgimg4, bgimg5
|
backgroundImage: '',
|
||||||
} // Store initial state
|
};
|
||||||
private stateSubject = new BehaviorSubject<StateType>(this.initialState); // Use any for initial null value
|
|
||||||
state$ = this.stateSubject.asObservable();
|
private readonly stateSignal = signal<StateType>(this.getInitialStateFromLocalStorage());
|
||||||
|
readonly state = this.stateSignal.asReadonly();
|
||||||
|
readonly state$ = toObservable(this.stateSignal);
|
||||||
|
|
||||||
private document = inject(DOCUMENT);
|
private document = inject(DOCUMENT);
|
||||||
navigationStyles: any;
|
navigationStyles: any;
|
||||||
private html = this.document.documentElement;
|
private html = this.document.documentElement;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.updateStateAndEmit(this.stateSignal());
|
||||||
const initialState: StateType = this.getInitialStateFromLocalStorage();
|
|
||||||
// this.initializeState();
|
|
||||||
this.stateSubject.next(initialState);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getInitialStateFromLocalStorage(): StateType {
|
private getInitialStateFromLocalStorage(): StateType {
|
||||||
@@ -64,40 +65,33 @@ export class AppStateService {
|
|||||||
return this.initialState;
|
return this.initialState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getupdateState(): StateType {
|
||||||
|
return this.stateSignal();
|
||||||
|
|
||||||
getupdateState() {
|
|
||||||
const currentState = this.stateSubject.getValue();
|
|
||||||
return currentState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateState(newState?: Partial<any>) { // Use any for partial updates
|
updateState(newState?: Partial<StateType>) {
|
||||||
const currentState = this.stateSubject.getValue(); // Get current state
|
const currentState = this.stateSignal();
|
||||||
|
|
||||||
if (!currentState) {
|
if (!currentState) {
|
||||||
|
if (newState) {
|
||||||
// Handle initial update case (no state emitted yet)
|
this.updateStateAndEmit({ ...this.initialState, ...newState });
|
||||||
this.updateStateAndEmit(newState);
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newState) {
|
if (newState) {
|
||||||
|
const updatedState = { ...currentState, ...newState };
|
||||||
const updatedState = { ...currentState, ...newState }; // Merge updates
|
this.updateStateAndEmit(updatedState);
|
||||||
this.updateStateAndEmit(updatedState); // Update and emit combined state
|
|
||||||
} else {
|
} else {
|
||||||
this.updateStateAndEmit(currentState);
|
this.updateStateAndEmit(currentState);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private state: { [key: string]: any } = {};
|
private stateStore: { [key: string]: any } = {};
|
||||||
getState(menuStyles: 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-bodybg', background.main);
|
||||||
this.html?.style.setProperty('--color-bodybg2', background.secondary);
|
this.html?.style.setProperty('--color-bodybg2', background.secondary);
|
||||||
this.html?.style.setProperty('--color-light', background.accent);
|
this.html?.style.setProperty('--color-light', background.accent);
|
||||||
@@ -107,48 +101,30 @@ export class AppStateService {
|
|||||||
this.applythemeSpecificChanges(background.theme);
|
this.applythemeSpecificChanges(background.theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private applyDirectionSpecificChanges(direction: string) {
|
private applyDirectionSpecificChanges(direction: string) {
|
||||||
|
|
||||||
|
|
||||||
this.html?.setAttribute('dir', direction);
|
this.html?.setAttribute('dir', direction);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applythemeSpecificChanges(theme: string) {
|
private applythemeSpecificChanges(theme: string) {
|
||||||
|
this.html?.setAttribute('class', theme);
|
||||||
|
this.html?.setAttribute('data-header-styles', theme);
|
||||||
this.html?.setAttribute('class', theme); //setting theme style
|
|
||||||
this.html?.setAttribute('data-header-styles', theme); //setting header style
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private applyNavigationStylesSpecificChanges(navigationStyles: string) {
|
private applyNavigationStylesSpecificChanges(navigationStyles: string) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.html?.setAttribute('data-nav-layout', navigationStyles);
|
this.html?.setAttribute('data-nav-layout', navigationStyles);
|
||||||
if (navigationStyles == 'horizontal') {
|
if (navigationStyles === 'horizontal') {
|
||||||
this.html?.setAttribute('data-nav-style', 'menu-click');
|
this.html?.setAttribute('data-nav-style', 'menu-click');
|
||||||
this.html?.removeAttribute('data-vertical-style');
|
this.html?.removeAttribute('data-vertical-style');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private applyMenuStylesSpecificChanges(menuStyles: string) {
|
private applyMenuStylesSpecificChanges(menuStyles: string) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.html?.setAttribute('data-nav-style', menuStyles);
|
this.html?.setAttribute('data-nav-style', menuStyles);
|
||||||
this.html?.setAttribute('data-toggled', menuStyles + '-closed');
|
this.html?.setAttribute('data-toggled', menuStyles + '-closed');
|
||||||
this.html?.removeAttribute('data-vertical-style');
|
this.html?.removeAttribute('data-vertical-style');
|
||||||
}
|
}
|
||||||
private applyLayoutStylesSpecificChanges(layoutStyles: string) {
|
|
||||||
|
|
||||||
|
private applyLayoutStylesSpecificChanges(layoutStyles: string) {
|
||||||
this.html?.setAttribute('data-vertical-style', layoutStyles);
|
this.html?.setAttribute('data-vertical-style', layoutStyles);
|
||||||
this.html?.removeAttribute('data-nav-style');
|
this.html?.removeAttribute('data-nav-style');
|
||||||
switch (layoutStyles) {
|
switch (layoutStyles) {
|
||||||
@@ -175,64 +151,57 @@ export class AppStateService {
|
|||||||
if (layoutStyles === 'icon-text') {
|
if (layoutStyles === 'icon-text') {
|
||||||
this.html?.setAttribute('icon-text', 'open');
|
this.html?.setAttribute('icon-text', 'open');
|
||||||
} else {
|
} else {
|
||||||
// If not 'icon-text', remove the icon-text attribute
|
|
||||||
this.html?.removeAttribute('icon-text');
|
this.html?.removeAttribute('icon-text');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private applypageStylesSpecificChanges(pageStyles: string) {
|
|
||||||
|
|
||||||
|
private applypageStylesSpecificChanges(pageStyles: string) {
|
||||||
this.html?.setAttribute('data-page-style', pageStyles);
|
this.html?.setAttribute('data-page-style', pageStyles);
|
||||||
|
|
||||||
const slideRight = document.querySelector('.slide-right') as HTMLElement | null;
|
const slideRight = document.querySelector('.slide-right') as HTMLElement | null;
|
||||||
if (slideRight) {
|
if (slideRight) {
|
||||||
// If the element exists, toggle the 'd-none' class
|
|
||||||
if (slideRight.classList.contains('d-none')) {
|
if (slideRight.classList.contains('d-none')) {
|
||||||
slideRight.classList.remove('d-none');
|
slideRight.classList.remove('d-none');
|
||||||
} else {
|
} else {
|
||||||
slideRight.classList.add('d-none');
|
slideRight.classList.add('d-none');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If the element does not exist (is null), create a safe fallback by adding 'd-none'
|
|
||||||
const dummySlideRight = document.createElement('div');
|
const dummySlideRight = document.createElement('div');
|
||||||
dummySlideRight.classList.add('slide-right', 'd-none'); // Add classes to the new element
|
dummySlideRight.classList.add('slide-right', 'd-none');
|
||||||
document.body.appendChild(dummySlideRight); // Append it to the DOM as a fallback
|
document.body.appendChild(dummySlideRight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private applywidthStylesSpecificChanges(widthStyles: string) {
|
|
||||||
|
|
||||||
|
private applywidthStylesSpecificChanges(widthStyles: string) {
|
||||||
this.html?.setAttribute('data-width', widthStyles);
|
this.html?.setAttribute('data-width', widthStyles);
|
||||||
}
|
}
|
||||||
private applymenuPositionSpecificChanges(menuPosition: string) {
|
|
||||||
|
|
||||||
|
private applymenuPositionSpecificChanges(menuPosition: string) {
|
||||||
this.html?.setAttribute('data-menu-position', menuPosition);
|
this.html?.setAttribute('data-menu-position', menuPosition);
|
||||||
}
|
}
|
||||||
private applyheaderPositionSpecificChanges(headerPosition: string) {
|
|
||||||
|
|
||||||
|
private applyheaderPositionSpecificChanges(headerPosition: string) {
|
||||||
this.html?.setAttribute('data-header-position', headerPosition);
|
this.html?.setAttribute('data-header-position', headerPosition);
|
||||||
}
|
}
|
||||||
private applyheaderColorSpecificChanges(headerColor: string) {
|
|
||||||
|
|
||||||
|
private applyheaderColorSpecificChanges(headerColor: string) {
|
||||||
this.html?.setAttribute('data-header-styles', headerColor);
|
this.html?.setAttribute('data-header-styles', headerColor);
|
||||||
}
|
}
|
||||||
private applymenuColorSpecificChanges(menuColor: string) {
|
|
||||||
|
|
||||||
|
private applymenuColorSpecificChanges(menuColor: string) {
|
||||||
this.html?.setAttribute('data-menu-styles', menuColor);
|
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-primaryrgb', primary);
|
||||||
this.html?.style.setProperty('--color-primary', primary);
|
this.html?.style.setProperty('--color-primary', primary);
|
||||||
}
|
}
|
||||||
private applybackgroundImageSpecificChanges(backgroundImage: string) {
|
|
||||||
|
|
||||||
|
private applybackgroundImageSpecificChanges(backgroundImage: string) {
|
||||||
this.html?.setAttribute('bg-img', backgroundImage);
|
this.html?.setAttribute('bg-img', backgroundImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public applyReset() {
|
public applyReset() {
|
||||||
|
|
||||||
if (this.html) {
|
if (this.html) {
|
||||||
this.html?.style.removeProperty('--color-bodybg');
|
this.html?.style.removeProperty('--color-bodybg');
|
||||||
this.html?.style.removeProperty('--color-gray3');
|
this.html?.style.removeProperty('--color-gray3');
|
||||||
@@ -242,76 +211,70 @@ export class AppStateService {
|
|||||||
this.html?.style.removeProperty('--color-inputborder');
|
this.html?.style.removeProperty('--color-inputborder');
|
||||||
this.html?.style.removeProperty('--color-primary');
|
this.html?.style.removeProperty('--color-primary');
|
||||||
this.html?.style.removeProperty('--color-primaryrgb');
|
this.html?.style.removeProperty('--color-primaryrgb');
|
||||||
|
|
||||||
}
|
}
|
||||||
this.html?.removeAttribute('bg-img');
|
this.html?.removeAttribute('bg-img');
|
||||||
this.html?.setAttribute('data-vertical-style', 'overlay');
|
this.html?.setAttribute('data-vertical-style', 'overlay');
|
||||||
this.stateSubject.next(this.initialState);
|
|
||||||
this.updateStateAndEmit(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) {
|
if (window.innerWidth <= 992) {
|
||||||
this.html?.setAttribute('data-toggled', 'close');
|
this.html?.setAttribute('data-toggled', 'close');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStateAndEmit(state: any) {
|
private updateStateAndEmit(state: StateType) {
|
||||||
// Conditional logic based on direction changes
|
if (state.theme) {
|
||||||
|
this.applythemeSpecificChanges(state.theme);
|
||||||
const currentState = this.stateSubject.getValue(); // Get current state
|
|
||||||
// Conditional logic based on theme changes
|
|
||||||
if (state['theme']) {
|
|
||||||
|
|
||||||
this.applythemeSpecificChanges(state['theme']);
|
|
||||||
}
|
}
|
||||||
if (state['direction']) {
|
if (state.direction) {
|
||||||
|
this.applyDirectionSpecificChanges(state.direction);
|
||||||
this.applyDirectionSpecificChanges(state['direction']);
|
|
||||||
}
|
}
|
||||||
// Conditional logic based on theme changes
|
if (state.navigationStyles) {
|
||||||
if (state['navigationStyles']) {
|
this.applyNavigationStylesSpecificChanges(state.navigationStyles);
|
||||||
this.applyNavigationStylesSpecificChanges(state['navigationStyles']);
|
|
||||||
}
|
}
|
||||||
// Conditional logic based on theme changes
|
if (state.menuStyles && !state.layoutStyles) {
|
||||||
if (state['menuStyles'] && !state['layoutStyles']) {
|
this.applyMenuStylesSpecificChanges(state.menuStyles);
|
||||||
this.applyMenuStylesSpecificChanges(state['menuStyles']);
|
|
||||||
}
|
}
|
||||||
if (state['layoutStyles'] && !state['menuStyles']) {
|
if (state.layoutStyles && !state.menuStyles) {
|
||||||
this.applyLayoutStylesSpecificChanges(state['layoutStyles']);
|
this.applyLayoutStylesSpecificChanges(state.layoutStyles);
|
||||||
}
|
}
|
||||||
if (state['pageStyles']) {
|
if (state.pageStyles) {
|
||||||
this.applypageStylesSpecificChanges(state['pageStyles']);
|
this.applypageStylesSpecificChanges(state.pageStyles);
|
||||||
}
|
}
|
||||||
if (state['widthStyles']) {
|
if (state.widthStyles) {
|
||||||
this.applywidthStylesSpecificChanges(state['widthStyles']);
|
this.applywidthStylesSpecificChanges(state.widthStyles);
|
||||||
}
|
}
|
||||||
if (state['menuPosition']) {
|
if (state.menuPosition) {
|
||||||
this.applymenuPositionSpecificChanges(state['menuPosition']);
|
this.applymenuPositionSpecificChanges(state.menuPosition);
|
||||||
}
|
}
|
||||||
if (state['headerPosition']) {
|
if (state.headerPosition) {
|
||||||
this.applyheaderPositionSpecificChanges(state['headerPosition']);
|
this.applyheaderPositionSpecificChanges(state.headerPosition);
|
||||||
}
|
}
|
||||||
if (state['themePrimary']) {
|
if (state.themePrimary) {
|
||||||
this.applyPrimarySpecificChanges(state['themePrimary']);
|
this.applyPrimarySpecificChanges(state.themePrimary);
|
||||||
}
|
}
|
||||||
if (state['themeBackground']) {
|
if (state.themeBackground) {
|
||||||
this.applyThemeBackgroundSpecificChanges(state['themeBackground']);
|
this.applyThemeBackgroundSpecificChanges(state.themeBackground);
|
||||||
}
|
}
|
||||||
if (state['headerColor']) {
|
if (state.headerColor) {
|
||||||
this.applyheaderColorSpecificChanges(state['headerColor']);
|
this.applyheaderColorSpecificChanges(state.headerColor);
|
||||||
}
|
}
|
||||||
if (state['menuColor']) {
|
if (state.menuColor) {
|
||||||
this.applymenuColorSpecificChanges(state['menuColor']);
|
this.applymenuColorSpecificChanges(state.menuColor);
|
||||||
}
|
}
|
||||||
if (state['backgroundImage']) {
|
if (state.backgroundImage) {
|
||||||
this.applybackgroundImageSpecificChanges(state['backgroundImage']);
|
this.applybackgroundImageSpecificChanges(state.backgroundImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stateSubject.next(state);
|
this.stateSignal.set(state);
|
||||||
this.updateLocalStorage(state);
|
this.updateLocalStorage(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateLocalStorage(state: any) {
|
private updateLocalStorage(state: StateType) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.localStorageKey, JSON.stringify(state));
|
localStorage.setItem(this.localStorageKey, JSON.stringify(state));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ export const SAAS_MENU_DATA: MenuContext = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Organizations',
|
title: 'Organizations',
|
||||||
icon: '<i class="bx bx-buildings side-menu__icon"></i>',
|
icon: '<i class="bx bx-store-alt side-menu__icon"></i>',
|
||||||
type: 'sub',
|
type: 'sub',
|
||||||
active: false,
|
active: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
dirchange: false,
|
dirchange: false,
|
||||||
children: [
|
children: [
|
||||||
|
{ path: '/organizations', title: 'Dashboard', type: 'link', dirchange: false },
|
||||||
{ path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false },
|
{ path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false },
|
||||||
{ path: '/organizations/onboarding', title: 'Organization Onboarding', 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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable, OnDestroy } from '@angular/core';
|
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||||
import { Subject, BehaviorSubject, fromEvent } from 'rxjs';
|
import { toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { Subject, fromEvent } from 'rxjs';
|
||||||
import { takeUntil, debounceTime } from 'rxjs/operators';
|
import { takeUntil, debounceTime } from 'rxjs/operators';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
// Menu
|
|
||||||
export interface Menu {
|
export interface Menu {
|
||||||
headTitle?: string;
|
headTitle?: string;
|
||||||
headTitle2?: string;
|
headTitle2?: string;
|
||||||
@@ -20,10 +21,9 @@ export interface Menu {
|
|||||||
children2?: Menu[];
|
children2?: Menu[];
|
||||||
Menusub?: boolean;
|
Menusub?: boolean;
|
||||||
target?: boolean;
|
target?: boolean;
|
||||||
menutype?: string,
|
menutype?: string;
|
||||||
dirchange?: boolean,
|
dirchange?: boolean;
|
||||||
nochild?: any
|
nochild?: any;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -31,9 +31,14 @@ export interface Menu {
|
|||||||
})
|
})
|
||||||
export class NavService implements OnDestroy {
|
export class NavService implements OnDestroy {
|
||||||
private unsubscriber: Subject<any> = new Subject();
|
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
|
// Search Box
|
||||||
public search = false;
|
public search = false;
|
||||||
@@ -72,8 +77,7 @@ export class NavService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (window.innerWidth < 991) {
|
if (window.innerWidth < 991) {
|
||||||
// Detect Route change sidebar close
|
this.router.events.subscribe(() => {
|
||||||
this.router.events.subscribe((event) => {
|
|
||||||
this.collapseSidebar = true;
|
this.collapseSidebar = true;
|
||||||
this.megaMenu = false;
|
this.megaMenu = false;
|
||||||
this.levelMenu = false;
|
this.levelMenu = false;
|
||||||
@@ -82,21 +86,19 @@ export class NavService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.unsubscriber.next;
|
this.unsubscriber.next(true);
|
||||||
this.unsubscriber.complete();
|
this.unsubscriber.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private setScreenWidth(width: number): void {
|
private setScreenWidth(width: number): void {
|
||||||
this.screenWidth.next(width);
|
this.screenWidthSignal.set(width);
|
||||||
}
|
}
|
||||||
|
|
||||||
items = new BehaviorSubject<Menu[]>([]);
|
|
||||||
|
|
||||||
setMenuItems(menuItems: Menu[]): void {
|
setMenuItems(menuItems: Menu[]): void {
|
||||||
this.items.next(menuItems);
|
this.itemsSignal.set(menuItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearMenuItems(): void {
|
clearMenuItems(): void {
|
||||||
this.items.next([]);
|
this.itemsSignal.set([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-8
@@ -1,9 +1,10 @@
|
|||||||
import { computed, Injectable, inject, signal } from '@angular/core';
|
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 { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { LoginRequest, LoginResponse, UserProfile } from '../../models/auth/auth.model';
|
import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model';
|
||||||
import { TokenStorageService } from './token-storage.service';
|
import { TokenStorageService } from '../../../core/services/auth/token-storage.service';
|
||||||
import { AppContextService } from '../../services/context/app-context.service';
|
import { AppContextService } from '../../../core/services/context/app-context.service';
|
||||||
import { AUTH_ENDPOINTS } from '../../../core/end-points/auth/auth.endpoints';
|
import { AUTH_ENDPOINTS } from '../../../core/end-points/auth/auth.endpoints';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -12,9 +13,10 @@ export class AuthService {
|
|||||||
private readonly tokenStorage = inject(TokenStorageService);
|
private readonly tokenStorage = inject(TokenStorageService);
|
||||||
private readonly appContextService = inject(AppContextService);
|
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 currentUserSignal = signal<UserProfile | null>(null);
|
||||||
|
readonly currentUser = this.currentUserSignal.asReadonly();
|
||||||
|
readonly user$ = toObservable(this.currentUserSignal);
|
||||||
|
|
||||||
private readonly accessTokenSignal = signal<string | null>(null);
|
private readonly accessTokenSignal = signal<string | null>(null);
|
||||||
readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal());
|
readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal());
|
||||||
|
|
||||||
@@ -91,7 +93,7 @@ export class AuthService {
|
|||||||
return this.isAuthenticatedSignal();
|
return this.isAuthenticatedSignal();
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentUser(): UserProfile | null {
|
get currentUserValue(): UserProfile | null {
|
||||||
return this.currentUserSignal();
|
return this.currentUserSignal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +110,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setAuthState(user: UserProfile | null, token: string | null): void {
|
private setAuthState(user: UserProfile | null, token: string | null): void {
|
||||||
this.userSubject.next(user);
|
|
||||||
this.currentUserSignal.set(user);
|
this.currentUserSignal.set(user);
|
||||||
this.accessTokenSignal.set(token);
|
this.accessTokenSignal.set(token);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectorRef, Component, inject } from '@angular/core';
|
import { ChangeDetectorRef, Component, inject } from '@angular/core';
|
||||||
import { FormBuilder, Validators } from '@angular/forms';
|
import { FormBuilder, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
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 { ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { catchError, finalize, of, switchMap, tap } from 'rxjs';
|
import { catchError, finalize, of, switchMap, tap } from 'rxjs';
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
<modal
|
||||||
|
[open]="open()"
|
||||||
|
[title]="modalTitle()"
|
||||||
|
size="md"
|
||||||
|
[submitAction]="mode() === 'create' ? 'save' : 'update'"
|
||||||
|
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
|
||||||
|
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
|
||||||
|
[loading]="saving() || modalLoading()"
|
||||||
|
[showSubmitButton]="!isViewMode()"
|
||||||
|
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||||
|
(closed)="closeModal()"
|
||||||
|
(submitted)="saveCity()"
|
||||||
|
>
|
||||||
|
@if (modalLoading()) {
|
||||||
|
<div class="flex min-h-32 items-center justify-center">
|
||||||
|
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
|
||||||
|
<span class="ms-2">Loading city...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="cityForm" (ngSubmit)="saveCity()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="countryId"
|
||||||
|
inputId="city-country-id"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Select country"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[searchFn]="countrySearchFn"
|
||||||
|
[valueWith]="countryValueFn"
|
||||||
|
[displayWith]="countryDisplayFn"
|
||||||
|
[selectedItem]="selectedFormCountry()"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
(itemSelected)="onFormCountryChanged($event)"
|
||||||
|
[validationMessages]="{ required: 'Country is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="stateId"
|
||||||
|
inputId="city-state-id"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="State"
|
||||||
|
placeholder="Select state"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[searchFn]="stateSearchFn"
|
||||||
|
[valueWith]="stateValueFn"
|
||||||
|
[displayWith]="stateDisplayFn"
|
||||||
|
[selectedItem]="selectedFormState()"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
(itemSelected)="selectedFormState.set($event)"
|
||||||
|
[validationMessages]="{ required: 'State is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="name"
|
||||||
|
inputId="city-name"
|
||||||
|
variant="floating"
|
||||||
|
label="City Name"
|
||||||
|
placeholder="Name"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="150"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'City name is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="code"
|
||||||
|
inputId="city-code"
|
||||||
|
variant="floating"
|
||||||
|
label="City Code"
|
||||||
|
placeholder="Code"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="16"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'City code is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</modal>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { CityDto, CityModalMode, CreateCityRequest, UpdateCityRequest } from '../../models/city.model';
|
||||||
|
import { CountryLookupDto } from '../../../countries/models/country.model';
|
||||||
|
import { StateLookupDto } from '../../../states/models/state.model';
|
||||||
|
import { TimezoneLookupDto } from '../../../timezones/models/timezone.model';
|
||||||
|
import { CityService } from '../../data-access/city.service';
|
||||||
|
import { CountryService } from '../../../countries/data-access/country.service';
|
||||||
|
import { StateService } from '../../../states/data-access/state.service';
|
||||||
|
import { TimezoneService } from '../../../timezones/data-access/timezone.service';
|
||||||
|
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||||
|
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
|
import {
|
||||||
|
AutocompleteDisplayFn,
|
||||||
|
AutocompleteSearchFn,
|
||||||
|
AutocompleteValueFn
|
||||||
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
|
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-city-form-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete],
|
||||||
|
templateUrl: './city-form-modal.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class CityFormModalComponent {
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
|
private readonly cityApi = inject(CityService);
|
||||||
|
private readonly countryApi = inject(CountryService);
|
||||||
|
private readonly stateApi = inject(StateService);
|
||||||
|
private readonly timezoneApi = inject(TimezoneService);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
|
readonly open = input<boolean>(false);
|
||||||
|
readonly mode = input<CityModalMode>('create');
|
||||||
|
readonly cityId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly saved = output<void>();
|
||||||
|
readonly closed = output<void>();
|
||||||
|
|
||||||
|
readonly modalLoading = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly submitAttempted = signal(false);
|
||||||
|
readonly selectedCity = signal<CityDto | null>(null);
|
||||||
|
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||||
|
readonly selectedFormState = signal<StateLookupDto | null>(null);
|
||||||
|
|
||||||
|
readonly cityForm = this.formBuilder.nonNullable.group({
|
||||||
|
countryId: ['', Validators.required],
|
||||||
|
stateId: ['', Validators.required],
|
||||||
|
name: ['', [Validators.required, Validators.maxLength(150)]],
|
||||||
|
code: ['', [Validators.required, Validators.maxLength(16), Validators.pattern(/^[A-Za-z0-9_-]+$/)]],
|
||||||
|
timezoneId: this.formBuilder.control<string | null>(null)
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||||
|
readonly modalTitle = computed(() => {
|
||||||
|
switch (this.mode()) {
|
||||||
|
case 'create': return 'Add City';
|
||||||
|
case 'edit': return 'Edit City';
|
||||||
|
case 'view': return 'View City';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly countrySearchFn: AutocompleteSearchFn<CountryLookupDto> = (term, page) =>
|
||||||
|
this.countryApi.autocomplete(term, page);
|
||||||
|
readonly countryValueFn: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||||
|
readonly countryDisplayFn: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
|
|
||||||
|
readonly stateSearchFn: AutocompleteSearchFn<StateLookupDto> = (term, page) => {
|
||||||
|
const countryId = this.cityForm.controls.countryId.value || this.selectedFormCountry()?.id || '';
|
||||||
|
if (!countryId) return of([]);
|
||||||
|
return this.stateApi.autocomplete(countryId, term || '', page);
|
||||||
|
};
|
||||||
|
readonly stateValueFn: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
|
||||||
|
readonly stateDisplayFn: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
|
||||||
|
|
||||||
|
readonly timezoneSearchFn: AutocompleteSearchFn<TimezoneLookupDto> = (term, page) =>
|
||||||
|
this.timezoneApi.autocomplete(term, page);
|
||||||
|
readonly timezoneValueFn: AutocompleteValueFn<TimezoneLookupDto, string> = tz => tz.id;
|
||||||
|
readonly timezoneDisplayFn: AutocompleteDisplayFn<TimezoneLookupDto> = tz => tz.displayName;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.open()) {
|
||||||
|
this.prepareModal(this.cityId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFormCountryChanged(country: CountryLookupDto | null): void {
|
||||||
|
this.selectedFormCountry.set(country);
|
||||||
|
this.cityForm.controls.countryId.setValue(country ? country.id : '');
|
||||||
|
this.cityForm.controls.stateId.setValue('');
|
||||||
|
this.selectedFormState.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareModal(id: string | null): void {
|
||||||
|
this.submitAttempted.set(false);
|
||||||
|
this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null });
|
||||||
|
this.selectedFormCountry.set(null);
|
||||||
|
this.selectedFormState.set(null);
|
||||||
|
|
||||||
|
if (!id || this.mode() === 'create') {
|
||||||
|
this.selectedCity.set(null);
|
||||||
|
this.modalLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modalLoading.set(true);
|
||||||
|
this.cityApi.getCityById(id).pipe(
|
||||||
|
switchMap(city => {
|
||||||
|
this.selectedCity.set(city);
|
||||||
|
return this.stateApi.getStateById(city.stateId).pipe(
|
||||||
|
switchMap(state => this.countryApi.getCountryById(state.countryId).pipe(
|
||||||
|
map(country => ({ city, state, country }))
|
||||||
|
)),
|
||||||
|
catchError(() => of({ city, state: null, country: null }))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
finalize(() => this.modalLoading.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: ({ city, state, country }) => {
|
||||||
|
if (country) this.selectedFormCountry.set({ id: country.id, name: country.name, iso2: country.iso2 });
|
||||||
|
if (state) this.selectedFormState.set({ id: state.id, name: state.name, code: state.code ?? '' });
|
||||||
|
|
||||||
|
this.cityForm.patchValue({
|
||||||
|
countryId: country?.id ?? '',
|
||||||
|
stateId: city.stateId,
|
||||||
|
name: city.name,
|
||||||
|
code: city.code ?? '',
|
||||||
|
timezoneId: city.timezoneId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.toastr.error('Unable to load city details.');
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCity(): void {
|
||||||
|
if (this.isViewMode()) {
|
||||||
|
this.closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitAttempted.set(true);
|
||||||
|
if (this.cityForm.invalid || this.saving()) return;
|
||||||
|
|
||||||
|
this.saving.set(true);
|
||||||
|
if (this.mode() === 'create') {
|
||||||
|
const request: CreateCityRequest = {
|
||||||
|
stateId: this.cityForm.controls.stateId.value,
|
||||||
|
name: this.cityForm.controls.name.value.trim(),
|
||||||
|
code: this.cityForm.controls.code.value.trim().toUpperCase(),
|
||||||
|
timezoneId: this.cityForm.controls.timezoneId.value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cityApi.createCity(request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('City created successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'create')
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const id = this.cityId();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const request: UpdateCityRequest = {
|
||||||
|
name: this.cityForm.controls.name.value.trim(),
|
||||||
|
code: this.cityForm.controls.code.value.trim().toUpperCase(),
|
||||||
|
timezoneId: this.cityForm.controls.timezoneId.value || null,
|
||||||
|
isActive: this.selectedCity()?.isActive ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cityApi.updateCity(id, request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('City updated successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'update')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
if (this.saving()) return;
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.toastr.error('A city with this code already exists in this state.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toastr.error(`Unable to ${action} city. Please try again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,9 @@ export const CITY_ENDPOINTS = {
|
|||||||
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`),
|
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`),
|
||||||
update: (id: string) =>
|
update: (id: string) =>
|
||||||
buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`),
|
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')
|
autocomplete: buildApiUrl('masterAdmin', '/v1/cities/autocomplete')
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { CITY_ENDPOINTS } from './city.endpoints';
|
|||||||
import {
|
import {
|
||||||
CityDto,
|
CityDto,
|
||||||
CreateCityRequest,
|
CreateCityRequest,
|
||||||
UpdateCityRequest
|
UpdateCityRequest,
|
||||||
|
UpdateCityStatusRequest
|
||||||
} from '../models/city.model';
|
} from '../models/city.model';
|
||||||
import {
|
import {
|
||||||
DataTableQuery,
|
DataTableQuery,
|
||||||
@@ -40,6 +41,14 @@ export class CityService {
|
|||||||
return this.http.put<CityDto>(CITY_ENDPOINTS.update(id), request);
|
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> {
|
getCityById(id: string): Observable<CityDto> {
|
||||||
return this.http.get<CityDto>(CITY_ENDPOINTS.getById(id));
|
return this.http.get<CityDto>(CITY_ENDPOINTS.getById(id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ export interface CityDto {
|
|||||||
id: string;
|
id: string;
|
||||||
stateId: string;
|
stateId: string;
|
||||||
name: string;
|
name: string;
|
||||||
state:string;
|
state?: string | { name?: string };
|
||||||
country:string;
|
stateName?: string;
|
||||||
|
country?: string | { name?: string };
|
||||||
|
countryName?: string;
|
||||||
code: string | null;
|
code: string | null;
|
||||||
timezoneId: string | null;
|
timezoneId: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -25,4 +27,9 @@ export interface UpdateCityRequest {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CityModalMode = 'create' | 'edit';
|
export interface UpdateCityStatusRequest {
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CityModalMode = 'create' | 'edit' | 'view';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<!-- Start::row-1 -->
|
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||||
<div class="grid grid-cols-12 gap-6">
|
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter($event)" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||||
<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">
|
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||||
<app-autocomplete
|
<app-autocomplete
|
||||||
formControlName="countryId"
|
formControlName="countryId"
|
||||||
@@ -11,18 +8,17 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
label="Country"
|
label="Country"
|
||||||
placeholder="Search country"
|
placeholder="Search country"
|
||||||
[searchFn]="searchCountries"
|
[searchFn]="filterCountrySearchFn"
|
||||||
[displayWith]="displayCountry"
|
[valueWith]="filterCountryValueFn"
|
||||||
[valueWith]="countryValue"
|
[displayWith]="filterCountryDisplayFn"
|
||||||
[selectedItem]="selectedCountry()"
|
[selectedItem]="selectedCountry()"
|
||||||
[minSearchLength]="1"
|
[minSearchLength]="0"
|
||||||
[debounceTime]="300"
|
[debounceTime]="300"
|
||||||
[limit]="50"
|
[limit]="50"
|
||||||
[clearable]="true"
|
[clearable]="true"
|
||||||
[hideValidation]="true"
|
[hideValidation]="true"
|
||||||
wrapperClass="!mb-0 w-full"
|
wrapperClass="!mb-0 w-full"
|
||||||
(itemSelected)="onFilterCountrySelected($event)"
|
(itemSelected)="onFilterCountryChanged($event)"
|
||||||
(cleared)="onFilterCountryCleared()"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,20 +29,18 @@
|
|||||||
variant="floating"
|
variant="floating"
|
||||||
size="sm"
|
size="sm"
|
||||||
label="State"
|
label="State"
|
||||||
[placeholder]="filterStatePlaceholder()"
|
placeholder="Search state"
|
||||||
[searchFn]="searchFilterStates"
|
[searchFn]="filterStateSearchFn"
|
||||||
[displayWith]="displayState"
|
[valueWith]="filterStateValueFn"
|
||||||
[valueWith]="stateValue"
|
[displayWith]="filterStateDisplayFn"
|
||||||
[selectedItem]="selectedFilterState()"
|
[selectedItem]="selectedFilterState()"
|
||||||
[minSearchLength]="1"
|
[minSearchLength]="0"
|
||||||
[debounceTime]="300"
|
[debounceTime]="300"
|
||||||
[limit]="50"
|
[limit]="50"
|
||||||
[clearable]="true"
|
[clearable]="true"
|
||||||
[disabled]="!selectedCountryId()"
|
|
||||||
[hideValidation]="true"
|
[hideValidation]="true"
|
||||||
wrapperClass="!mb-0 w-full"
|
wrapperClass="!mb-0 w-full"
|
||||||
(itemSelected)="onFilterStateSelected($event)"
|
(itemSelected)="onFilterStateChanged($event)"
|
||||||
(cleared)="onFilterStateCleared()"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-12 sm:col-span-2 lg:col-span-1">
|
<div class="col-span-12 sm:col-span-2 lg:col-span-1">
|
||||||
@@ -58,119 +52,46 @@
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
||||||
(buttonClicked)="applyCityFilters()"
|
(buttonClicked)="onApplyFilter($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</app-filter-card>
|
</app-filter-card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- End::row-1 -->
|
|
||||||
|
|
||||||
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
|
<app-data-table
|
||||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
[columns]="columns()"
|
||||||
tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
|
[rows]="tableStore.rows()"
|
||||||
[showAddButton]="true" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
[actions]="actions()"
|
||||||
searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)"
|
[totalRecords]="tableStore.totalRecords()"
|
||||||
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
(actionClicked)="onActionClick($event)" toolTip="Add City" />
|
[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)"
|
||||||
|
/>
|
||||||
|
|
||||||
<modal [open]="showCityModal()" [title]="modalTitle()" size="lg"
|
<app-confirm-dialog
|
||||||
[submitAction]="modalMode() === 'create' ? 'save' : 'update'" [submitLabel]="submitLabel()"
|
title="Delete City"
|
||||||
[loadingLabel]="loadingLabel()" [loading]="saving()"
|
text="Do you really want to delete this city?"
|
||||||
(closed)="closeCityModal()" (submitted)="saveCity()">
|
confirmButtonText="Delete"
|
||||||
<form [formGroup]="cityForm" (ngSubmit)="saveCity()" autocomplete="off">
|
cancelButtonText="Cancel"
|
||||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
(confirmed)="onDeleteConfirmed()"
|
||||||
<div class="col-span-12 md:col-span-6">
|
(cancelled)="onDeleteCancelled()"
|
||||||
<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-city-form-modal
|
||||||
<app-autocomplete
|
[open]="tableStore.showModal()"
|
||||||
formControlName="stateId"
|
[mode]="tableStore.modalMode()"
|
||||||
inputId="city-state"
|
[cityId]="tableStore.selectedItem()?.id ?? null"
|
||||||
variant="floating"
|
(saved)="tableStore.refresh(); tableStore.closeModal()"
|
||||||
label="State"
|
(closed)="tableStore.closeModal()"
|
||||||
[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>
|
|
||||||
</form>
|
|
||||||
</modal>
|
|
||||||
|
|||||||
@@ -1,54 +1,34 @@
|
|||||||
import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { 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 { ToastrService } from 'ngx-toastr';
|
||||||
import {
|
import { of } from 'rxjs';
|
||||||
Subject,
|
import { catchError, finalize } from 'rxjs/operators';
|
||||||
catchError,
|
|
||||||
debounceTime,
|
|
||||||
distinctUntilChanged,
|
|
||||||
finalize,
|
|
||||||
map,
|
|
||||||
of,
|
|
||||||
switchMap,
|
|
||||||
take
|
|
||||||
} from 'rxjs';
|
|
||||||
|
|
||||||
import {
|
import { CityDto, UpdateCityRequest } from '../../models/city.model';
|
||||||
CityDto,
|
|
||||||
CityModalMode,
|
|
||||||
CreateCityRequest,
|
|
||||||
UpdateCityRequest
|
|
||||||
} from '../../models/city.model';
|
|
||||||
import { CountryLookupDto } from '../../../countries/models/country.model';
|
import { CountryLookupDto } from '../../../countries/models/country.model';
|
||||||
import { StateLookupDto } from '../../../states/models/state.model';
|
import { StateLookupDto } from '../../../states/models/state.model';
|
||||||
import { TimezoneDto, TimezoneLookupDto } from '../../../timezones/models/timezone.model';
|
import { CityService } from '../../data-access/city.service';
|
||||||
import { CityService } from '../../../cities/data-access/city.service';
|
|
||||||
import { CountryService } from '../../../countries/data-access/country.service';
|
import { CountryService } from '../../../countries/data-access/country.service';
|
||||||
import { StateService } from '../../../states/data-access/state.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 { 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 {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
DataTableActionEvent,
|
DataTableActionEvent,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
DataTablePageEvent,
|
DataTableRecord
|
||||||
DataTableQuery,
|
|
||||||
DataTableRecord,
|
|
||||||
DataTableSortEvent
|
|
||||||
} from '../../../../../shared/components/data-table/data-table.types';
|
} 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 { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
import {
|
import {
|
||||||
AutocompleteDisplayFn,
|
AutocompleteDisplayFn,
|
||||||
AutocompleteResolveValueFn,
|
|
||||||
AutocompleteSearchFn,
|
AutocompleteSearchFn,
|
||||||
AutocompleteValueFn
|
AutocompleteValueFn
|
||||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
|
||||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||||
import { Button } from '../../../../../shared/components/button/button';
|
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 {
|
interface CityTableRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,6 +40,8 @@ interface CityTableRow extends DataTableRecord {
|
|||||||
serialNumber: number;
|
serialNumber: number;
|
||||||
state: string;
|
state: string;
|
||||||
country: string;
|
country: string;
|
||||||
|
stateName: string;
|
||||||
|
countryName: string;
|
||||||
createdOn?: string;
|
createdOn?: string;
|
||||||
modifiedOn?: string | null;
|
modifiedOn?: string | null;
|
||||||
}
|
}
|
||||||
@@ -67,495 +49,213 @@ interface CityTableRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'city-list',
|
selector: 'city-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard, Button],
|
imports: [
|
||||||
|
DataTable,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Autocomplete,
|
||||||
|
FilterCard,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
CityFormModalComponent
|
||||||
|
],
|
||||||
|
providers: [DataTableStore],
|
||||||
templateUrl: './city-list.html',
|
templateUrl: './city-list.html',
|
||||||
styleUrl: './city-list.scss'
|
styleUrl: './city-list.scss'
|
||||||
})
|
})
|
||||||
export class CityList {
|
export class CityList implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly cityApi = inject(CityService);
|
private readonly cityApi = inject(CityService);
|
||||||
private readonly countryApi = inject(CountryService);
|
private readonly countryApi = inject(CountryService);
|
||||||
private readonly stateApi = inject(StateService);
|
private readonly stateApi = inject(StateService);
|
||||||
private readonly timezoneApi = inject(TimezoneService);
|
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
private readonly toastr = inject(ToastrService);
|
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 selectedCountry = signal<CountryLookupDto | null>(null);
|
||||||
readonly selectedFilterState = signal<StateLookupDto | null>(null);
|
readonly selectedFilterState = signal<StateLookupDto | null>(null);
|
||||||
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
readonly statusChangingId = signal<string | null>(null);
|
||||||
readonly selectedFormState = signal<StateLookupDto | null>(null);
|
readonly deletingId = signal<string | null>(null);
|
||||||
readonly totalRecords = signal(0);
|
readonly pendingDeleteCity = signal<CityTableRow | null>(null);
|
||||||
readonly saving = signal(false);
|
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||||
readonly showCityModal = signal(false);
|
|
||||||
readonly modalMode = signal<CityModalMode>('create');
|
|
||||||
readonly selectedCity = signal<CityDto | null>(null);
|
|
||||||
readonly submitAttempted = signal(false);
|
|
||||||
|
|
||||||
readonly filterForm = this.formBuilder.nonNullable.group({
|
readonly filterForm = this.formBuilder.nonNullable.group({
|
||||||
countryId: [''],
|
countryId: [''],
|
||||||
stateId: [{ value: '', disabled: true }]
|
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>[]>([
|
readonly columns = signal<DataTableColumn<CityTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '70px' },
|
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||||
{ key: 'name', label: 'City Name', header: 'City Name', sortable: true, align: 'left' },
|
{ key: 'name', label: 'City Name', header: 'City Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
{ 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: false },
|
{ key: 'stateName', label: 'State', header: 'State', sortable: true, headerAlign: 'center', align: 'left' },
|
||||||
{ key: 'countryName', label: 'Country', header: 'Country', sortable: false },
|
{ key: 'countryName', label: 'Country', header: 'Country', sortable: true, headerAlign: 'center', align: 'center' },
|
||||||
{
|
{
|
||||||
key: 'isActive',
|
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
|
||||||
label: 'Status',
|
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
|
||||||
header: 'Status',
|
formatter: value => value ? 'Active' : 'Inactive'
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly actions = signal<DataTableAction<CityTableRow>[]>([
|
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: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||||
{
|
{
|
||||||
type: 'deactivate',
|
type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning',
|
||||||
label: 'Deactivate',
|
visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-ban',
|
|
||||||
className: 'text-danger',
|
|
||||||
visible: row => row.isActive
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'activate',
|
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
|
||||||
label: 'Activate',
|
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
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() {
|
readonly filterCountrySearchFn: AutocompleteSearchFn<CountryLookupDto> = (term, page) =>
|
||||||
this.configureCityQueries();
|
this.countryApi.autocomplete(term, page);
|
||||||
this.configureFilterChanges();
|
readonly filterCountryValueFn: AutocompleteValueFn<CountryLookupDto, string> = c => c.id;
|
||||||
this.configureFormChanges();
|
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 {
|
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.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 {
|
onFilterStateChanged(state: StateLookupDto | null): void {
|
||||||
this.selectedCountry.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
onFilterStateSelected(state: StateLookupDto): void {
|
|
||||||
this.selectedFilterState.set(state);
|
this.selectedFilterState.set(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
onFilterStateCleared(): void {
|
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.selectedFilterState.set(null);
|
||||||
}
|
this.tableStore.reset();
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddCity(): void {
|
onAddCity(): void {
|
||||||
this.modalMode.set('create');
|
this.tableStore.openCreateModal();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeCityModal(): void {
|
onActionClick(event: DataTableActionEvent<CityTableRow>): void {
|
||||||
if (this.saving()) return;
|
if (event.action.type === 'view') this.tableStore.openViewModal(event.row);
|
||||||
this.showCityModal.set(false);
|
if (event.action.type === 'edit') this.tableStore.openEditModal(event.row);
|
||||||
this.selectedCity.set(null);
|
if (event.action.type === 'delete') this.requestDeleteCity(event.row);
|
||||||
this.selectedFormCountry.set(null);
|
if (event.action.type === 'activate') this.changeCityStatus(event.row, true);
|
||||||
this.selectedFormState.set(null);
|
if (event.action.type === 'deactivate') this.changeCityStatus(event.row, false);
|
||||||
this.submitAttempted.set(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCity(): void {
|
onDeleteConfirmed(): void {
|
||||||
if (this.saving()) return;
|
const city = this.pendingDeleteCity();
|
||||||
if (this.cityForm.invalid) {
|
if (!city) return;
|
||||||
this.submitAttempted.set(true);
|
this.pendingDeleteCity.set(null);
|
||||||
this.cityForm.markAllAsTouched();
|
this.deletingId.set(city.id);
|
||||||
this.focusFirstInvalidControl();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.saving.set(true);
|
this.cityApi.delete(city.id).pipe(
|
||||||
const city = this.selectedCity();
|
finalize(() => this.deletingId.set(null)),
|
||||||
const request$ = this.modalMode() === 'create'
|
takeUntilDestroyed(this.destroyRef)
|
||||||
? this.cityApi.createCity(this.buildCreateRequest())
|
).subscribe({
|
||||||
: 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({
|
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastr.success(
|
this.toastr.success('City deleted successfully.');
|
||||||
this.modalMode() === 'create'
|
this.tableStore.refresh();
|
||||||
? 'City saved successfully.'
|
},
|
||||||
: 'City updated successfully.'
|
error: (err) => {
|
||||||
);
|
let errorMsg = 'Unable to delete city.';
|
||||||
this.showCityModal.set(false);
|
if (err?.status === 409) {
|
||||||
this.selectedCity.set(null);
|
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete city because it is currently in use or referenced by other records.';
|
||||||
this.loadCities(this.queryState.getQuery());
|
} 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 {
|
onDeleteCancelled(): void {
|
||||||
this.cityQueryRequests$.pipe(
|
this.pendingDeleteCity.set(null);
|
||||||
switchMap(query => {
|
}
|
||||||
const stateId = this.appliedStateId();
|
|
||||||
const countryId = this.appliedCountryId();
|
private requestDeleteCity(city: CityTableRow): void {
|
||||||
return this.cityApi.getCityDataTable(query, countryId, stateId).pipe(
|
this.pendingDeleteCity.set(city);
|
||||||
catchError(() => {
|
this.deleteConfirmDialog()?.open();
|
||||||
this.toastr.error('Unable to load cities.');
|
}
|
||||||
this.clearGrid();
|
|
||||||
return of(null);
|
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)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
).subscribe(response => {
|
).subscribe({
|
||||||
if (!response) return;
|
next: () => {
|
||||||
const query = this.queryState.getQuery();
|
this.toastr.success(`City ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||||
if (response.draw !== query.draw) return;
|
this.tableStore.refresh();
|
||||||
|
},
|
||||||
this.cities.set(response.rows.map((city, index) => ({
|
error: (err) => {
|
||||||
...city,
|
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} city.`;
|
||||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
this.toastr.error(msg);
|
||||||
})));
|
}
|
||||||
this.totalRecords.set(response.filtered);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private configureFilterChanges(): void {
|
|
||||||
this.filterForm.controls.countryId.valueChanges.pipe(
|
|
||||||
distinctUntilChanged(),
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(countryId => {
|
|
||||||
if (!countryId || this.selectedCountry()?.id !== countryId) {
|
|
||||||
this.selectedCountry.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectedCountryId.set(countryId || null);
|
|
||||||
this.selectedStateId.set(null);
|
|
||||||
this.selectedFilterState.set(null);
|
|
||||||
|
|
||||||
this.filterForm.controls.stateId.reset('', { emitEvent: false });
|
|
||||||
countryId
|
|
||||||
? this.filterForm.controls.stateId.enable({ emitEvent: false })
|
|
||||||
: this.filterForm.controls.stateId.disable({ emitEvent: false });
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
this.filterForm.controls.stateId.valueChanges.pipe(
|
|
||||||
distinctUntilChanged(),
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(stateId => {
|
|
||||||
if (!stateId || this.selectedFilterState()?.id !== stateId) {
|
|
||||||
this.selectedFilterState.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectedStateId.set(stateId || null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private configureFormChanges(): void {
|
|
||||||
this.cityForm.controls.countryId.valueChanges.pipe(
|
|
||||||
distinctUntilChanged(),
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
).subscribe(countryId => {
|
|
||||||
if (!this.showCityModal() || this.modalMode() !== 'create') return;
|
|
||||||
|
|
||||||
if (!countryId || this.selectedFormCountry()?.id !== countryId) {
|
|
||||||
this.selectedFormCountry.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectedFormState.set(null);
|
|
||||||
this.cityForm.controls.stateId.reset('', { emitEvent: false });
|
|
||||||
this.cityForm.controls.stateId.enable({ emitEvent: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private openExistingCity(city: CityDto, mode: 'edit'): void {
|
|
||||||
this.cityApi.getCityById(city.id).pipe(
|
|
||||||
switchMap(details => this.stateApi.getStateById(details.stateId).pipe(
|
|
||||||
map(stateDetails => ({ details, countryId: stateDetails.countryId }))
|
|
||||||
)),
|
|
||||||
take(1)
|
|
||||||
).subscribe(({ details, countryId }) => {
|
|
||||||
this.selectedCity.set(details);
|
|
||||||
this.modalMode.set(mode);
|
|
||||||
this.submitAttempted.set(false);
|
|
||||||
this.selectedFormCountry.set(null);
|
|
||||||
this.selectedFormState.set(null);
|
|
||||||
this.cityForm.enable({ emitEvent: false });
|
|
||||||
this.cityForm.reset({
|
|
||||||
countryId,
|
|
||||||
stateId: details.stateId,
|
|
||||||
name: details.name ?? '',
|
|
||||||
code: details.code ?? '',
|
|
||||||
timezoneId: details.timezoneId
|
|
||||||
}, { emitEvent: false });
|
|
||||||
// this.cityForm.controls.countryId.disable({ emitEvent: false });
|
|
||||||
// this.cityForm.controls.stateId.disable({ emitEvent: false });
|
|
||||||
this.resetFormState();
|
|
||||||
this.showCityModal.set(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateCityStatus(city: CityDto, isActive: boolean): void {
|
|
||||||
this.cityApi.updateCity(city.id, {
|
|
||||||
name: city.name.trim(),
|
|
||||||
code: city.code?.trim().toUpperCase() ?? '',
|
|
||||||
timezoneId: city.timezoneId,
|
|
||||||
isActive
|
|
||||||
}).subscribe(() => {
|
|
||||||
this.toastr.success(
|
|
||||||
isActive ? 'City activated successfully.' : 'City deactivated successfully.'
|
|
||||||
);
|
|
||||||
this.loadCities(this.queryState.getQuery());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCreateRequest(): CreateCityRequest {
|
|
||||||
const value = this.cityForm.getRawValue();
|
|
||||||
return {
|
|
||||||
stateId: value.stateId,
|
|
||||||
name: value.name.trim(),
|
|
||||||
code: value.code.trim().toUpperCase(),
|
|
||||||
timezoneId: value.timezoneId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildUpdateRequest(isActive: boolean): UpdateCityRequest {
|
|
||||||
const value = this.cityForm.getRawValue();
|
|
||||||
return {
|
|
||||||
name: value.name.trim(),
|
|
||||||
code: value.code.trim().toUpperCase(),
|
|
||||||
timezoneId: value.timezoneId,
|
|
||||||
isActive
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearGrid(): void {
|
|
||||||
this.cities.set([]);
|
|
||||||
this.totalRecords.set(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetFormState(): void {
|
|
||||||
this.cityForm.markAsPristine();
|
|
||||||
this.cityForm.markAsUntouched();
|
|
||||||
this.cityForm.updateValueAndValidity();
|
|
||||||
}
|
|
||||||
|
|
||||||
private focusFirstInvalidControl(): void {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
|
||||||
'modal [data-form-control][aria-invalid="true"]'
|
|
||||||
);
|
|
||||||
control?.focus();
|
|
||||||
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private toCityDto(row: CityTableRow): CityDto {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
stateId: row.stateId,
|
|
||||||
name: row.name,
|
|
||||||
state: row.state,
|
|
||||||
country: row.country,
|
|
||||||
code: row.code,
|
|
||||||
timezoneId: row.timezoneId,
|
|
||||||
isActive: row.isActive,
|
|
||||||
createdOn: row.createdOn,
|
|
||||||
modifiedOn: row.modifiedOn
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private toTimezoneLookup(timezone: TimezoneDto): TimezoneLookupDto {
|
|
||||||
return {
|
|
||||||
id: timezone.id,
|
|
||||||
ianaId: timezone.ianaId,
|
|
||||||
displayName: timezone.displayName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
<modal
|
||||||
|
[open]="open()"
|
||||||
|
[title]="modalTitle()"
|
||||||
|
size="md"
|
||||||
|
[submitAction]="mode() === 'create' ? 'save' : 'update'"
|
||||||
|
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
|
||||||
|
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
|
||||||
|
[loading]="saving() || modalLoading()"
|
||||||
|
[showSubmitButton]="!isViewMode()"
|
||||||
|
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||||
|
(closed)="closeModal()"
|
||||||
|
(submitted)="saveCountry()"
|
||||||
|
>
|
||||||
|
@if (modalLoading()) {
|
||||||
|
<div class="flex min-h-32 items-center justify-center">
|
||||||
|
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
|
||||||
|
<span class="ms-2">Loading country...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="countryForm" (ngSubmit)="saveCountry()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="name"
|
||||||
|
inputId="country-name"
|
||||||
|
variant="floating"
|
||||||
|
label="Country Name"
|
||||||
|
placeholder="Name"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="150"
|
||||||
|
[submitAttempted]="countrySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Country name is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="iso2"
|
||||||
|
inputId="country-iso2"
|
||||||
|
variant="floating"
|
||||||
|
label="ISO2 Code"
|
||||||
|
placeholder="Code"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="2"
|
||||||
|
[submitAttempted]="countrySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'ISO2 code is required.', pattern: 'ISO2 must be exactly 2 letters.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="iso3"
|
||||||
|
inputId="country-iso3"
|
||||||
|
variant="floating"
|
||||||
|
label="ISO3 Code"
|
||||||
|
placeholder="Code"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="3"
|
||||||
|
[submitAttempted]="countrySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'ISO3 code is required.', pattern: 'ISO3 must be exactly 3 letters.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="phoneCode"
|
||||||
|
inputId="country-phone-code"
|
||||||
|
variant="floating"
|
||||||
|
label="Phone Code"
|
||||||
|
placeholder="+1"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="16"
|
||||||
|
[submitAttempted]="countrySubmitAttempted()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="defaultCurrencyId"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
inputId="country-currency-id"
|
||||||
|
label="Default Currency"
|
||||||
|
placeholder="Select currency"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[submitAttempted]="countrySubmitAttempted()"
|
||||||
|
[searchFn]="currencySearchFn"
|
||||||
|
[valueWith]="currencyValueFn"
|
||||||
|
[displayWith]="currencyDisplayFn"
|
||||||
|
[selectedItem]="selectedCurrency()"
|
||||||
|
(itemSelected)="selectedCurrency.set($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</modal>
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CountryDto,
|
||||||
|
CountryModalMode,
|
||||||
|
CreateCountryRequest,
|
||||||
|
UpdateCountryRequest
|
||||||
|
} from '../../models/country.model';
|
||||||
|
import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api';
|
||||||
|
import { CountryService } from '../../data-access/country.service';
|
||||||
|
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||||
|
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
|
import {
|
||||||
|
AutocompleteDisplayFn,
|
||||||
|
AutocompleteSearchFn,
|
||||||
|
AutocompleteValueFn
|
||||||
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
|
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-country-form-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete],
|
||||||
|
templateUrl: './country-form-modal.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class CountryFormModalComponent {
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
|
private readonly countryApi = inject(CountryService);
|
||||||
|
private readonly currencyApi = inject(CurrencyService);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
|
readonly open = input<boolean>(false);
|
||||||
|
readonly mode = input<CountryModalMode>('create');
|
||||||
|
readonly countryId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly saved = output<void>();
|
||||||
|
readonly closed = output<void>();
|
||||||
|
|
||||||
|
readonly modalLoading = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly countrySubmitAttempted = signal(false);
|
||||||
|
readonly selectedCountry = signal<CountryDto | null>(null);
|
||||||
|
readonly selectedCurrency = signal<CurrencyLookupDto | null>(null);
|
||||||
|
|
||||||
|
readonly countryForm = this.formBuilder.nonNullable.group({
|
||||||
|
name: ['', [Validators.required, Validators.maxLength(150)]],
|
||||||
|
iso2: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{2}$/)]],
|
||||||
|
iso3: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{3}$/)]],
|
||||||
|
phoneCode: [
|
||||||
|
'',
|
||||||
|
[Validators.maxLength(16), Validators.pattern(/^\+?[0-9]{1,15}$/)]
|
||||||
|
],
|
||||||
|
defaultCurrencyId: this.formBuilder.control<string | null>(null)
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||||
|
readonly modalTitle = computed(() => {
|
||||||
|
switch (this.mode()) {
|
||||||
|
case 'create': return 'Add Country';
|
||||||
|
case 'edit': return 'Edit Country';
|
||||||
|
case 'view': return 'View Country';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly currencySearchFn: AutocompleteSearchFn<CurrencyLookupDto> = (term, page) =>
|
||||||
|
this.currencyApi.autocomplete(term, page);
|
||||||
|
readonly currencyValueFn: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
|
||||||
|
readonly currencyDisplayFn: AutocompleteDisplayFn<CurrencyLookupDto> = currency =>
|
||||||
|
`${currency.code} - ${currency.name}`;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.open()) {
|
||||||
|
this.prepareModal(this.countryId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareModal(id: string | null): void {
|
||||||
|
this.countrySubmitAttempted.set(false);
|
||||||
|
this.countryForm.reset({ name: '', iso2: '', iso3: '', phoneCode: '', defaultCurrencyId: null });
|
||||||
|
this.selectedCurrency.set(null);
|
||||||
|
|
||||||
|
if (!id || this.mode() === 'create') {
|
||||||
|
this.selectedCountry.set(null);
|
||||||
|
this.modalLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modalLoading.set(true);
|
||||||
|
this.countryApi.getCountryById(id).pipe(
|
||||||
|
switchMap(country => {
|
||||||
|
this.selectedCountry.set(country);
|
||||||
|
if (!country.defaultCurrencyId) return of({ country, currency: null });
|
||||||
|
return this.currencyApi.getCurrencyById(country.defaultCurrencyId).pipe(
|
||||||
|
map(currency => ({ country, currency })),
|
||||||
|
catchError(() => of({ country, currency: null }))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
finalize(() => this.modalLoading.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: ({ country, currency }) => {
|
||||||
|
if (currency) {
|
||||||
|
this.selectedCurrency.set({ id: currency.id, code: currency.code, name: currency.name, symbol: currency.symbol });
|
||||||
|
}
|
||||||
|
this.countryForm.patchValue({
|
||||||
|
name: country.name,
|
||||||
|
iso2: country.iso2,
|
||||||
|
iso3: country.iso3,
|
||||||
|
phoneCode: country.phoneCode ?? '',
|
||||||
|
defaultCurrencyId: country.defaultCurrencyId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.toastr.error('Unable to load country details.');
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCountry(): void {
|
||||||
|
if (this.isViewMode()) {
|
||||||
|
this.closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.countrySubmitAttempted.set(true);
|
||||||
|
if (this.countryForm.invalid || this.saving()) return;
|
||||||
|
|
||||||
|
this.saving.set(true);
|
||||||
|
if (this.mode() === 'create') {
|
||||||
|
const request: CreateCountryRequest = {
|
||||||
|
name: this.countryForm.controls.name.value.trim(),
|
||||||
|
iso2: this.countryForm.controls.iso2.value.trim().toUpperCase(),
|
||||||
|
iso3: this.countryForm.controls.iso3.value.trim().toUpperCase(),
|
||||||
|
phoneCode: this.countryForm.controls.phoneCode.value.trim() || null,
|
||||||
|
defaultCurrencyId: this.countryForm.controls.defaultCurrencyId.value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.countryApi.createCountry(request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('Country created successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'create')
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const id = this.countryId();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const request: UpdateCountryRequest = {
|
||||||
|
name: this.countryForm.controls.name.value.trim(),
|
||||||
|
iso2: this.countryForm.controls.iso2.value.trim().toUpperCase(),
|
||||||
|
iso3: this.countryForm.controls.iso3.value.trim().toUpperCase(),
|
||||||
|
phoneCode: this.countryForm.controls.phoneCode.value.trim() || null,
|
||||||
|
defaultCurrencyId: this.countryForm.controls.defaultCurrencyId.value || null,
|
||||||
|
isActive: this.selectedCountry()?.isActive ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.countryApi.updateCountry(id, request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('Country updated successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'update')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
if (this.saving()) return;
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.toastr.error('A country with this ISO code already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toastr.error(`Unable to ${action} country. Please try again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CountryLookupDto,
|
CountryLookupDto,
|
||||||
CreateCountryRequest,
|
CreateCountryRequest,
|
||||||
UpdateCountryRequest,
|
UpdateCountryRequest,
|
||||||
|
UpdateCountryStatusRequest,
|
||||||
} from '../models/country.model';
|
} from '../models/country.model';
|
||||||
import { COUNTRY_ENDPOINTS } from './country.endpoints';
|
import { COUNTRY_ENDPOINTS } from './country.endpoints';
|
||||||
|
|
||||||
@@ -32,6 +33,14 @@ export class CountryService {
|
|||||||
return this.http.put<CountryDto>(COUNTRY_ENDPOINTS.update(id), request);
|
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> {
|
getCountryById(id: string): Observable<CountryDto> {
|
||||||
return this.http.get<CountryDto>(COUNTRY_ENDPOINTS.getById(id));
|
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;
|
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()"
|
<app-data-table
|
||||||
(addClicked)="onAddCountry()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
|
[columns]="columns()"
|
||||||
[pageSize]="queryState.pageSize()" tableTitle="Countries" buttonTitle="Add"
|
[rows]="tableStore.rows()"
|
||||||
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search countries..." [searchDebounceTime]="300"
|
[actions]="actions()"
|
||||||
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
[totalRecords]="tableStore.totalRecords()"
|
||||||
(actionClicked)="onActionClick($event)" toolTip="Add Country">
|
[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">
|
<ng-template appDataTableCell="name" let-row let-value="value">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@if (getFlagUrl(row.iso2); as flagUrl) {
|
@if (getFlagUrl(row.iso2); as flagUrl) {
|
||||||
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
|
<img
|
||||||
(error)="onFlagError($event)" />
|
[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>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-data-table>
|
</app-data-table>
|
||||||
@@ -27,70 +42,10 @@
|
|||||||
(cancelled)="onDeleteCancelled()"
|
(cancelled)="onDeleteCancelled()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modal [open]="showCountryModal()" [title]="countryModalTitle()" size="md"
|
<app-country-form-modal
|
||||||
[submitAction]="countrySubmitAction()" [submitLabel]="countrySubmitLabel()" [loadingLabel]="countryLoadingLabel()"
|
[open]="tableStore.showModal()"
|
||||||
[loading]="saving()" (closed)="closeCountryModal()"
|
[mode]="tableStore.modalMode()"
|
||||||
(submitted)="saveCountry()">
|
[countryId]="tableStore.selectedItem()?.id ?? null"
|
||||||
<form [formGroup]="countryForm" (ngSubmit)="saveCountry()" autocomplete="off">
|
(saved)="tableStore.refresh()"
|
||||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
(closed)="tableStore.closeModal()"
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import {
|
|
||||||
FormBuilder,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
Validators
|
|
||||||
} from '@angular/forms';
|
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs';
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
import {
|
import { CountryDto, UpdateCountryRequest } from '../../models/country.model';
|
||||||
CountryDto,
|
|
||||||
CountryModalMode,
|
|
||||||
CreateCountryRequest,
|
|
||||||
UpdateCountryRequest
|
|
||||||
} from '../../models/country.model';
|
|
||||||
import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api';
|
|
||||||
import { CountryService } from '../../data-access/country.service';
|
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 {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
DataTableActionEvent,
|
DataTableActionEvent,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
DataTablePageEvent,
|
DataTableRecord
|
||||||
DataTableQuery,
|
|
||||||
DataTableRecord,
|
|
||||||
DataTableSortEvent
|
|
||||||
} from '../../../../../shared/components/data-table/data-table.types';
|
} 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 { 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 { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||||
|
import { CountryFormModalComponent } from '../../components/country-form-modal/country-form-modal';
|
||||||
|
|
||||||
interface CountryTableRow extends DataTableRecord {
|
interface CountryTableRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -54,380 +33,110 @@ interface CountryTableRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'country-list',
|
selector: 'country-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog],
|
imports: [DataTable, DataTableCellDirective, ConfirmDialog, CountryFormModalComponent],
|
||||||
|
providers: [DataTableStore],
|
||||||
templateUrl: './country-list.html',
|
templateUrl: './country-list.html',
|
||||||
styleUrl: './country-list.scss',
|
styleUrl: './country-list.scss',
|
||||||
})
|
})
|
||||||
export class CountryList {
|
export class CountryList implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly countryApi = inject(CountryService);
|
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 toastr = inject(ToastrService);
|
||||||
private readonly countryQueryRequests$ = new Subject<DataTableQuery>();
|
readonly tableStore = inject(DataTableStore<CountryDto, CountryTableRow>);
|
||||||
|
|
||||||
readonly queryState = new DataTableQueryState();
|
readonly statusChangingId = signal<string | null>(null);
|
||||||
|
readonly deletingId = signal<string | null>(null);
|
||||||
readonly countries = signal<CountryTableRow[]>([]);
|
readonly pendingDeleteCountry = signal<CountryTableRow | null>(null);
|
||||||
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 deleteConfirmDialog = viewChild(ConfirmDialog);
|
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>[]>([
|
readonly columns = signal<DataTableColumn<CountryTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
|
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||||
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
|
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true,
|
||||||
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
|
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary'
|
||||||
{ key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true },
|
},
|
||||||
{ key: 'currencyName', label: 'Default Currency', header: 'Default Currency', sortable: true, align: 'left' },
|
{ 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',
|
key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true, headerAlign: 'center', align: 'center',
|
||||||
label: 'Status',
|
formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—'
|
||||||
header: 'Status',
|
},
|
||||||
sortable: true,
|
{
|
||||||
badge: true,
|
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
|
||||||
badgeClass: value =>
|
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
|
||||||
value === true
|
|
||||||
? 'badge bg-success/10 text-success'
|
|
||||||
: 'badge bg-danger/10 text-danger',
|
|
||||||
formatter: value => value ? 'Active' : 'Inactive'
|
formatter: value => value ? 'Active' : 'Inactive'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly actions = signal<DataTableAction<CountryTableRow>[]>([
|
readonly actions = signal<DataTableAction<CountryTableRow>[]>([
|
||||||
|
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||||
{
|
{
|
||||||
type: 'edit',
|
type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning',
|
||||||
label: 'Edit',
|
visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-edit',
|
|
||||||
className: 'text-primary'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'delete',
|
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
|
||||||
label: 'Delete',
|
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-trash',
|
|
||||||
className: 'text-danger',
|
|
||||||
visible: row => row.isActive
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'activate',
|
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||||
label: 'Activate',
|
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-check',
|
|
||||||
className: 'text-success',
|
|
||||||
visible: row => !row.isActive
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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 {
|
ngOnInit(): void {
|
||||||
this.loadCountries(this.queryState.getQuery());
|
this.tableStore.initialize({
|
||||||
}
|
fetcher: query => this.countryApi.getCountryDataTable(query)
|
||||||
|
|
||||||
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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onReset(): void {
|
onAddCountry(): void {
|
||||||
const query = this.queryState.reset();
|
this.tableStore.openCreateModal();
|
||||||
this.loadCountries(query);
|
}
|
||||||
|
|
||||||
|
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 {
|
onDeleteConfirmed(): void {
|
||||||
const country = this.pendingDeleteCountry();
|
const country = this.pendingDeleteCountry();
|
||||||
|
if (!country) return;
|
||||||
if (!country) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingDeleteCountry.set(null);
|
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 {
|
onDeleteCancelled(): void {
|
||||||
this.pendingDeleteCountry.set(null);
|
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 {
|
getFlagUrl(iso2: string | null | undefined): string {
|
||||||
const code = iso2?.trim().toLowerCase();
|
const code = iso2?.trim().toLowerCase();
|
||||||
|
|
||||||
return code && /^[a-z]{2}$/.test(code)
|
return code && /^[a-z]{2}$/.test(code)
|
||||||
? `https://flagcdn.com/24x18/${code}.png`
|
? `https://flagcdn.com/24x18/${code}.png`
|
||||||
: '';
|
: '';
|
||||||
@@ -438,122 +147,26 @@ export class CountryList {
|
|||||||
image.style.display = 'none';
|
image.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
private viewCountry(country: CountryDto): void {
|
private requestDeleteCountry(country: CountryTableRow): void {
|
||||||
this.openEditCountry(country);
|
|
||||||
}
|
|
||||||
|
|
||||||
private requestDeleteCountry(country: CountryDto): void {
|
|
||||||
this.pendingDeleteCountry.set(country);
|
this.pendingDeleteCountry.set(country);
|
||||||
this.deleteConfirmDialog()?.open();
|
this.deleteConfirmDialog()?.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
private deleteCountry(country: CountryDto): void {
|
private changeCountryStatus(country: CountryTableRow, activate: boolean): void {
|
||||||
this.updateCountryStatus(country, false);
|
this.statusChangingId.set(country.id);
|
||||||
}
|
|
||||||
|
|
||||||
private activateCountry(country: CountryDto): void {
|
this.countryApi.updateStatus(country.id, { isActive: activate }).pipe(
|
||||||
this.updateCountryStatus(country, true);
|
finalize(() => this.statusChangingId.set(null)),
|
||||||
}
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
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: () => {
|
next: () => {
|
||||||
this.toastr.success(
|
this.toastr.success(`Country ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||||
isActive
|
this.tableStore.refresh();
|
||||||
? 'Country activated successfully.'
|
},
|
||||||
: 'Country deactivated successfully.'
|
error: (err) => {
|
||||||
);
|
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} country.`;
|
||||||
this.loadCountries(this.queryState.getQuery());
|
this.toastr.error(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private nullWhenBlank(value: string | null | undefined): string | null {
|
|
||||||
const normalized = value?.trim();
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
? normalized
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private toCountryDto(row: CountryTableRow): CountryDto {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
iso2: row.iso2,
|
|
||||||
iso3: row.iso3,
|
|
||||||
name: row.name,
|
|
||||||
phoneCode: row.phoneCode,
|
|
||||||
defaultCurrencyId: row.defaultCurrencyId,
|
|
||||||
isActive: row.isActive,
|
|
||||||
createdOn: row.createdOn,
|
|
||||||
modifiedOn: row.modifiedOn
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
<modal
|
||||||
|
[open]="open()"
|
||||||
|
[title]="modalTitle()"
|
||||||
|
size="md"
|
||||||
|
[submitAction]="mode() === 'create' ? 'save' : 'update'"
|
||||||
|
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
|
||||||
|
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
|
||||||
|
[loading]="saving() || modalLoading()"
|
||||||
|
[showSubmitButton]="!isViewMode()"
|
||||||
|
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||||
|
(closed)="closeModal()"
|
||||||
|
(submitted)="saveCurrency()"
|
||||||
|
>
|
||||||
|
@if (modalLoading()) {
|
||||||
|
<div class="flex min-h-32 items-center justify-center">
|
||||||
|
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
|
||||||
|
<span class="ms-2">Loading currency...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="currencyForm" (ngSubmit)="saveCurrency()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="code"
|
||||||
|
inputId="currency-code"
|
||||||
|
variant="floating"
|
||||||
|
label="ISO Code"
|
||||||
|
placeholder="Code"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="3"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Currency code is required.', pattern: 'Currency code must be 3 letters.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="name"
|
||||||
|
inputId="currency-name"
|
||||||
|
variant="floating"
|
||||||
|
label="Currency Name"
|
||||||
|
placeholder="Name"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="100"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Currency name is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="symbol"
|
||||||
|
inputId="currency-symbol"
|
||||||
|
variant="floating"
|
||||||
|
label="Symbol"
|
||||||
|
placeholder="Symbol"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="10"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'Currency symbol is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="numericCode"
|
||||||
|
inputId="currency-numeric-code"
|
||||||
|
variant="floating"
|
||||||
|
label="Numeric Code"
|
||||||
|
type="number"
|
||||||
|
placeholder="Code"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[min]="1"
|
||||||
|
[max]="999"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="decimalDigits"
|
||||||
|
inputId="currency-decimal-digits"
|
||||||
|
variant="floating"
|
||||||
|
label="Decimal Digits"
|
||||||
|
type="number"
|
||||||
|
placeholder="Digits"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[min]="0"
|
||||||
|
[max]="8"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</modal>
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateCurrencyRequest,
|
||||||
|
CurrencyDto,
|
||||||
|
CurrencyIso2Value,
|
||||||
|
CurrencyModalMode,
|
||||||
|
UpdateCurrencyRequest
|
||||||
|
} from '../../models/currency.model';
|
||||||
|
import { CurrencyService } from '../../data-access/currency.service';
|
||||||
|
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||||
|
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-currency-form-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [Modal, ReactiveFormsModule, FormInput],
|
||||||
|
templateUrl: './currency-form-modal.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class CurrencyFormModalComponent {
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
|
private readonly currencyApi = inject(CurrencyService);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
|
readonly open = input<boolean>(false);
|
||||||
|
readonly mode = input<CurrencyModalMode>('create');
|
||||||
|
readonly currencyId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly saved = output<void>();
|
||||||
|
readonly closed = output<void>();
|
||||||
|
|
||||||
|
readonly modalLoading = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly currencySubmitAttempted = signal(false);
|
||||||
|
readonly selectedCurrency = signal<CurrencyDto | null>(null);
|
||||||
|
|
||||||
|
readonly currencyForm = this.formBuilder.nonNullable.group({
|
||||||
|
name: ['', [Validators.required, Validators.maxLength(100)]],
|
||||||
|
code: ['', [Validators.required, Validators.pattern(/^[A-Za-z]{3}$/)]],
|
||||||
|
symbol: ['', [Validators.required, Validators.maxLength(10)]],
|
||||||
|
numericCode: [
|
||||||
|
0,
|
||||||
|
[Validators.required, Validators.min(1), Validators.max(999)]
|
||||||
|
],
|
||||||
|
decimalDigits: [
|
||||||
|
2,
|
||||||
|
[Validators.required, Validators.min(0), Validators.max(8)]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||||
|
readonly modalTitle = computed(() => {
|
||||||
|
switch (this.mode()) {
|
||||||
|
case 'create': return 'Add Currency';
|
||||||
|
case 'edit': return 'Edit Currency';
|
||||||
|
case 'view': return 'View Currency';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.open()) {
|
||||||
|
this.prepareModal(this.currencyId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareModal(id: string | null): void {
|
||||||
|
this.currencySubmitAttempted.set(false);
|
||||||
|
this.currencyForm.reset({
|
||||||
|
name: '', code: '', symbol: '', numericCode: 0, decimalDigits: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!id || this.mode() === 'create') {
|
||||||
|
this.selectedCurrency.set(null);
|
||||||
|
this.modalLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modalLoading.set(true);
|
||||||
|
this.currencyApi.getCurrencyById(id).pipe(
|
||||||
|
finalize(() => this.modalLoading.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: currency => {
|
||||||
|
this.selectedCurrency.set(currency);
|
||||||
|
this.currencyForm.patchValue({
|
||||||
|
name: currency.name,
|
||||||
|
code: currency.code,
|
||||||
|
symbol: currency.symbol,
|
||||||
|
numericCode: currency.numericCode,
|
||||||
|
decimalDigits: currency.decimalDigits
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.toastr.error('Unable to load currency details.');
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrency(): void {
|
||||||
|
if (this.isViewMode()) {
|
||||||
|
this.closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currencySubmitAttempted.set(true);
|
||||||
|
if (this.currencyForm.invalid || this.saving()) return;
|
||||||
|
|
||||||
|
this.saving.set(true);
|
||||||
|
if (this.mode() === 'create') {
|
||||||
|
const request: CreateCurrencyRequest = {
|
||||||
|
code: this.currencyForm.controls.code.value.trim().toUpperCase(),
|
||||||
|
name: this.currencyForm.controls.name.value.trim(),
|
||||||
|
symbol: this.currencyForm.controls.symbol.value.trim(),
|
||||||
|
numericCode: this.currencyForm.controls.numericCode.value,
|
||||||
|
decimalDigits: this.currencyForm.controls.decimalDigits.value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currencyApi.createCurrency(request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('Currency created successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'create')
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const id = this.currencyId();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const request: UpdateCurrencyRequest = {
|
||||||
|
code: this.currencyForm.controls.code.value.trim().toUpperCase(),
|
||||||
|
name: this.currencyForm.controls.name.value.trim(),
|
||||||
|
symbol: this.currencyForm.controls.symbol.value.trim(),
|
||||||
|
numericCode: this.currencyForm.controls.numericCode.value,
|
||||||
|
decimalDigits: this.currencyForm.controls.decimalDigits.value,
|
||||||
|
isActive: this.selectedCurrency()?.isActive ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currencyApi.updateCurrency(id, request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('Currency updated successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'update')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
if (this.saving()) return;
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.toastr.error('A currency with this ISO code already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toastr.error(`Unable to ${action} currency. Please try again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
CreateCurrencyRequest,
|
CreateCurrencyRequest,
|
||||||
CurrencyDto,
|
CurrencyDto,
|
||||||
CurrencyLookupDto,
|
CurrencyLookupDto,
|
||||||
UpdateCurrencyRequest
|
UpdateCurrencyRequest,
|
||||||
|
UpdateCurrencyStatusRequest
|
||||||
} from '../models/currency.model';
|
} from '../models/currency.model';
|
||||||
import { CURRENCY_ENDPOINTS } from './currency.endpoints';
|
import { CURRENCY_ENDPOINTS } from './currency.endpoints';
|
||||||
|
|
||||||
@@ -29,6 +30,14 @@ export class CurrencyService {
|
|||||||
return this.http.put<CurrencyDto>(CURRENCY_ENDPOINTS.update(id), request);
|
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> {
|
getCurrencyById(id: string): Observable<CurrencyDto> {
|
||||||
return this.http.get<CurrencyDto>(CURRENCY_ENDPOINTS.getById(id));
|
return this.http.get<CurrencyDto>(CURRENCY_ENDPOINTS.getById(id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface CurrencyDto {
|
|||||||
modifiedOn?: string | null;
|
modifiedOn?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface CurrencyLookupDto {
|
export interface CurrencyLookupDto {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
@@ -40,4 +39,9 @@ export interface UpdateCurrencyRequest extends CreateCurrencyRequest {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CurrencyModalMode = 'create' | 'edit';
|
export interface UpdateCurrencyStatusRequest {
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrencyModalMode = 'create' | 'edit' | 'view';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
<app-data-table [columns]="columns()" [rows]="currencies()" [actions]="actions()" [totalRecords]="totalRecords()"
|
<app-data-table
|
||||||
[pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
[columns]="columns()"
|
||||||
tableTitle="Currency Management" buttonTitle="Add" [showSearch]="true" [showAddButton]="true"
|
[rows]="tableStore.rows()"
|
||||||
searchPlaceholder="Search currencies..." [searchDebounceTime]="300" toolTip="Add Currency"
|
[actions]="actions()"
|
||||||
(addClicked)="onAddCurrency()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
[totalRecords]="tableStore.totalRecords()"
|
||||||
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)">
|
[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>
|
<ng-template appDataTableCell="iso2" let-row>
|
||||||
@if (visibleCountries(row); as countries) {
|
@if (visibleCountries(row); as countries) {
|
||||||
@if (countries.length > 0) {
|
@if (countries.length > 0) {
|
||||||
@@ -100,47 +114,22 @@
|
|||||||
<span class="badge bg-primary/10 text-primary">
|
<span class="badge bg-primary/10 text-primary">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</span>
|
</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?"
|
<app-confirm-dialog
|
||||||
confirmButtonText="Delete" cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()"
|
title="Delete Currency"
|
||||||
(cancelled)="onDeleteCancelled()" />
|
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"
|
<app-currency-form-modal
|
||||||
[submitAction]="currencySubmitAction()" [submitLabel]="currencySubmitLabel()"
|
[open]="tableStore.showModal()"
|
||||||
[loadingLabel]="currencyLoadingLabel()" [loading]="saving()" (closed)="closeCurrencyModal()"
|
[mode]="tableStore.modalMode()"
|
||||||
(submitted)="saveCurrency()">
|
[currencyId]="tableStore.selectedItem()?.id ?? null"
|
||||||
<form [formGroup]="currencyForm" (ngSubmit)="saveCurrency()" autocomplete="off">
|
(saved)="tableStore.refresh()"
|
||||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
(closed)="tableStore.closeModal()"
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
CdkConnectedOverlay,
|
CdkConnectedOverlay,
|
||||||
CdkOverlayOrigin,
|
CdkOverlayOrigin,
|
||||||
ConnectedOverlayPositionChange,
|
ConnectedOverlayPositionChange,
|
||||||
ConnectedPosition
|
ConnectedPosition
|
||||||
} from '@angular/cdk/overlay';
|
} 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 {
|
import {
|
||||||
CreateCurrencyRequest,
|
|
||||||
CurrencyCountryFlag,
|
CurrencyCountryFlag,
|
||||||
CurrencyDto,
|
CurrencyDto,
|
||||||
CurrencyIso2Value,
|
CurrencyIso2Value,
|
||||||
CurrencyModalMode,
|
|
||||||
UpdateCurrencyRequest
|
UpdateCurrencyRequest
|
||||||
} from '../../models/currency.model';
|
} from '../../models/currency.model';
|
||||||
import { CurrencyService } from '../../data-access/currency.service';
|
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 {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
DataTableActionEvent,
|
DataTableActionEvent,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
DataTablePageEvent,
|
DataTableRecord
|
||||||
DataTableQuery,
|
|
||||||
DataTableRecord,
|
|
||||||
DataTableSortEvent
|
|
||||||
} from '../../../../../shared/components/data-table/data-table.types';
|
} 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 { 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 { 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';
|
type Iso2TooltipPlacement = 'above' | 'below' | 'left' | 'right';
|
||||||
|
|
||||||
interface CurrencyTableRow extends DataTableRecord {
|
interface CurrencyTableRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -57,32 +47,29 @@ interface CurrencyTableRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'currency-list',
|
selector: 'currency-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog, CdkOverlayOrigin, CdkConnectedOverlay],
|
imports: [
|
||||||
|
DataTable,
|
||||||
|
DataTableCellDirective,
|
||||||
|
ConfirmDialog,
|
||||||
|
CdkOverlayOrigin,
|
||||||
|
CdkConnectedOverlay,
|
||||||
|
CurrencyFormModalComponent
|
||||||
|
],
|
||||||
|
providers: [DataTableStore],
|
||||||
templateUrl: './currency-list.html',
|
templateUrl: './currency-list.html',
|
||||||
styleUrl: './currency-list.scss',
|
styleUrl: './currency-list.scss',
|
||||||
})
|
})
|
||||||
export class CurrencyList {
|
export class CurrencyList implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly currencyApi = inject(CurrencyService);
|
private readonly currencyApi = inject(CurrencyService);
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
|
||||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
private readonly currencyQueryRequests$ = new Subject<DataTableQuery>();
|
readonly tableStore = inject(DataTableStore<CurrencyDto, CurrencyTableRow>);
|
||||||
|
|
||||||
readonly queryState = new DataTableQueryState();
|
readonly statusChangingId = signal<string | null>(null);
|
||||||
|
readonly deletingId = signal<string | null>(null);
|
||||||
readonly currencies = signal<CurrencyTableRow[]>([]);
|
readonly pendingDeleteCurrency = signal<CurrencyTableRow | null>(null);
|
||||||
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 deleteConfirmDialog = viewChild(ConfirmDialog);
|
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||||
|
|
||||||
readonly openIso2TooltipCurrencyId = signal<string | null>(null);
|
readonly openIso2TooltipCurrencyId = signal<string | null>(null);
|
||||||
readonly iso2TooltipPlacement = signal<Iso2TooltipPlacement>('right');
|
readonly iso2TooltipPlacement = signal<Iso2TooltipPlacement>('right');
|
||||||
readonly iso2TooltipPositions: ConnectedPosition[] = [
|
readonly iso2TooltipPositions: ConnectedPosition[] = [
|
||||||
@@ -96,439 +83,109 @@ export class CurrencyList {
|
|||||||
];
|
];
|
||||||
private iso2TooltipCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
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>[]>([
|
readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
|
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '90px' },
|
||||||
{ key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true , align: 'left'},
|
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
{ key: 'iso2', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'center', align: 'left' },
|
||||||
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true },
|
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: false, headerAlign: 'center', align: 'center', width: '90px' },
|
||||||
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true },
|
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true, headerAlign: 'center', align: 'center', width: '130px' },
|
||||||
{ key: 'decimalDigits', label: 'Decimal Digits', header: 'Decimal Digits', sortable: true },
|
|
||||||
{
|
{
|
||||||
key: 'isActive',
|
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
|
||||||
label: 'Status',
|
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
|
||||||
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'
|
formatter: value => value ? 'Active' : 'Inactive'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly actions = signal<DataTableAction<CurrencyTableRow>[]>([
|
readonly actions = signal<DataTableAction<CurrencyTableRow>[]>([
|
||||||
|
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||||
{
|
{
|
||||||
type: 'edit',
|
type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning',
|
||||||
label: 'Edit',
|
visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-edit',
|
|
||||||
className: 'text-primary'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'delete',
|
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
|
||||||
label: 'Delete',
|
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-trash',
|
|
||||||
className: 'text-danger',
|
|
||||||
visible: row => row.isActive
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'activate',
|
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||||
label: 'Activate',
|
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-check',
|
|
||||||
className: 'text-success',
|
|
||||||
visible: row => !row.isActive
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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 {
|
ngOnInit(): void {
|
||||||
this.loadCurrencies(this.queryState.getQuery());
|
this.tableStore.initialize({
|
||||||
}
|
fetcher: query => this.currencyApi.getCurrencyDataTable(query)
|
||||||
|
|
||||||
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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onReset(): void {
|
onAddCurrency(): void {
|
||||||
const query = this.queryState.reset();
|
this.tableStore.openCreateModal();
|
||||||
this.loadCurrencies(query);
|
}
|
||||||
|
|
||||||
|
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 {
|
onDeleteConfirmed(): void {
|
||||||
const currency = this.pendingDeleteCurrency();
|
const currency = this.pendingDeleteCurrency();
|
||||||
|
if (!currency) return;
|
||||||
if (!currency) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingDeleteCurrency.set(null);
|
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 {
|
onDeleteCancelled(): void {
|
||||||
this.pendingDeleteCurrency.set(null);
|
this.pendingDeleteCurrency.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
onActionClick(event: DataTableActionEvent<CurrencyTableRow>): void {
|
private requestDeleteCurrency(currency: 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 {
|
|
||||||
this.pendingDeleteCurrency.set(currency);
|
this.pendingDeleteCurrency.set(currency);
|
||||||
this.deleteConfirmDialog()?.open();
|
this.deleteConfirmDialog()?.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
private deleteCurrency(currency: CurrencyDto): void {
|
private changeCurrencyStatus(currency: CurrencyTableRow, activate: boolean): void {
|
||||||
this.updateCurrencyStatus(currency, false);
|
this.statusChangingId.set(currency.id);
|
||||||
}
|
|
||||||
|
|
||||||
private activateCurrency(currency: CurrencyDto): void {
|
this.currencyApi.updateStatus(currency.id, { isActive: activate }).pipe(
|
||||||
this.updateCurrencyStatus(currency, true);
|
finalize(() => this.statusChangingId.set(null)),
|
||||||
}
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
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: () => {
|
next: () => {
|
||||||
this.toastr.success(
|
this.toastr.success(`Currency ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||||
isActive
|
this.tableStore.refresh();
|
||||||
? 'Currency activated successfully.'
|
},
|
||||||
: 'Currency deactivated successfully.'
|
error: (err) => {
|
||||||
);
|
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} currency.`;
|
||||||
this.loadCurrencies(this.queryState.getQuery());
|
this.toastr.error(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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[] {
|
visibleCountries(row: CurrencyTableRow): readonly CurrencyCountryFlag[] {
|
||||||
return this.normalizeIso2Codes(row.iso2).slice(0, 1);
|
return this.normalizeIso2Codes(row.iso2).slice(0, 1);
|
||||||
}
|
}
|
||||||
@@ -589,7 +246,7 @@ export class CurrencyList {
|
|||||||
const rawCode = typeof item === 'string'
|
const rawCode = typeof item === 'string'
|
||||||
? item
|
? item
|
||||||
: item && typeof item === 'object' && 'iso2' in item
|
: item && typeof item === 'object' && 'iso2' in item
|
||||||
? String(item.iso2)
|
? String((item as { iso2?: unknown }).iso2)
|
||||||
: '';
|
: '';
|
||||||
const iso2 = rawCode.trim().toUpperCase();
|
const iso2 = rawCode.trim().toUpperCase();
|
||||||
|
|
||||||
@@ -615,6 +272,7 @@ export class CurrencyList {
|
|||||||
? `https://flagcdn.com/24x18/${code}.png`
|
? `https://flagcdn.com/24x18/${code}.png`
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onFlagError(event: Event): void {
|
onFlagError(event: Event): void {
|
||||||
const image = event.target as HTMLImageElement;
|
const image = event.target as HTMLImageElement;
|
||||||
image.classList.add('hidden');
|
image.classList.add('hidden');
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
<modal [open]="open()" [title]="modalTitle()" size="md" [submitAction]="mode() === 'create' ? 'save' : 'update'"
|
||||||
|
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
|
||||||
|
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'" [loading]="saving() || modalLoading()"
|
||||||
|
[showSubmitButton]="!isViewMode()" [cancelLabel]="isViewMode() ? 'Close' : 'Cancel'" (closed)="closeModal()"
|
||||||
|
(submitted)="saveLanguage()">
|
||||||
|
@if (modalLoading()) {
|
||||||
|
<div class="flex min-h-32 items-center justify-center">
|
||||||
|
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
|
||||||
|
<span class="ms-2">Loading language...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="languageForm" (ngSubmit)="saveLanguage()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="code" inputId="language-code" variant="floating" label="Language Code"
|
||||||
|
placeholder="e.g. en-US" [required]="true" [readonly]="isViewMode() || mode() === 'edit'" [maxLength]="35"
|
||||||
|
[submitAttempted]="submitAttempted()" [validationMessages]="{ required: 'Language code is required.' }" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="name" inputId="language-name" variant="floating" label="Language Name"
|
||||||
|
placeholder="Name" [required]="true" [readonly]="isViewMode()" [maxLength]="100"
|
||||||
|
[submitAttempted]="submitAttempted()" [validationMessages]="{ required: 'Language name is required.' }" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input formControlName="nativeName" inputId="language-native-name" variant="floating"
|
||||||
|
label="Native Name" placeholder="Native Name" [required]="true" [readonly]="isViewMode()" [maxLength]="100"
|
||||||
|
[submitAttempted]="submitAttempted()" [validationMessages]="{ required: 'Native name is required.' }" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6 flex items-center pt-3">
|
||||||
|
<label for="language-rtl" class="inline-flex cursor-pointer items-center gap-2">
|
||||||
|
<input id="language-rtl" type="checkbox" formControlName="isRightToLeft" class="form-check-input" />
|
||||||
|
<span>Right To Left Language</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</modal>
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateLanguageRequest,
|
||||||
|
LanguageDto,
|
||||||
|
LanguageModalMode,
|
||||||
|
UpdateLanguageRequest
|
||||||
|
} from '../../models/language.model';
|
||||||
|
import { LanguageService } from '../../data-access/language.service';
|
||||||
|
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||||
|
import { FormCheckbox } from '../../../../../shared/components/form/form-checkbox/form-checkbox';
|
||||||
|
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-language-form-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [Modal, ReactiveFormsModule, FormInput],
|
||||||
|
templateUrl: './language-form-modal.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class LanguageFormModalComponent {
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
|
private readonly languageApi = inject(LanguageService);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
|
readonly open = input<boolean>(false);
|
||||||
|
readonly mode = input<LanguageModalMode>('create');
|
||||||
|
readonly languageId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly saved = output<void>();
|
||||||
|
readonly closed = output<void>();
|
||||||
|
|
||||||
|
readonly modalLoading = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly submitAttempted = signal(false);
|
||||||
|
readonly selectedLanguage = signal<LanguageDto | null>(null);
|
||||||
|
|
||||||
|
readonly languageForm = this.formBuilder.nonNullable.group({
|
||||||
|
name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
|
||||||
|
code: ['', [Validators.required, Validators.maxLength(35), Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/)]],
|
||||||
|
nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]],
|
||||||
|
isRightToLeft: [false]
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||||
|
readonly modalTitle = computed(() => {
|
||||||
|
switch (this.mode()) {
|
||||||
|
case 'create': return 'Add Language';
|
||||||
|
case 'edit': return 'Edit Language';
|
||||||
|
case 'view': return 'View Language';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.open()) {
|
||||||
|
this.prepareModal(this.languageId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareModal(id: string | null): void {
|
||||||
|
this.submitAttempted.set(false);
|
||||||
|
this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
|
||||||
|
|
||||||
|
if (!id || this.mode() === 'create') {
|
||||||
|
this.selectedLanguage.set(null);
|
||||||
|
this.modalLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modalLoading.set(true);
|
||||||
|
this.languageApi.getById(id).pipe(
|
||||||
|
finalize(() => this.modalLoading.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: language => {
|
||||||
|
this.selectedLanguage.set(language);
|
||||||
|
this.languageForm.patchValue({
|
||||||
|
name: language.name,
|
||||||
|
code: language.code,
|
||||||
|
nativeName: language.nativeName,
|
||||||
|
isRightToLeft: language.isRightToLeft
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.toastr.error('Unable to load language details.');
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLanguage(): void {
|
||||||
|
if (this.isViewMode()) {
|
||||||
|
this.closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitAttempted.set(true);
|
||||||
|
if (this.languageForm.invalid || this.saving()) return;
|
||||||
|
|
||||||
|
this.saving.set(true);
|
||||||
|
if (this.mode() === 'create') {
|
||||||
|
const request: CreateLanguageRequest = {
|
||||||
|
code: this.languageForm.controls.code.value.trim(),
|
||||||
|
name: this.languageForm.controls.name.value.trim(),
|
||||||
|
nativeName: this.languageForm.controls.nativeName.value.trim(),
|
||||||
|
isRightToLeft: this.languageForm.controls.isRightToLeft.value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.languageApi.create(request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('Language created successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'create')
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const id = this.languageId();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const request: UpdateLanguageRequest = {
|
||||||
|
code: this.languageForm.controls.code.value.trim(),
|
||||||
|
name: this.languageForm.controls.name.value.trim(),
|
||||||
|
nativeName: this.languageForm.controls.nativeName.value.trim(),
|
||||||
|
isRightToLeft: this.languageForm.controls.isRightToLeft.value,
|
||||||
|
isActive: this.selectedLanguage()?.isActive ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.languageApi.update(id, request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('Language updated successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'update')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
if (this.saving()) return;
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.toastr.error('A language with this code already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toastr.error(`Unable to ${action} language. Please try again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,9 @@ export const LANGUAGE_ENDPOINTS = {
|
|||||||
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
|
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
|
||||||
update: (id: string) =>
|
update: (id: string) =>
|
||||||
buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`),
|
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')
|
autocomplete: buildApiUrl('masterAdmin', '/v1/languages/autocomplete')
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
CreateLanguageRequest,
|
CreateLanguageRequest,
|
||||||
LanguageDto,
|
LanguageDto,
|
||||||
LanguageLookupDto,
|
LanguageLookupDto,
|
||||||
UpdateLanguageRequest
|
UpdateLanguageRequest,
|
||||||
|
UpdateLanguageStatusRequest
|
||||||
} from '../models/language.model';
|
} from '../models/language.model';
|
||||||
import {
|
import {
|
||||||
DataTableQuery,
|
DataTableQuery,
|
||||||
@@ -34,6 +35,14 @@ export class LanguageService {
|
|||||||
return this.http.put<LanguageDto>(LANGUAGE_ENDPOINTS.update(id), request);
|
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(
|
autocomplete(
|
||||||
term: string | null,
|
term: string | null,
|
||||||
limit = 10
|
limit = 10
|
||||||
|
|||||||
@@ -28,4 +28,9 @@ export interface UpdateLanguageRequest extends CreateLanguageRequest {
|
|||||||
isActive: boolean;
|
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()"
|
<app-data-table
|
||||||
[pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()" tableTitle="Languages" buttonTitle="Add"
|
[columns]="columns()"
|
||||||
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search languages..." [searchDebounceTime]="300"
|
[rows]="tableStore.rows()"
|
||||||
toolTip="Add Language" (addClicked)="onAddLanguage()" (searchChanged)="onSearch($event)"
|
[actions]="actions()"
|
||||||
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)">
|
[totalRecords]="tableStore.totalRecords()"
|
||||||
<ng-template appDataTableCell="name" let-value="value">
|
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
<span class="font-semibold">{{ value }}</span>
|
[pageSize]="tableStore.queryState.pageSize()"
|
||||||
</ng-template>
|
tableTitle="Languages"
|
||||||
<ng-template appDataTableCell="code" let-value="value">
|
buttonTitle="Add"
|
||||||
<span class="badge bg-primary/10 text-primary">{{ value }}</span>
|
[showSearch]="true"
|
||||||
</ng-template>
|
[showAddButton]="true"
|
||||||
</app-data-table>
|
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?"
|
<app-confirm-dialog
|
||||||
confirmButtonText="Delete" cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()"
|
title="Delete Language"
|
||||||
(cancelled)="onDeleteCancelled()" />
|
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()"
|
<app-language-form-modal
|
||||||
[submitLabel]="submitLabel()" [loadingLabel]="loadingLabel()" [loading]="saving() || modalLoading()"
|
[open]="tableStore.showModal()"
|
||||||
(closed)="closeModal()" (submitted)="saveLanguage()">
|
[mode]="tableStore.modalMode()"
|
||||||
@if (modalLoading()) {
|
[languageId]="tableStore.selectedItem()?.id ?? null"
|
||||||
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
|
(saved)="tableStore.refresh()"
|
||||||
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
|
(closed)="tableStore.closeModal()"
|
||||||
<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>
|
|
||||||
@@ -1,32 +1,20 @@
|
|||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
|
||||||
import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core';
|
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { Subject, catchError, finalize, of, switchMap } from 'rxjs';
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
import {
|
import { LanguageDto, UpdateLanguageRequest } from '../../models/language.model';
|
||||||
CreateLanguageRequest,
|
|
||||||
LanguageDto,
|
|
||||||
LanguageModalMode,
|
|
||||||
UpdateLanguageRequest
|
|
||||||
} from '../../models/language.model';
|
|
||||||
import { LanguageService } from '../../data-access/language.service';
|
import { LanguageService } from '../../data-access/language.service';
|
||||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
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 {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
DataTableActionEvent,
|
DataTableActionEvent,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
DataTablePageEvent,
|
DataTableRecord
|
||||||
DataTableQuery,
|
|
||||||
DataTableRecord,
|
|
||||||
DataTableSortEvent
|
|
||||||
} from '../../../../../shared/components/data-table/data-table.types';
|
} 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 { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||||
|
import { LanguageFormModalComponent } from '../../components/language-form-modal/language-form-modal';
|
||||||
|
|
||||||
interface LanguageTableRow extends DataTableRecord {
|
interface LanguageTableRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,314 +31,125 @@ interface LanguageTableRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'language-list',
|
selector: 'language-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
|
imports: [DataTable, ConfirmDialog, LanguageFormModalComponent],
|
||||||
|
providers: [DataTableStore],
|
||||||
templateUrl: './language-list.html',
|
templateUrl: './language-list.html',
|
||||||
styleUrl: './language-list.scss'
|
styleUrl: './language-list.scss'
|
||||||
})
|
})
|
||||||
export class LanguageList {
|
export class LanguageList implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly languageApi = inject(LanguageService);
|
private readonly languageApi = inject(LanguageService);
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
|
||||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
private readonly toastr = inject(ToastrService);
|
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 statusChangingId = signal<string | null>(null);
|
||||||
readonly showModal = signal(false);
|
readonly deletingId = signal<string | null>(null);
|
||||||
readonly modalMode = signal<LanguageModalMode>('create');
|
readonly pendingDeleteLanguage = signal<LanguageTableRow | null>(null);
|
||||||
readonly selectedLanguageId = signal<string | null>(null);
|
|
||||||
readonly selectedLanguage = signal<LanguageDto | null>(null);
|
|
||||||
readonly submitAttempted = signal(false);
|
|
||||||
readonly pendingDeleteLanguageId = signal<string | null>(null);
|
|
||||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
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>[]>([
|
readonly columns = signal<DataTableColumn<LanguageTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
{ 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: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left' },
|
||||||
{ key: 'code', label: 'Language Code', header: 'Language Code', sortable: true },
|
{ key: 'code', label: 'Language Code', header: 'Language Code', sortable: true },
|
||||||
{ key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true },
|
{ key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true },
|
||||||
{
|
{
|
||||||
key: 'isRightToLeft', label: 'Direction', header: 'Direction', sortable: true,
|
key: 'isRightToLeft', label: 'RTL', header: 'RTL', sortable: false, badge: true,
|
||||||
formatter: value => value ? 'RTL' : 'LTR'
|
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,
|
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
|
||||||
badgeClass: value => value === true
|
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
|
||||||
? 'badge bg-success/10 text-success'
|
|
||||||
: 'badge bg-danger/10 text-danger',
|
|
||||||
formatter: value => value ? 'Active' : 'Inactive'
|
formatter: value => value ? 'Active' : 'Inactive'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly actions = signal<DataTableAction<LanguageTableRow>[]>([
|
readonly actions = signal<DataTableAction<LanguageTableRow>[]>([
|
||||||
|
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||||
{
|
{
|
||||||
type: 'edit',
|
type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning',
|
||||||
label: 'Edit',
|
visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-edit',
|
|
||||||
className: 'text-primary'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'delete',
|
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
|
||||||
label: 'Delete',
|
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-trash',
|
|
||||||
className: 'text-danger',
|
|
||||||
visible: row => row.isActive,
|
|
||||||
disabled: row => this.statusChangingId() === row.id
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'activate',
|
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||||
label: 'Activate',
|
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-check',
|
|
||||||
className: 'text-success',
|
|
||||||
visible: row => !row.isActive,
|
|
||||||
disabled: row => this.statusChangingId() === 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 {
|
ngOnInit(): void {
|
||||||
this.loadLanguages(this.queryState.getQuery());
|
this.tableStore.initialize({
|
||||||
}
|
fetcher: query => this.languageApi.getDataTable(query)
|
||||||
|
});
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddLanguage(): void {
|
onAddLanguage(): void {
|
||||||
this.modalMode.set('create');
|
this.tableStore.openCreateModal();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openEditLanguage(id: string): void {
|
onActionClick(event: DataTableActionEvent<LanguageTableRow>): void {
|
||||||
this.modalMode.set('edit');
|
if (event.action.type === 'view') this.tableStore.openViewModal(event.row);
|
||||||
this.selectedLanguageId.set(id);
|
if (event.action.type === 'edit') this.tableStore.openEditModal(event.row);
|
||||||
this.selectedLanguage.set(null);
|
if (event.action.type === 'delete') this.requestDeleteLanguage(event.row);
|
||||||
this.submitAttempted.set(false);
|
if (event.action.type === 'activate') this.changeLanguageStatus(event.row, true);
|
||||||
this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false });
|
if (event.action.type === 'deactivate') this.changeLanguageStatus(event.row, 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)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModal(): void {
|
onDeleteConfirmed(): void {
|
||||||
if (this.saving()) return;
|
const lang = this.pendingDeleteLanguage();
|
||||||
this.showModal.set(false);
|
if (!lang) return;
|
||||||
this.selectedLanguageId.set(null);
|
this.pendingDeleteLanguage.set(null);
|
||||||
this.selectedLanguage.set(null);
|
this.deletingId.set(lang.id);
|
||||||
this.submitAttempted.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveLanguage(): void {
|
this.languageApi.delete(lang.id).pipe(
|
||||||
if (this.languageForm.invalid) {
|
finalize(() => this.deletingId.set(null)),
|
||||||
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)),
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastr.success(
|
this.toastr.success('Language deleted successfully.');
|
||||||
this.modalMode() === 'create'
|
this.tableStore.refresh();
|
||||||
? 'Language saved successfully.'
|
|
||||||
: 'Language updated successfully.'
|
|
||||||
);
|
|
||||||
this.finishSave();
|
|
||||||
},
|
},
|
||||||
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 {
|
onDeleteCancelled(): void {
|
||||||
const value = this.languageForm.getRawValue();
|
this.pendingDeleteLanguage.set(null);
|
||||||
return {
|
|
||||||
code: this.normalizeCode(value.code),
|
|
||||||
name: value.name.trim(),
|
|
||||||
nativeName: value.nativeName.trim(),
|
|
||||||
isRightToLeft: value.isRightToLeft
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeCode(code: string): string {
|
private requestDeleteLanguage(language: LanguageTableRow): void {
|
||||||
return code.trim().split('-').map((part, index) =>
|
this.pendingDeleteLanguage.set(language);
|
||||||
index === 0 ? part.toLowerCase() : part.toUpperCase()
|
this.deleteConfirmDialog()?.open();
|
||||||
).join('-');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private changeLanguageStatus(id: string, isActive: boolean): void {
|
private changeLanguageStatus(language: LanguageTableRow, activate: boolean): void {
|
||||||
if (this.statusChangingId()) return;
|
this.statusChangingId.set(language.id);
|
||||||
this.statusChangingId.set(id);
|
|
||||||
this.languageApi.getById(id).pipe(
|
this.languageApi.updateStatus(language.id, { isActive: activate }).pipe(
|
||||||
switchMap(language => this.languageApi.update(id, {
|
|
||||||
code: language.code,
|
|
||||||
name: language.name,
|
|
||||||
nativeName: language.nativeName,
|
|
||||||
isRightToLeft: language.isRightToLeft,
|
|
||||||
isActive
|
|
||||||
})),
|
|
||||||
finalize(() => this.statusChangingId.set(null)),
|
finalize(() => this.statusChangingId.set(null)),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastr.success(isActive
|
this.toastr.success(`Language ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||||
? 'Language activated successfully.'
|
this.tableStore.refresh();
|
||||||
: 'Language deleted successfully.');
|
|
||||||
this.loadLanguages(this.queryState.getQuery());
|
|
||||||
},
|
},
|
||||||
error: (error: HttpErrorResponse) => {
|
error: (err) => {
|
||||||
if (error.status === 404) this.toastr.error('The language is no longer available.');
|
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} language.`;
|
||||||
|
this.toastr.error(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSaveError(error: HttpErrorResponse): void {
|
|
||||||
if (error.status === 409) {
|
|
||||||
this.toastr.error('A language with this code already exists.', 'Duplicate language code');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private finishSave(): void {
|
|
||||||
this.showModal.set(false);
|
|
||||||
this.selectedLanguageId.set(null);
|
|
||||||
this.selectedLanguage.set(null);
|
|
||||||
this.submitAttempted.set(false);
|
|
||||||
this.loadLanguages(this.queryState.getQuery());
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetFormState(): void {
|
|
||||||
this.languageForm.markAsPristine();
|
|
||||||
this.languageForm.markAsUntouched();
|
|
||||||
this.languageForm.updateValueAndValidity();
|
|
||||||
}
|
|
||||||
|
|
||||||
private focusFirstInvalidControl(): void {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
|
||||||
'modal [data-form-control][aria-invalid="true"]'
|
|
||||||
);
|
|
||||||
control?.focus();
|
|
||||||
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
<modal
|
||||||
|
[open]="open()"
|
||||||
|
[title]="modalTitle()"
|
||||||
|
size="md"
|
||||||
|
[submitAction]="mode() === 'create' ? 'save' : 'update'"
|
||||||
|
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
|
||||||
|
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
|
||||||
|
[loading]="saving() || modalLoading()"
|
||||||
|
[showSubmitButton]="!isViewMode()"
|
||||||
|
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||||
|
(closed)="closeModal()"
|
||||||
|
(submitted)="saveState()"
|
||||||
|
>
|
||||||
|
@if (modalLoading()) {
|
||||||
|
<div class="flex min-h-32 items-center justify-center">
|
||||||
|
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
|
||||||
|
<span class="ms-2">Loading state...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="countryId"
|
||||||
|
inputId="state-country-id"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Select country"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[searchFn]="countrySearchFn"
|
||||||
|
[valueWith]="countryValueFn"
|
||||||
|
[displayWith]="countryDisplayFn"
|
||||||
|
[selectedItem]="selectedFormCountry()"
|
||||||
|
(itemSelected)="selectedFormCountry.set($event)"
|
||||||
|
[validationMessages]="{ required: 'Country is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="name"
|
||||||
|
inputId="state-name"
|
||||||
|
variant="floating"
|
||||||
|
label="State Name"
|
||||||
|
placeholder="Name"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="150"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'State name is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="code"
|
||||||
|
inputId="state-code"
|
||||||
|
variant="floating"
|
||||||
|
label="State Code"
|
||||||
|
placeholder="Code"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="16"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{ required: 'State code is required.' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</modal>
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
||||||
|
import {
|
||||||
|
CreateStateRequest,
|
||||||
|
StateDto,
|
||||||
|
StateModalMode,
|
||||||
|
UpdateStateRequest
|
||||||
|
} from '../../models/state.model';
|
||||||
|
import { StateService } from '../../data-access/state.service';
|
||||||
|
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||||
|
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
|
import {
|
||||||
|
AutocompleteDisplayFn,
|
||||||
|
AutocompleteSearchFn,
|
||||||
|
AutocompleteValueFn
|
||||||
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
|
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-state-form-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete],
|
||||||
|
templateUrl: './state-form-modal.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class StateFormModalComponent {
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
|
private readonly stateApi = inject(StateService);
|
||||||
|
private readonly countryApi = inject(CountryService);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
|
readonly open = input<boolean>(false);
|
||||||
|
readonly mode = input<StateModalMode>('create');
|
||||||
|
readonly stateId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly saved = output<void>();
|
||||||
|
readonly closed = output<void>();
|
||||||
|
|
||||||
|
readonly modalLoading = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly submitAttempted = signal(false);
|
||||||
|
readonly selectedState = signal<StateDto | null>(null);
|
||||||
|
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||||
|
|
||||||
|
readonly stateForm = this.formBuilder.nonNullable.group({
|
||||||
|
countryId: ['', Validators.required],
|
||||||
|
name: ['', [Validators.required, Validators.maxLength(150)]],
|
||||||
|
code: ['', [Validators.required, Validators.maxLength(16), Validators.pattern(/^[A-Za-z0-9_-]+$/)]]
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||||
|
readonly modalTitle = computed(() => {
|
||||||
|
switch (this.mode()) {
|
||||||
|
case 'create': return 'Add State';
|
||||||
|
case 'edit': return 'Edit State';
|
||||||
|
case 'view': return 'View State';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly countrySearchFn: AutocompleteSearchFn<CountryLookupDto> = (term, page) =>
|
||||||
|
this.countryApi.autocomplete(term, page).pipe(catchError(() => of([])));
|
||||||
|
readonly countryValueFn: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||||
|
readonly countryDisplayFn: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.open()) {
|
||||||
|
this.prepareModal(this.stateId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareModal(id: string | null): void {
|
||||||
|
this.submitAttempted.set(false);
|
||||||
|
this.stateForm.reset({ countryId: '', name: '', code: '' });
|
||||||
|
this.selectedFormCountry.set(null);
|
||||||
|
|
||||||
|
if (!id || this.mode() === 'create') {
|
||||||
|
this.selectedState.set(null);
|
||||||
|
this.modalLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modalLoading.set(true);
|
||||||
|
this.stateApi.getStateById(id).pipe(
|
||||||
|
switchMap(state => {
|
||||||
|
this.selectedState.set(state);
|
||||||
|
if (!state.countryId) return of({ state, country: null });
|
||||||
|
return this.countryApi.getCountryById(state.countryId).pipe(
|
||||||
|
map(country => ({ state, country })),
|
||||||
|
catchError(() => of({ state, country: null }))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
finalize(() => this.modalLoading.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: ({ state, country }) => {
|
||||||
|
if (country) {
|
||||||
|
this.selectedFormCountry.set({ id: country.id, name: country.name, iso2: country.iso2 });
|
||||||
|
}
|
||||||
|
this.stateForm.patchValue({
|
||||||
|
countryId: state.countryId,
|
||||||
|
name: state.name,
|
||||||
|
code: state.code ?? ''
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.toastr.error('Unable to load state details.');
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState(): void {
|
||||||
|
if (this.isViewMode()) {
|
||||||
|
this.closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitAttempted.set(true);
|
||||||
|
if (this.stateForm.invalid || this.saving()) return;
|
||||||
|
|
||||||
|
this.saving.set(true);
|
||||||
|
if (this.mode() === 'create') {
|
||||||
|
const request: CreateStateRequest = {
|
||||||
|
countryId: this.stateForm.controls.countryId.value,
|
||||||
|
name: this.stateForm.controls.name.value.trim(),
|
||||||
|
code: this.stateForm.controls.code.value.trim().toUpperCase()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.stateApi.createState(request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('State created successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'create')
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const id = this.stateId();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const request: UpdateStateRequest = {
|
||||||
|
countryId: this.stateForm.controls.countryId.value || null,
|
||||||
|
name: this.stateForm.controls.name.value.trim(),
|
||||||
|
code: this.stateForm.controls.code.value.trim().toUpperCase(),
|
||||||
|
isActive: this.selectedState()?.isActive ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.stateApi.updateState(id, request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('State updated successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: err => this.handleSaveError(err, 'update')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
if (this.saving()) return;
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.toastr.error('A state with this code already exists in this country.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toastr.error(`Unable to ${action} state. Please try again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
CreateStateRequest,
|
CreateStateRequest,
|
||||||
StateDto,
|
StateDto,
|
||||||
StateLookupDto,
|
StateLookupDto,
|
||||||
UpdateStateRequest
|
UpdateStateRequest,
|
||||||
|
UpdateStateStatusRequest
|
||||||
} from "../models/state.model";
|
} from "../models/state.model";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -30,6 +31,14 @@ export class StateService {
|
|||||||
return this.http.put<StateDto>(STATE_ENDPOINTS.update(id), request);
|
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> {
|
getStateById(id: string): Observable<StateDto> {
|
||||||
return this.http.get<StateDto>(STATE_ENDPOINTS.getById(id));
|
return this.http.get<StateDto>(STATE_ENDPOINTS.getById(id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,15 @@ export interface CreateStateRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStateRequest {
|
export interface UpdateStateRequest {
|
||||||
countryId: null;
|
countryId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StateModalMode = 'create' | 'edit';
|
export interface UpdateStateStatusRequest {
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateModalMode = 'create' | 'edit' | 'view';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<div class="grid grid-cols-12 gap-6">
|
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||||
<div class="xl:col-span-12 col-span-12">
|
<form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||||
<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">
|
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||||
<app-autocomplete
|
<app-autocomplete
|
||||||
formControlName="countryId"
|
formControlName="countryId"
|
||||||
@@ -20,7 +18,7 @@
|
|||||||
[clearable]="true"
|
[clearable]="true"
|
||||||
[hideValidation]="true"
|
[hideValidation]="true"
|
||||||
wrapperClass="!mb-0 w-full"
|
wrapperClass="!mb-0 w-full"
|
||||||
(itemSelected)="onCountryLookupSelected($event)"
|
(itemSelected)="onFilterCountrySelected($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
||||||
@@ -32,21 +30,32 @@
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
||||||
(buttonClicked)="applyCountryFilter()"
|
(buttonClicked)="onApplyFilter()"
|
||||||
></app-button>
|
></app-button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</app-filter-card>
|
</app-filter-card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
|
<app-data-table
|
||||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
[columns]="columns()"
|
||||||
tableTitle="States" buttonTitle="Add" [showSearch]="true"
|
[rows]="tableStore.rows()"
|
||||||
[showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
|
[actions]="actions()"
|
||||||
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
[totalRecords]="tableStore.totalRecords()"
|
||||||
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
|
[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
|
<app-confirm-dialog
|
||||||
title="Delete State"
|
title="Delete State"
|
||||||
@@ -57,54 +66,10 @@
|
|||||||
(cancelled)="onDeleteCancelled()"
|
(cancelled)="onDeleteCancelled()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modal [open]="showStateModal()" [title]="stateModalTitle()" size="md" [submitAction]="stateSubmitAction()"
|
<app-state-form-modal
|
||||||
[submitLabel]="stateSubmitLabel()" [loadingLabel]="stateLoadingLabel()" [loading]="saving()"
|
[open]="tableStore.showModal()"
|
||||||
(closed)="closeStateModal()" (submitted)="saveState()">
|
[mode]="tableStore.modalMode()"
|
||||||
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
|
[stateId]="tableStore.selectedItem()?.id ?? null"
|
||||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
(saved)="tableStore.refresh(); tableStore.closeModal()"
|
||||||
<div class="col-span-12 md:col-span-6">
|
(closed)="tableStore.closeModal()"
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import {
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
FormBuilder,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
Validators
|
|
||||||
} from '@angular/forms';
|
|
||||||
import { ToastrService } from 'ngx-toastr';
|
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 {
|
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
||||||
CountryLookupDto
|
import { StateDto, UpdateStateRequest } from '../../models/state.model';
|
||||||
} from '../../../countries/public-api';
|
|
||||||
import {
|
|
||||||
CreateStateRequest,
|
|
||||||
StateDto,
|
|
||||||
StateModalMode,
|
|
||||||
UpdateStateRequest
|
|
||||||
} from '../../models/state.model';
|
|
||||||
import { CountryService } from '../../../countries/public-api';
|
|
||||||
import { StateService } from '../../data-access/state.service';
|
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 {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
DataTableActionEvent,
|
DataTableActionEvent,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
DataTablePageEvent,
|
DataTableRecord
|
||||||
DataTableQuery,
|
|
||||||
DataTableRecord,
|
|
||||||
DataTableSortEvent
|
|
||||||
} from '../../../../../shared/components/data-table/data-table.types';
|
} 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 { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
import {
|
import {
|
||||||
AutocompleteDisplayFn,
|
AutocompleteDisplayFn,
|
||||||
@@ -37,11 +23,10 @@ import {
|
|||||||
AutocompleteSearchFn,
|
AutocompleteSearchFn,
|
||||||
AutocompleteValueFn
|
AutocompleteValueFn
|
||||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
} 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 { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||||
import { Button as AppButton } from '../../../../../shared/components/button/button';
|
import { Button as AppButton } from '../../../../../shared/components/button/button';
|
||||||
|
import { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal';
|
||||||
|
|
||||||
interface StateTableRow extends DataTableRecord {
|
interface StateTableRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -57,515 +42,165 @@ interface StateTableRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'state-list',
|
selector: 'state-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard, AppButton],
|
imports: [
|
||||||
|
DataTable,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Autocomplete,
|
||||||
|
ConfirmDialog,
|
||||||
|
FilterCard,
|
||||||
|
AppButton,
|
||||||
|
StateFormModalComponent
|
||||||
|
],
|
||||||
|
providers: [DataTableStore],
|
||||||
templateUrl: './state-list.html',
|
templateUrl: './state-list.html',
|
||||||
styleUrl: './state-list.scss',
|
styleUrl: './state-list.scss',
|
||||||
})
|
})
|
||||||
export class StateList {
|
export class StateList implements OnInit {
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly stateApi = inject(StateService);
|
private readonly stateApi = inject(StateService);
|
||||||
private readonly countryApi = inject(CountryService);
|
private readonly countryApi = inject(CountryService);
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
private readonly toastr = inject(ToastrService);
|
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 selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
||||||
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
readonly statusChangingId = signal<string | null>(null);
|
||||||
readonly selectedCountryId = signal<string | null>(null);
|
readonly deletingId = signal<string | null>(null);
|
||||||
readonly appliedCountryId = signal<string | null>(null);
|
readonly pendingDeleteState = signal<StateTableRow | 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 deleteConfirmDialog = viewChild(ConfirmDialog);
|
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||||
|
|
||||||
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
||||||
countryId: ['']
|
countryId: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> =
|
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> = (term, limit) =>
|
||||||
(term, limit) => this.countryApi.autocomplete(term, limit);
|
this.countryApi.autocomplete(term, limit);
|
||||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> = value =>
|
||||||
value => this.countryApi.getCountryById(value).pipe(
|
this.countryApi.getCountryById(value).pipe(
|
||||||
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
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>[]>([
|
readonly columns = signal<DataTableColumn<StateTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' },
|
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
||||||
{ key: 'name', header: 'Name', label: 'Name', sortable: true, align: 'left' },
|
{ key: 'name', label: 'State Name', header: 'State Name', sortable: true, headerAlign: 'center', align: 'left' },
|
||||||
{ key: 'code', header: 'Code', label: 'Code', sortable: true },
|
|
||||||
{
|
{
|
||||||
key: 'isActive',
|
key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true,
|
||||||
label: 'Status',
|
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary',
|
||||||
header: 'Status',
|
formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—'
|
||||||
sortable: true,
|
},
|
||||||
badge: true,
|
{
|
||||||
badgeClass: value =>
|
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
|
||||||
value === true
|
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
|
||||||
? 'badge bg-success/10 text-success'
|
|
||||||
: 'badge bg-danger/10 text-danger',
|
|
||||||
width: '100px',
|
|
||||||
formatter: value => value ? 'Active' : 'Inactive'
|
formatter: value => value ? 'Active' : 'Inactive'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly actions = signal<DataTableAction<StateTableRow>[]>([
|
readonly actions = signal<DataTableAction<StateTableRow>[]>([
|
||||||
|
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||||
{
|
{
|
||||||
type: 'edit',
|
type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning',
|
||||||
label: 'Edit',
|
visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-edit',
|
|
||||||
className: 'text-primary'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'delete',
|
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
|
||||||
label: 'Delete',
|
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-trash',
|
|
||||||
className: 'text-danger',
|
|
||||||
visible: row => row.isActive
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'activate',
|
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
|
||||||
label: 'Activate',
|
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
icon: 'ti ti-check',
|
|
||||||
className: 'text-success',
|
|
||||||
visible: row => !row.isActive
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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 {
|
ngOnInit(): void {
|
||||||
this.loadStates(this.queryState.getQuery());
|
this.tableStore.initialize({
|
||||||
}
|
fetcher: query => {
|
||||||
|
|
||||||
loadStates(query: DataTableQuery): void {
|
|
||||||
this.stateQueryRequests$.next(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCountryLookupSelected(country: CountryLookupDto): void {
|
|
||||||
this.selectedCountryLookup.set(country);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyCountryFilter(): void {
|
|
||||||
const countryId = this.countryFilterForm.controls.countryId.value || null;
|
const countryId = this.countryFilterForm.controls.countryId.value || null;
|
||||||
this.appliedCountryId.set(countryId);
|
return this.stateApi.getStateDataTable(query, countryId);
|
||||||
|
}
|
||||||
this.loadStates(this.queryState.setPage({
|
});
|
||||||
pageIndex: 1,
|
|
||||||
pageSize: this.queryState.pageSize()
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onFormCountrySelected(country: CountryLookupDto): void {
|
onFilterCountrySelected(country: CountryLookupDto | null): void {
|
||||||
this.selectedFormCountry.set(country);
|
this.selectedCountryLookup.set(country);
|
||||||
|
this.countryFilterForm.controls.countryId.setValue(country ? country.id : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
onFormCountryCleared(): void {
|
onApplyFilter(): void {
|
||||||
this.selectedFormCountry.set(null);
|
this.tableStore.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearch(value: string): void {
|
onResetFilter(): void {
|
||||||
const query = this.queryState.setSearch(value.trim());
|
this.countryFilterForm.reset({ countryId: '' });
|
||||||
this.loadStates(query);
|
this.selectedCountryLookup.set(null);
|
||||||
|
this.tableStore.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
onPageChange(event: DataTablePageEvent): void {
|
onAddState(): void {
|
||||||
const query = this.queryState.setPage(event);
|
this.tableStore.openCreateModal();
|
||||||
this.loadStates(query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSortChange(event: DataTableSortEvent): void {
|
onActionClick(event: DataTableActionEvent<StateTableRow>): void {
|
||||||
const query = this.queryState.setSort(event);
|
if (event.action.type === 'view') this.tableStore.openViewModal(event.row);
|
||||||
this.loadStates(query);
|
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);
|
||||||
onRefresh(): void {
|
if (event.action.type === 'deactivate') this.changeStateStatus(event.row, false);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDeleteConfirmed(): void {
|
onDeleteConfirmed(): void {
|
||||||
const state = this.pendingDeleteState();
|
const state = this.pendingDeleteState();
|
||||||
|
if (!state) return;
|
||||||
if (!state) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingDeleteState.set(null);
|
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 {
|
onDeleteCancelled(): void {
|
||||||
this.pendingDeleteState.set(null);
|
this.pendingDeleteState.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
onActionClick(event: DataTableActionEvent<StateTableRow>): void {
|
private requestDeleteState(state: 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 {
|
|
||||||
this.pendingDeleteState.set(state);
|
this.pendingDeleteState.set(state);
|
||||||
this.deleteConfirmDialog()?.open();
|
this.deleteConfirmDialog()?.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
private deleteState(state: StateDto): void {
|
private changeStateStatus(state: StateTableRow, activate: boolean): void {
|
||||||
this.updateStateStatus(state, false);
|
this.statusChangingId.set(state.id);
|
||||||
}
|
|
||||||
|
|
||||||
private activateState(state: StateDto): void {
|
this.stateApi.updateStatus(state.id, { isActive: activate }).pipe(
|
||||||
this.updateStateStatus(state, true);
|
finalize(() => this.statusChangingId.set(null)),
|
||||||
}
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
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: () => {
|
next: () => {
|
||||||
this.toastr.success(
|
this.toastr.success(`State ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||||
isActive
|
this.tableStore.refresh();
|
||||||
? 'State activated successfully.'
|
},
|
||||||
: 'State deactivated successfully.'
|
error: (err) => {
|
||||||
);
|
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} state.`;
|
||||||
this.loadStates(this.queryState.getQuery());
|
this.toastr.error(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private toStateDto(row: StateTableRow): StateDto {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
countryId: row.countryId,
|
|
||||||
name: row.name,
|
|
||||||
code: row.code,
|
|
||||||
isActive: row.isActive,
|
|
||||||
createdOn: row.createdOn,
|
|
||||||
modifiedOn: row.modifiedOn
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
<modal
|
||||||
|
[open]="open()"
|
||||||
|
[title]="modalTitle()"
|
||||||
|
size="md"
|
||||||
|
[submitAction]="submitAction()"
|
||||||
|
[submitLabel]="submitLabel()"
|
||||||
|
[loadingLabel]="loadingLabel()"
|
||||||
|
[loading]="saving() || modalLoading()"
|
||||||
|
[showSubmitButton]="!isViewMode()"
|
||||||
|
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
|
||||||
|
(closed)="closeModal()"
|
||||||
|
(submitted)="saveTimezone()"
|
||||||
|
>
|
||||||
|
@if (modalLoading()) {
|
||||||
|
<div class="flex min-h-32 items-center justify-center" role="status" aria-live="polite">
|
||||||
|
<span class="ti ti-loader-2 animate-spin text-2xl text-primary" aria-hidden="true"></span>
|
||||||
|
<span class="ms-2">Loading timezone...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="timezoneForm" (ngSubmit)="saveTimezone()" autocomplete="off">
|
||||||
|
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="ianaId"
|
||||||
|
inputId="timezone-iana-id"
|
||||||
|
variant="floating"
|
||||||
|
label="IANA Timezone ID"
|
||||||
|
placeholder="Id"
|
||||||
|
help="e.g.: Asia/Kolkata"
|
||||||
|
autocomplete="off"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="64"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{
|
||||||
|
required: 'Timezone ID is required.',
|
||||||
|
maxlength: 'Timezone ID must be 64 characters or fewer.',
|
||||||
|
pattern: 'Use a valid IANA timezone ID, for example Asia/Kolkata or America/New_York.'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="displayName"
|
||||||
|
inputId="timezone-display-name"
|
||||||
|
variant="floating"
|
||||||
|
label="Display Name"
|
||||||
|
placeholder="Name"
|
||||||
|
help="e.g.: India Standard Time"
|
||||||
|
autocomplete="off"
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[maxLength]="128"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{
|
||||||
|
required: 'Timezone display name is required.',
|
||||||
|
maxlength: 'Timezone display name must be 128 characters or fewer.',
|
||||||
|
pattern: 'Timezone display name cannot contain only whitespace.'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<app-form-input
|
||||||
|
formControlName="utcOffsetMinutes"
|
||||||
|
inputId="timezone-utc-offset"
|
||||||
|
variant="floating"
|
||||||
|
label="UTC Offset (minutes)"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Minutes"
|
||||||
|
help="Enter an offset from -720 (-12:00) to 840 (+14:00)."
|
||||||
|
[required]="true"
|
||||||
|
[readonly]="isViewMode()"
|
||||||
|
[min]="-720"
|
||||||
|
[max]="840"
|
||||||
|
[step]="1"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
|
[validationMessages]="{
|
||||||
|
required: 'UTC offset is required.',
|
||||||
|
min: 'UTC offset must be between -12:00 and +14:00.',
|
||||||
|
max: 'UTC offset must be between -12:00 and +14:00.'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (isViewMode() && selectedTimezone(); as timezone) {
|
||||||
|
<div class="col-span-12 md:col-span-6 pt-1">
|
||||||
|
<span class="block text-sm text-textmuted">Formatted UTC Offset</span>
|
||||||
|
<span class="mt-2 block font-semibold">{{ formatUtcOffset(timezone.utcOffsetMinutes) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<span class="block text-sm text-textmuted">Status</span>
|
||||||
|
<span class="badge mt-2" [class.bg-success]="timezone.isActive" [class.bg-danger]="!timezone.isActive">
|
||||||
|
{{ timezone.isActive ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</modal>
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateTimezoneRequest,
|
||||||
|
TimezoneDto,
|
||||||
|
TimezoneModalMode,
|
||||||
|
UpdateTimezoneRequest
|
||||||
|
} from '../../models/timezone.model';
|
||||||
|
import { TimezoneService } from '../../data-access/timezone.service';
|
||||||
|
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||||
|
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-timezone-form-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [Modal, ReactiveFormsModule, FormInput],
|
||||||
|
templateUrl: './timezone-form-modal.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class TimezoneFormModalComponent {
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
|
private readonly timezoneApi = inject(TimezoneService);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
|
||||||
|
readonly open = input<boolean>(false);
|
||||||
|
readonly mode = input<TimezoneModalMode>('create');
|
||||||
|
readonly timezoneId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly saved = output<void>();
|
||||||
|
readonly closed = output<void>();
|
||||||
|
|
||||||
|
readonly modalLoading = signal(false);
|
||||||
|
readonly saving = signal(false);
|
||||||
|
readonly submitAttempted = signal(false);
|
||||||
|
readonly selectedTimezone = signal<TimezoneDto | null>(null);
|
||||||
|
|
||||||
|
readonly timezoneForm = this.formBuilder.nonNullable.group({
|
||||||
|
ianaId: ['', [
|
||||||
|
Validators.required,
|
||||||
|
Validators.maxLength(64),
|
||||||
|
Validators.pattern(/^[A-Za-z]+(?:[._+-]?[A-Za-z0-9]+)*(?:\/[A-Za-z0-9._+-]+)+$/)
|
||||||
|
]],
|
||||||
|
displayName: ['', [Validators.required, Validators.maxLength(128), Validators.pattern(/.*\S.*/)]],
|
||||||
|
utcOffsetMinutes: [0, [Validators.required, Validators.min(-720), Validators.max(840)]]
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isViewMode = computed(() => this.mode() === 'view');
|
||||||
|
readonly modalTitle = computed(() => {
|
||||||
|
switch (this.mode()) {
|
||||||
|
case 'create': return 'Add Timezone';
|
||||||
|
case 'edit': return 'Edit Timezone';
|
||||||
|
case 'view': return 'View Timezone';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
readonly submitLabel = computed(() => this.mode() === 'create' ? 'Save' : 'Update');
|
||||||
|
readonly loadingLabel = computed(() => this.mode() === 'create' ? 'Saving...' : 'Updating...');
|
||||||
|
readonly submitAction = computed<'save' | 'update'>(() => this.mode() === 'create' ? 'save' : 'update');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.open()) {
|
||||||
|
this.prepareModal(this.timezoneId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareModal(id: string | null): void {
|
||||||
|
this.submitAttempted.set(false);
|
||||||
|
this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 });
|
||||||
|
|
||||||
|
if (!id || this.mode() === 'create') {
|
||||||
|
this.selectedTimezone.set(null);
|
||||||
|
this.modalLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modalLoading.set(true);
|
||||||
|
this.timezoneApi.getById(id).pipe(
|
||||||
|
finalize(() => this.modalLoading.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: timezone => {
|
||||||
|
this.selectedTimezone.set(timezone);
|
||||||
|
this.timezoneForm.patchValue({
|
||||||
|
ianaId: timezone.ianaId,
|
||||||
|
displayName: timezone.displayName,
|
||||||
|
utcOffsetMinutes: timezone.utcOffsetMinutes
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.toastr.error('Unable to load timezone details.');
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimezone(): void {
|
||||||
|
if (this.isViewMode()) {
|
||||||
|
this.closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitAttempted.set(true);
|
||||||
|
if (this.timezoneForm.invalid || this.saving()) return;
|
||||||
|
|
||||||
|
this.saving.set(true);
|
||||||
|
if (this.mode() === 'create') {
|
||||||
|
const request: CreateTimezoneRequest = {
|
||||||
|
ianaId: this.timezoneForm.controls.ianaId.value.trim(),
|
||||||
|
displayName: this.timezoneForm.controls.displayName.value.trim(),
|
||||||
|
utcOffsetMinutes: this.timezoneForm.controls.utcOffsetMinutes.value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.timezoneApi.create(request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('Timezone created successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: (error: HttpErrorResponse) => this.handleSaveError(error, 'create')
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const id = this.timezoneId();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const request: UpdateTimezoneRequest = {
|
||||||
|
ianaId: this.timezoneForm.controls.ianaId.value.trim(),
|
||||||
|
displayName: this.timezoneForm.controls.displayName.value.trim(),
|
||||||
|
utcOffsetMinutes: this.timezoneForm.controls.utcOffsetMinutes.value,
|
||||||
|
isActive: this.selectedTimezone()?.isActive ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.timezoneApi.update(id, request).pipe(
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving.set(false);
|
||||||
|
this.toastr.success('Timezone updated successfully.');
|
||||||
|
this.saved.emit();
|
||||||
|
this.closed.emit();
|
||||||
|
},
|
||||||
|
error: (error: HttpErrorResponse) => this.handleSaveError(error, 'update')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
if (this.saving()) return;
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatUtcOffset(totalMinutes: number): string {
|
||||||
|
const isNegative = totalMinutes < 0;
|
||||||
|
const absMinutes = Math.abs(totalMinutes);
|
||||||
|
const hours = Math.floor(absMinutes / 60);
|
||||||
|
const minutes = absMinutes % 60;
|
||||||
|
const formattedHours = String(hours).padStart(2, '0');
|
||||||
|
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||||
|
const prefix = isNegative ? '-' : '+';
|
||||||
|
return `UTC${prefix}${formattedHours}:${formattedMinutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
|
||||||
|
if (error.status === 409) {
|
||||||
|
this.toastr.error('A timezone with this IANA ID already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toastr.error(`Unable to ${action} timezone. Please try again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,9 @@ export const TIMEZONE_ENDPOINTS = {
|
|||||||
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`),
|
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`),
|
||||||
update: (id: string) =>
|
update: (id: string) =>
|
||||||
buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`),
|
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')
|
autocomplete: buildApiUrl('masterAdmin', '/v1/timezones/autocomplete')
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
CreateTimezoneRequest,
|
CreateTimezoneRequest,
|
||||||
TimezoneDto,
|
TimezoneDto,
|
||||||
TimezoneLookupDto,
|
TimezoneLookupDto,
|
||||||
UpdateTimezoneRequest
|
UpdateTimezoneRequest,
|
||||||
|
UpdateTimezoneStatusRequest
|
||||||
} from '../models/timezone.model';
|
} from '../models/timezone.model';
|
||||||
import {
|
import {
|
||||||
DataTableQuery,
|
DataTableQuery,
|
||||||
@@ -34,6 +35,14 @@ export class TimezoneService {
|
|||||||
return this.http.put<TimezoneDto>(TIMEZONE_ENDPOINTS.update(id), request);
|
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[]> {
|
autocomplete(term: string | null, limit = 10): Observable<readonly TimezoneLookupDto[]> {
|
||||||
const normalizedTerm = term?.trim() || null;
|
const normalizedTerm = term?.trim() || null;
|
||||||
let params = new HttpParams().set('limit', limit);
|
let params = new HttpParams().set('limit', limit);
|
||||||
|
|||||||
@@ -24,4 +24,9 @@ export interface UpdateTimezoneRequest extends CreateTimezoneRequest {
|
|||||||
readonly isActive: boolean;
|
readonly isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateTimezoneStatusRequest {
|
||||||
|
readonly isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type TimezoneModalMode = 'create' | 'edit' | 'view';
|
export type TimezoneModalMode = 'create' | 'edit' | 'view';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<app-data-table
|
<app-data-table
|
||||||
[columns]="columns()"
|
[columns]="columns()"
|
||||||
[rows]="timezones()"
|
[rows]="tableStore.rows()"
|
||||||
[actions]="actions()"
|
[actions]="actions()"
|
||||||
[totalRecords]="totalRecords()"
|
[totalRecords]="tableStore.totalRecords()"
|
||||||
[pageIndex]="queryState.pageIndex()"
|
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
[pageSize]="queryState.pageSize()"
|
[pageSize]="tableStore.queryState.pageSize()"
|
||||||
tableTitle="Timezones"
|
tableTitle="Timezones"
|
||||||
buttonTitle="Add"
|
buttonTitle="Add"
|
||||||
[showSearch]="true"
|
[showSearch]="true"
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
[searchDebounceTime]="300"
|
[searchDebounceTime]="300"
|
||||||
toolTip="Add Timezone"
|
toolTip="Add Timezone"
|
||||||
(addClicked)="onAddTimezone()"
|
(addClicked)="onAddTimezone()"
|
||||||
(searchChanged)="onSearch($event)"
|
(searchChanged)="tableStore.onSearch($event)"
|
||||||
(pageChanged)="onPageChange($event)"
|
(pageChanged)="tableStore.onPageChange($event)"
|
||||||
(sortChanged)="onSortChange($event)"
|
(sortChanged)="tableStore.onSortChange($event)"
|
||||||
(actionClicked)="onActionClick($event)"
|
(actionClicked)="onActionClick($event)"
|
||||||
>
|
>
|
||||||
<ng-template appDataTableCell="ianaId" let-value="value">
|
<ng-template appDataTableCell="ianaId" let-value="value">
|
||||||
@@ -35,103 +35,10 @@
|
|||||||
(cancelled)="onDeleteCancelled()"
|
(cancelled)="onDeleteCancelled()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modal
|
<app-timezone-form-modal
|
||||||
[open]="showModal()"
|
[open]="tableStore.showModal()"
|
||||||
[title]="modalTitle()"
|
[mode]="tableStore.modalMode()"
|
||||||
size="md"
|
[timezoneId]="tableStore.selectedItem()?.id ?? null"
|
||||||
[submitAction]="submitAction()"
|
(saved)="tableStore.refresh()"
|
||||||
[submitLabel]="submitLabel()"
|
(closed)="tableStore.closeModal()"
|
||||||
[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>
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
CreateTimezoneRequest,
|
Component,
|
||||||
TimezoneDto,
|
DestroyRef,
|
||||||
TimezoneModalMode,
|
OnInit,
|
||||||
UpdateTimezoneRequest
|
inject,
|
||||||
} from '../../models/timezone.model';
|
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 { TimezoneService } from '../../data-access/timezone.service';
|
||||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
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 {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
DataTableActionEvent,
|
DataTableActionEvent,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
DataTablePageEvent,
|
DataTableRecord
|
||||||
DataTableQuery,
|
|
||||||
DataTableRecord,
|
|
||||||
DataTableSortEvent
|
|
||||||
} from '../../../../../shared/components/data-table/data-table.types';
|
} 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 { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
|
||||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||||
|
import { TimezoneFormModalComponent } from '../../components/timezone-form-modal/timezone-form-modal';
|
||||||
|
|
||||||
interface TimezoneTableRow extends DataTableRecord {
|
interface TimezoneTableRow extends DataTableRecord {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -42,54 +38,27 @@ interface TimezoneTableRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'timezone-list',
|
selector: 'timezone-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog],
|
imports: [
|
||||||
|
DataTable,
|
||||||
|
DataTableCellDirective,
|
||||||
|
ConfirmDialog,
|
||||||
|
TimezoneFormModalComponent
|
||||||
|
],
|
||||||
|
providers: [DataTableStore],
|
||||||
templateUrl: './timezone-list.html',
|
templateUrl: './timezone-list.html',
|
||||||
styleUrl: './timezone-list.scss'
|
styleUrl: './timezone-list.scss'
|
||||||
})
|
})
|
||||||
export class TimezoneList implements OnInit {
|
export class TimezoneList implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly timezoneApi = inject(TimezoneService);
|
private readonly timezoneApi = inject(TimezoneService);
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
|
||||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
private readonly toastr = inject(ToastrService);
|
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 statusChangingId = signal<string | null>(null);
|
||||||
readonly showModal = signal(false);
|
readonly deletingId = signal<string | null>(null);
|
||||||
readonly modalMode = signal<TimezoneModalMode>('create');
|
readonly pendingDeleteTimezone = signal<TimezoneTableRow | null>(null);
|
||||||
readonly selectedTimezoneId = signal<string | null>(null);
|
|
||||||
readonly selectedTimezone = signal<TimezoneDto | null>(null);
|
|
||||||
readonly submitAttempted = signal(false);
|
|
||||||
readonly pendingDeleteTimezoneId = signal<string | null>(null);
|
|
||||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
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>[]>([
|
readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
|
{ 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' },
|
{ 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'
|
className: 'text-primary'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'delete',
|
type: 'deactivate',
|
||||||
label: 'Delete',
|
label: 'Deactivate',
|
||||||
icon: 'ti ti-trash',
|
icon: 'ti ti-toggle-right',
|
||||||
className: 'text-danger',
|
className: 'text-warning',
|
||||||
visible: row => row.isActive,
|
visible: row => row.isActive,
|
||||||
disabled: row => this.statusChangingId() === row.id
|
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'activate',
|
type: 'activate',
|
||||||
label: 'Activate',
|
label: 'Activate',
|
||||||
icon: 'ti ti-check',
|
icon: 'ti ti-toggle-left',
|
||||||
className: 'text-success',
|
className: 'text-success',
|
||||||
visible: row => !row.isActive,
|
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() {
|
ngOnInit(): void {
|
||||||
this.queryRequests$.pipe(
|
this.tableStore.initialize({
|
||||||
switchMap(query => this.timezoneApi.getDataTable(query).pipe(
|
fetcher: query => this.timezoneApi.getDataTable(query)
|
||||||
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.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private requestDeleteTimezone(id: string): void {
|
|
||||||
this.pendingDeleteTimezoneId.set(id);
|
|
||||||
this.deleteConfirmDialog()?.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
onAddTimezone(): void {
|
onAddTimezone(): void {
|
||||||
this.modalMode.set('create');
|
this.tableStore.openCreateModal();
|
||||||
this.prepareModal(null);
|
|
||||||
this.showModal.set(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openTimezone(id: string, mode: 'edit' | 'view'): void {
|
onActionClick(event: DataTableActionEvent<TimezoneTableRow>): void {
|
||||||
this.modalMode.set(mode);
|
if (event.action.type === 'view') this.tableStore.openViewModal(event.row);
|
||||||
this.prepareModal(id);
|
if (event.action.type === 'edit') this.tableStore.openEditModal(event.row);
|
||||||
this.modalLoading.set(true);
|
if (event.action.type === 'delete') this.requestDeleteTimezone(event.row);
|
||||||
this.showModal.set(true);
|
if (event.action.type === 'activate') this.changeTimezoneStatus(event.row, true);
|
||||||
this.timezoneApi.getById(id).pipe(
|
if (event.action.type === 'deactivate') this.changeTimezoneStatus(event.row, false);
|
||||||
finalize(() => this.modalLoading.set(false)),
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
error: (error: HttpErrorResponse) => {
|
|
||||||
this.showModal.set(false);
|
|
||||||
if (error.status === 404) this.toastr.error('The timezone is no longer available.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModal(): void {
|
onDeleteConfirmed(): void {
|
||||||
if (this.saving()) return;
|
const timezone = this.pendingDeleteTimezone();
|
||||||
this.showModal.set(false);
|
if (!timezone) return;
|
||||||
this.selectedTimezoneId.set(null);
|
this.pendingDeleteTimezone.set(null);
|
||||||
this.selectedTimezone.set(null);
|
this.deletingId.set(timezone.id);
|
||||||
this.submitAttempted.set(false);
|
|
||||||
this.timezoneForm.enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
saveTimezone(): void {
|
this.timezoneApi.delete(timezone.id).pipe(
|
||||||
if (this.isViewMode() || this.saving() || this.modalLoading()) return;
|
finalize(() => this.deletingId.set(null)),
|
||||||
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)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastr.success(this.modalMode() === 'create'
|
this.toastr.success('Timezone deleted successfully.');
|
||||||
? 'Timezone saved successfully.'
|
this.tableStore.refresh();
|
||||||
: 'Timezone updated successfully.');
|
|
||||||
this.finishSave();
|
|
||||||
},
|
},
|
||||||
error: (error: HttpErrorResponse) => this.handleSaveError(error)
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
formatUtcOffset(minutes: number): string {
|
onDeleteCancelled(): void {
|
||||||
const sign = minutes >= 0 ? '+' : '-';
|
this.pendingDeleteTimezone.set(null);
|
||||||
const absolute = Math.abs(minutes);
|
|
||||||
return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private prepareModal(id: string | null): void {
|
private requestDeleteTimezone(timezone: TimezoneTableRow): void {
|
||||||
this.selectedTimezoneId.set(id);
|
this.pendingDeleteTimezone.set(timezone);
|
||||||
this.selectedTimezone.set(null);
|
this.deleteConfirmDialog()?.open();
|
||||||
this.submitAttempted.set(false);
|
|
||||||
this.timezoneForm.enable();
|
|
||||||
this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 });
|
|
||||||
this.resetFormState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCreateRequest(): CreateTimezoneRequest {
|
private changeTimezoneStatus(timezone: TimezoneTableRow, activate: boolean): void {
|
||||||
const value = this.timezoneForm.getRawValue();
|
this.statusChangingId.set(timezone.id);
|
||||||
return {
|
|
||||||
ianaId: value.ianaId.trim(),
|
|
||||||
displayName: value.displayName.trim(),
|
|
||||||
utcOffsetMinutes: value.utcOffsetMinutes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private changeTimezoneStatus(id: string, isActive: boolean): void {
|
this.timezoneApi.updateStatus(timezone.id, { isActive: activate }).pipe(
|
||||||
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
|
|
||||||
})),
|
|
||||||
finalize(() => this.statusChangingId.set(null)),
|
finalize(() => this.statusChangingId.set(null)),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastr.success(isActive
|
this.toastr.success(`Timezone ${activate ? 'activated' : 'deactivated'} successfully.`);
|
||||||
? 'Timezone activated successfully.'
|
this.tableStore.refresh();
|
||||||
: 'Timezone deleted successfully.');
|
|
||||||
this.loadTimezones(this.queryState.getQuery());
|
|
||||||
},
|
},
|
||||||
error: (error: HttpErrorResponse) => {
|
error: (err) => {
|
||||||
if (error.status === 404) this.toastr.error('The timezone is no longer available.');
|
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} timezone.`;
|
||||||
|
this.toastr.error(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSaveError(error: HttpErrorResponse): void {
|
formatUtcOffset(totalMinutes: number): string {
|
||||||
if (error.status === 409) {
|
const isNegative = totalMinutes < 0;
|
||||||
const message = this.apiErrorMessage(error) ?? 'A timezone with this IANA ID already exists.';
|
const absMinutes = Math.abs(totalMinutes);
|
||||||
this.toastr.error(message, 'Duplicate IANA timezone ID');
|
const hours = Math.floor(absMinutes / 60);
|
||||||
} else if (error.status === 404) {
|
const minutes = absMinutes % 60;
|
||||||
this.toastr.error('The timezone is no longer available.');
|
const formattedHours = String(hours).padStart(2, '0');
|
||||||
this.closeModal();
|
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||||
}
|
const prefix = isNegative ? '-' : '+';
|
||||||
}
|
return `UTC${prefix}${formattedHours}:${formattedMinutes}`;
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -50,13 +50,28 @@
|
|||||||
<div class="xl:col-span-12 col-span-12">
|
<div class="xl:col-span-12 col-span-12">
|
||||||
|
|
||||||
|
|
||||||
<app-data-table [columns]="columns()" [rows]="recentOrganizations()" [actions]="actions()"
|
<app-data-table
|
||||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
|
[columns]="columns()"
|
||||||
tableTitle="Organizations" buttonTitle="Add" [showSearch]="true" [showAddButton]="true"
|
[rows]="tableStore.rows()"
|
||||||
searchPlaceholder="Search..." [searchDebounceTime]="300" [emptyMessage]="emptyMessage()"
|
[actions]="actions()"
|
||||||
[emptyDescription]="emptyDescription()" (addClicked)="onAddOrganization()"
|
[totalRecords]="tableStore.totalRecords()"
|
||||||
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
(actionClicked)="onActionClick($event)" toolTip="Add Organization" rowColorMode="none" />
|
[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>
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { Component, signal, inject } from '@angular/core';
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import { DataTable } from '../../../shared/components/data-table/data-table';
|
import { DataTable } from '../../../shared/components/data-table/data-table';
|
||||||
import { DataTableColumn, DataTableRecord } from '../../../shared/components/data-table/data-table.types';
|
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 OrganizationPlan = 'Enterprise' | 'Standard' | 'Trial';
|
||||||
type OrganizationStatus = 'Active' | 'Trial' | 'Suspended';
|
type OrganizationStatus = 'Active' | 'Trial' | 'Suspended';
|
||||||
@@ -17,25 +26,29 @@ interface DashboardStatCard {
|
|||||||
helperClass: string;
|
helperClass: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecentOrganizationRow extends DataTableRecord {
|
interface OrganizationListRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
|
countryId: string;
|
||||||
country: string;
|
country: string;
|
||||||
plan: OrganizationPlan;
|
plan: string;
|
||||||
status: OrganizationStatus;
|
status: OrganizationStatus;
|
||||||
created: string;
|
expiry: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'dashboard',
|
selector: 'dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable],
|
imports: [DataTable],
|
||||||
|
providers: [DataTableStore],
|
||||||
templateUrl: './dashboard.html',
|
templateUrl: './dashboard.html',
|
||||||
styleUrl: './dashboard.scss',
|
styleUrl: './dashboard.scss',
|
||||||
})
|
})
|
||||||
export class Dashboard {
|
export class Dashboard implements OnInit {
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly toastr = inject(ToastrService);
|
||||||
|
readonly tableStore = inject(DataTableStore<OrganizationListRow, OrganizationListRow>);
|
||||||
|
|
||||||
readonly statCards = signal<DashboardStatCard[]>([
|
readonly statCards = signal<DashboardStatCard[]>([
|
||||||
{
|
{
|
||||||
@@ -79,7 +92,7 @@ export class Dashboard {
|
|||||||
value: '1',
|
value: '1',
|
||||||
helper: 'Needs renewal action',
|
helper: 'Needs renewal action',
|
||||||
accentClass: 'border-primary',
|
accentClass: 'border-primary',
|
||||||
iconClass: 'ti ti-license',
|
iconClass: 'ri ri-pass-expired-line',
|
||||||
iconBackgroundClass: 'bg-primary',
|
iconBackgroundClass: 'bg-primary',
|
||||||
helperClass: 'bg-primary/10 text-primary',
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
},
|
},
|
||||||
@@ -88,98 +101,107 @@ export class Dashboard {
|
|||||||
value: '482',
|
value: '482',
|
||||||
helper: 'Users with access',
|
helper: 'Users with access',
|
||||||
accentClass: 'border-primary',
|
accentClass: 'border-primary',
|
||||||
iconClass: 'ri-profile-line',
|
iconClass: 'ri ri-group-line',
|
||||||
iconBackgroundClass: 'bg-primary',
|
iconBackgroundClass: 'bg-primary',
|
||||||
helperClass: 'bg-primary/10 text-primary',
|
helperClass: 'bg-primary/10 text-primary',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly recentOrganizations = signal<RecentOrganizationRow[]>([
|
readonly recentOrganizations = signal<OrganizationListRow[]>([
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
code: 'ORG-0024',
|
code: 'ORG-0024',
|
||||||
organizationName: 'Syscom Group',
|
organizationName: 'Syscom Group',
|
||||||
|
countryId: 'c1',
|
||||||
country: 'Saudi Arabia',
|
country: 'Saudi Arabia',
|
||||||
plan: 'Enterprise',
|
plan: 'Enterprise',
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
created: '09-07-2026',
|
expiry: '31-12-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
code: 'ORG-0023',
|
code: 'ORG-0023',
|
||||||
organizationName: 'Acme Trading',
|
organizationName: 'Acme Trading',
|
||||||
|
countryId: 'c2',
|
||||||
country: 'India',
|
country: 'India',
|
||||||
plan: 'Standard',
|
plan: 'Standard',
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
created: '01-07-2026',
|
expiry: '31-03-2027'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
code: 'ORG-0022',
|
code: 'ORG-0022',
|
||||||
organizationName: 'Falcon Retail LLC',
|
organizationName: 'Falcon Retail LLC',
|
||||||
|
countryId: 'c3',
|
||||||
country: 'UAE',
|
country: 'UAE',
|
||||||
plan: 'Trial',
|
plan: 'Trial',
|
||||||
status: 'Trial',
|
status: 'Trial',
|
||||||
created: '28-06-2026',
|
expiry: '28-07-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
code: 'ORG-0025',
|
code: 'ORG-0021',
|
||||||
organizationName: 'Syscom Group',
|
organizationName: 'Oasis Foods',
|
||||||
|
countryId: 'c1',
|
||||||
country: 'Saudi Arabia',
|
country: 'Saudi Arabia',
|
||||||
plan: 'Enterprise',
|
plan: 'Standard',
|
||||||
status: 'Active',
|
status: 'Suspended',
|
||||||
created: '09-07-2026',
|
expiry: '15-06-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
code: 'ORG-0026',
|
code: 'ORG-0022',
|
||||||
organizationName: 'Acme Trading',
|
organizationName: 'Oasis Foods',
|
||||||
country: 'India',
|
countryId: 'c1',
|
||||||
|
country: 'Saudi Arabia',
|
||||||
plan: 'Standard',
|
plan: 'Standard',
|
||||||
status: 'Active',
|
status: 'Suspended',
|
||||||
created: '01-07-2026',
|
expiry: '15-06-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: '6',
|
||||||
code: 'ORG-0027',
|
code: 'ORG-0023',
|
||||||
organizationName: 'Falcon Retail LLC',
|
organizationName: 'Oasis Foods',
|
||||||
country: 'UAE',
|
countryId: 'c1',
|
||||||
plan: 'Trial',
|
country: 'Saudi Arabia',
|
||||||
status: 'Trial',
|
plan: 'Standard',
|
||||||
created: '28-06-2026',
|
status: 'Suspended',
|
||||||
|
expiry: '15-06-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '7',
|
id: '7',
|
||||||
code: 'ORG-0028',
|
code: 'ORG-0024',
|
||||||
organizationName: 'Syscom Group',
|
organizationName: 'Oasis Foods',
|
||||||
|
countryId: 'c1',
|
||||||
country: 'Saudi Arabia',
|
country: 'Saudi Arabia',
|
||||||
plan: 'Enterprise',
|
plan: 'Standard',
|
||||||
status: 'Active',
|
status: 'Suspended',
|
||||||
created: '09-07-2026',
|
expiry: '15-06-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '8',
|
id: '8',
|
||||||
code: 'ORG-0029',
|
code: 'ORG-0025',
|
||||||
organizationName: 'Acme Trading',
|
organizationName: 'Oasis Foods',
|
||||||
country: 'India',
|
countryId: 'c1',
|
||||||
|
country: 'Saudi Arabia',
|
||||||
plan: 'Standard',
|
plan: 'Standard',
|
||||||
status: 'Active',
|
status: 'Suspended',
|
||||||
created: '01-07-2026',
|
expiry: '15-06-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '9',
|
id: '9',
|
||||||
code: 'ORG-0030',
|
code: 'ORG-0026',
|
||||||
organizationName: 'Falcon Retail LLC',
|
organizationName: 'Oasis Foods',
|
||||||
country: 'UAE',
|
countryId: 'c1',
|
||||||
plan: 'Trial',
|
country: 'Saudi Arabia',
|
||||||
status: 'Trial',
|
plan: 'Standard',
|
||||||
created: '28-06-2026',
|
status: 'Suspended',
|
||||||
|
expiry: '15-06-2026'
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly columns = signal<DataTableColumn<RecentOrganizationRow>[]>([
|
readonly columns = signal<DataTableColumn<OrganizationListRow>[]>([
|
||||||
{ key: 'code', label: 'Code', header: 'Code', sortable: false, align: 'left' },
|
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
||||||
{
|
{
|
||||||
key: 'organizationName',
|
key: 'organizationName',
|
||||||
label: 'Name',
|
label: 'Name',
|
||||||
@@ -187,7 +209,7 @@ export class Dashboard {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'left',
|
align: 'left',
|
||||||
},
|
},
|
||||||
{ key: 'country', label: 'Country', header: 'Country', sortable: false },
|
{ key: 'country', label: 'Country', header: 'Country', sortable: true },
|
||||||
{
|
{
|
||||||
key: 'plan',
|
key: 'plan',
|
||||||
label: 'Plan',
|
label: 'Plan',
|
||||||
@@ -212,52 +234,75 @@ export class Dashboard {
|
|||||||
? 'badge bg-warning/10 text-warning'
|
? 'badge bg-warning/10 text-warning'
|
||||||
: 'badge bg-danger/10 text-danger',
|
: 'badge bg-danger/10 text-danger',
|
||||||
},
|
},
|
||||||
{ key: 'created', label: 'Created', header: 'Created', sortable: false },
|
{ key: 'expiry', label: 'Expiry', header: 'Expiry', sortable: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly totalRecords = signal<number>(6);
|
|
||||||
readonly queryState = {
|
|
||||||
pageIndex: signal<number>(0)
|
|
||||||
};
|
|
||||||
readonly emptyMessage = signal<string>('No Organizations');
|
readonly emptyMessage = signal<string>('No Organizations');
|
||||||
readonly emptyDescription = signal<string>('Start by adding your first organization');
|
readonly emptyDescription = signal<string>('Start by adding your first organization');
|
||||||
|
|
||||||
readonly actions = signal<any[]>([
|
readonly actions = signal<DataTableAction<OrganizationListRow>[]>([
|
||||||
{ id: 'edit', label: 'Edit', icon: 'ti ti-edit' },
|
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
|
||||||
{ id: 'delete', label: 'Delete', icon: 'ti ti-trash' },
|
{ 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 {
|
onAddOrganization(): void {
|
||||||
void this.router.navigate(['/organizations/onboarding']);
|
void this.router.navigate(['/organizations/onboarding']);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearch(searchTerm: string) {
|
onActionClick(event: DataTableActionEvent<OrganizationListRow>): void {
|
||||||
console.log('Search:', searchTerm);
|
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}`);
|
||||||
onPageChange(event: any) {
|
|
||||||
console.log('Page changed:', event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSortChange(event: any) {
|
|
||||||
console.log('Sort changed:', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
onActionClick(event: any) {
|
|
||||||
const { actionId, record } = event;
|
|
||||||
|
|
||||||
switch (actionId) {
|
|
||||||
case 'edit':
|
|
||||||
console.log('Edit organization:', record);
|
|
||||||
// Implement edit logic here
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
console.log('Delete organization:', record);
|
|
||||||
// Implement delete logic here
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log('Unknown action:', actionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="grid grid-cols-12 gap-6">
|
<div class="grid grid-cols-12 gap-6">
|
||||||
<div class="xl:col-span-12 col-span-12">
|
<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">
|
<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">
|
<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">
|
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||||
<app-autocomplete
|
<app-autocomplete
|
||||||
formControlName="countryId"
|
formControlName="countryId"
|
||||||
@@ -24,21 +24,40 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
<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()">
|
<app-button
|
||||||
<i class="ti ti-filter" aria-hidden="true"></i>
|
action="custom"
|
||||||
<span>Filter</span>
|
label="Filter"
|
||||||
</button>
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</app-filter-card>
|
</app-filter-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-data-table [columns]="columns()" [rows]="organizations()" [actions]="actions()"
|
<app-data-table
|
||||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
[columns]="columns()"
|
||||||
tableTitle="Organizations" buttonTitle="Add" [showSearch]="true" rowColorMode="none"
|
[rows]="tableStore.rows()"
|
||||||
[showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
|
[actions]="actions()"
|
||||||
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
[totalRecords]="tableStore.totalRecords()"
|
||||||
(addClicked)="onAddOrganization()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add Organization" />
|
[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,18 +1,17 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { of, map } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import { DataTable } from '../../../shared/components/data-table/data-table';
|
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 {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
DataTableActionEvent,
|
DataTableActionEvent,
|
||||||
DataTableColumn,
|
DataTableColumn,
|
||||||
DataTablePageEvent,
|
|
||||||
DataTableRecord,
|
DataTableRecord,
|
||||||
DataTableSortEvent,
|
DataTableResult
|
||||||
} from '../../../shared/components/data-table/data-table.types';
|
} from '../../../shared/components/data-table/data-table.types';
|
||||||
import { Autocomplete } from '../../../shared/components/form/autocomplete/autocomplete';
|
import { Autocomplete } from '../../../shared/components/form/autocomplete/autocomplete';
|
||||||
import {
|
import {
|
||||||
@@ -22,15 +21,11 @@ import {
|
|||||||
AutocompleteValueFn,
|
AutocompleteValueFn,
|
||||||
} from '../../../shared/components/form/autocomplete/autocomplete.types';
|
} from '../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
import { FilterCard } from '../../../shared/components/filter-card/filter-card';
|
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';
|
import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api';
|
||||||
|
|
||||||
type OrganizationStatus = 'Active' | 'Trial' | 'Suspended';
|
type OrganizationStatus = 'Active' | 'Trial' | 'Suspended';
|
||||||
|
|
||||||
interface CountryLookup {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrganizationListRow extends DataTableRecord {
|
interface OrganizationListRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -45,23 +40,19 @@ interface OrganizationListRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'organization-list',
|
selector: 'organization-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable],
|
imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable, Button],
|
||||||
|
providers: [DataTableStore],
|
||||||
templateUrl: './organization-list.html',
|
templateUrl: './organization-list.html',
|
||||||
styleUrl: './organization-list.scss',
|
styleUrl: './organization-list.scss',
|
||||||
})
|
})
|
||||||
|
export class OrganizationList implements OnInit {
|
||||||
|
|
||||||
export class OrganizationList {
|
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
private readonly countryApi = inject(CountryService);
|
private readonly countryApi = inject(CountryService);
|
||||||
|
readonly tableStore = inject(DataTableStore<OrganizationListRow, OrganizationListRow>);
|
||||||
readonly queryState = new DataTableQueryState();
|
|
||||||
|
|
||||||
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
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 appliedCountryId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
||||||
@@ -109,18 +100,61 @@ export class OrganizationList {
|
|||||||
status: 'Suspended',
|
status: 'Suspended',
|
||||||
expiry: '15-06-2026'
|
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 organizations = signal<OrganizationListRow[]>([]);
|
|
||||||
readonly totalRecords = signal(0);
|
|
||||||
|
|
||||||
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 displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
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 columns = signal<DataTableColumn<OrganizationListRow>[]>([
|
readonly columns = signal<DataTableColumn<OrganizationListRow>[]>([
|
||||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
||||||
@@ -170,55 +204,19 @@ export class OrganizationList {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly emptyMessage = signal('No organizations found');
|
ngOnInit(): void {
|
||||||
readonly emptyDescription = signal('There are no organizations available for the current filters.');
|
this.tableStore.initialize({
|
||||||
|
fetcher: query => {
|
||||||
constructor() {
|
|
||||||
this.refreshTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
onCountryLookupSelected(country: CountryLookupDto): void {
|
|
||||||
this.selectedCountryLookup.set(country);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyCountryFilter(): void {
|
|
||||||
const selectedCountryId = this.countryFilterForm.controls.countryId.value.trim();
|
|
||||||
this.appliedCountryId.set(selectedCountryId || null);
|
|
||||||
this.queryState.pageIndex.set(1);
|
|
||||||
this.refreshTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
onSearch(value: string): void {
|
|
||||||
this.queryState.setSearch(value);
|
|
||||||
this.refreshTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
onPageChange(event: DataTablePageEvent): void {
|
|
||||||
this.queryState.setPage(event);
|
|
||||||
this.refreshTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
onSortChange(event: DataTableSortEvent): void {
|
|
||||||
this.queryState.setSort(event);
|
|
||||||
this.refreshTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
onAddOrganization(): void {
|
|
||||||
void this.router.navigate(['/organizations/onboarding']);
|
|
||||||
}
|
|
||||||
|
|
||||||
onActionClick(event: DataTableActionEvent<OrganizationListRow>): void {
|
|
||||||
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private refreshTable(): void {
|
|
||||||
const countryId = this.appliedCountryId();
|
const countryId = this.appliedCountryId();
|
||||||
const search = this.queryState.searchText().trim().toLowerCase();
|
const selectedCountryName = this.selectedCountryLookup()?.name?.toLowerCase();
|
||||||
const sortBy = this.queryState.sortColumn();
|
const search = (query.search || '').trim().toLowerCase();
|
||||||
const sortDir = this.queryState.sortDirection();
|
const sortBy = query.sortBy;
|
||||||
|
const sortDir = query.sortDir;
|
||||||
|
|
||||||
let rows = this.allOrganizations().filter(row => {
|
let rows = this.allOrganizations().filter(row => {
|
||||||
const matchesCountry = !countryId || row.countryId === countryId;
|
const matchesCountry = !countryId
|
||||||
|
|| row.countryId === countryId
|
||||||
|
|| (!!selectedCountryName && row.country.toLowerCase() === selectedCountryName);
|
||||||
const matchesSearch = !search
|
const matchesSearch = !search
|
||||||
|| row.code.toLowerCase().includes(search)
|
|| row.code.toLowerCase().includes(search)
|
||||||
|| row.organizationName.toLowerCase().includes(search)
|
|| row.organizationName.toLowerCase().includes(search)
|
||||||
@@ -226,26 +224,55 @@ export class OrganizationList {
|
|||||||
|| row.plan.toLowerCase().includes(search)
|
|| row.plan.toLowerCase().includes(search)
|
||||||
|| row.status.toLowerCase().includes(search)
|
|| row.status.toLowerCase().includes(search)
|
||||||
|| row.expiry.toLowerCase().includes(search);
|
|| row.expiry.toLowerCase().includes(search);
|
||||||
|
|
||||||
return matchesCountry && matchesSearch;
|
return matchesCountry && matchesSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sortBy) {
|
if (sortBy) {
|
||||||
rows = [...rows].sort((left, right) => {
|
rows = [...rows].sort((left, right) => {
|
||||||
const leftValue = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
|
const leftVal = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
|
||||||
const rightValue = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
|
const rightVal = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
|
||||||
const compared = leftValue.localeCompare(rightValue);
|
const compared = leftVal.localeCompare(rightVal);
|
||||||
return sortDir === 'asc' ? compared : -compared;
|
return sortDir === 'asc' ? compared : -compared;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = rows.length;
|
const total = rows.length;
|
||||||
const pageIndex = this.queryState.pageIndex();
|
const start = (query.page - 1) * query.pageSize;
|
||||||
const pageSize = this.queryState.pageSize();
|
const end = start + query.pageSize;
|
||||||
const start = (pageIndex - 1) * pageSize;
|
const result: DataTableResult<OrganizationListRow> = {
|
||||||
const end = start + pageSize;
|
draw: query.draw,
|
||||||
|
total,
|
||||||
|
filtered: total,
|
||||||
|
rows: rows.slice(start, end)
|
||||||
|
};
|
||||||
|
return of(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.totalRecords.set(total);
|
onCountryLookupSelected(country: CountryLookupDto | null): void {
|
||||||
this.organizations.set(rows.slice(start, end));
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-48
@@ -111,10 +111,52 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Button Group Footer -->
|
<!-- Button Group Footer -->
|
||||||
<footer class="onboarding-action-footer mt-5">
|
<footer class="modern-modal-footer mt-5 px-6">
|
||||||
<!-- Mobile View (< 640px) -->
|
<!-- Mobile View (< 640px) -->
|
||||||
<div class="flex flex-col gap-2.5 sm:hidden">
|
<div class="flex flex-col gap-2.5 sm:hidden">
|
||||||
|
<!-- Back or Cancel button -->
|
||||||
<div class="w-full">
|
<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>
|
||||||
|
|
||||||
|
<!-- 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()) {
|
@if (!isLastStep()) {
|
||||||
<app-button
|
<app-button
|
||||||
action="next"
|
action="next"
|
||||||
@@ -140,53 +182,13 @@
|
|||||||
></app-button>
|
></app-button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-2.5 w-full max-[420px]:grid-cols-1">
|
|
||||||
<div class="min-w-0">
|
|
||||||
@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>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop View (>= 640px) -->
|
<!-- Desktop View (>= 640px) -->
|
||||||
<div class="hidden w-full sm:grid sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-center sm:gap-3">
|
<div class="hidden w-full sm:flex sm:items-center sm:justify-between sm:gap-3">
|
||||||
<!-- Left: Cancel or Back -->
|
<!-- Left: Cancel or Back -->
|
||||||
<div class="justify-self-start">
|
<div>
|
||||||
@if (isFirstStep()) {
|
@if (isFirstStep()) {
|
||||||
<app-button
|
<app-button
|
||||||
action="cancel"
|
action="cancel"
|
||||||
@@ -207,8 +209,8 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Save Draft -->
|
<!-- Right: Save Draft & Next/Finish -->
|
||||||
<div class="justify-self-center">
|
<div class="flex items-center gap-2.5 sm:gap-3">
|
||||||
<app-button
|
<app-button
|
||||||
action="save"
|
action="save"
|
||||||
[variant]="'custom'"
|
[variant]="'custom'"
|
||||||
@@ -220,10 +222,7 @@
|
|||||||
className="onboarding-save-btn ti-btn-outline-primary whitespace-nowrap px-4"
|
className="onboarding-save-btn ti-btn-outline-primary whitespace-nowrap px-4"
|
||||||
(buttonClicked)="requestSaveDraft()"
|
(buttonClicked)="requestSaveDraft()"
|
||||||
></app-button>
|
></app-button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Save Draft & Next/Finish -->
|
|
||||||
<div class="justify-self-end">
|
|
||||||
@if (!isLastStep()) {
|
@if (!isLastStep()) {
|
||||||
<app-button
|
<app-button
|
||||||
action="next"
|
action="next"
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
export interface OnboardingLookupValue {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly secondaryLabel?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ORGANIZATION_TYPE_OPTIONS: readonly OnboardingLookupValue[] = [
|
||||||
|
{ id: '0', label: 'Enterprise' },
|
||||||
|
{ id: '1', label: 'Holding' },
|
||||||
|
{ id: '2', label: 'Government' },
|
||||||
|
{ id: '3', label: 'NGO' },
|
||||||
|
{ id: '4', label: 'Startup' },
|
||||||
|
];
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { buildApiUrl } from '../../../../core/config/api-url.util';
|
||||||
|
|
||||||
|
export const ORGANIZATION_ONBOARDING_ENDPOINTS = {
|
||||||
|
createDraft: buildApiUrl('masterAdmin', '/v1/organizations'),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
buildApiUrl('masterAdmin', `/v1/organizations/${encodeURIComponent(id)}`),
|
||||||
|
|
||||||
|
updateBasics: (id: string) =>
|
||||||
|
buildApiUrl('masterAdmin', `/v1/organizations/${encodeURIComponent(id)}/steps/basics`),
|
||||||
|
|
||||||
|
updateLocalization: (id: string) =>
|
||||||
|
buildApiUrl('masterAdmin', `/v1/organizations/${encodeURIComponent(id)}/steps/localization`),
|
||||||
|
|
||||||
|
updatePlan: (id: string) =>
|
||||||
|
buildApiUrl('masterAdmin', `/v1/organizations/${encodeURIComponent(id)}/steps/plan`),
|
||||||
|
|
||||||
|
updateAdminContact: (id: string) =>
|
||||||
|
buildApiUrl('masterAdmin', `/v1/organizations/${encodeURIComponent(id)}/steps/admin-contact`),
|
||||||
|
|
||||||
|
finish: (id: string) =>
|
||||||
|
buildApiUrl('masterAdmin', `/v1/organizations/${encodeURIComponent(id)}/finish`),
|
||||||
|
|
||||||
|
subscriptionPlans: buildApiUrl('masterAdmin', '/v1/plans/autocomplete'),
|
||||||
|
|
||||||
|
subscriptionPlanById: (id: string) =>
|
||||||
|
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}`),
|
||||||
|
} as const;
|
||||||
+41
-2
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, computed, signal } from '@angular/core';
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
|
||||||
import { OrganizationOnboardingDraft } from '../models/organization-draft.model';
|
import { OrganizationOnboardingDraft } from '../../models/organization-draft.model';
|
||||||
import {
|
import {
|
||||||
OnboardingStepDefinition,
|
OnboardingStepDefinition,
|
||||||
OrganizationAdminValue,
|
OrganizationAdminValue,
|
||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
OrganizationLocalizationValue,
|
OrganizationLocalizationValue,
|
||||||
OrganizationOnboardingData,
|
OrganizationOnboardingData,
|
||||||
OrganizationPlanLimitsValue,
|
OrganizationPlanLimitsValue,
|
||||||
} from '../models/organization-onboarding.model';
|
} from '../../models/organization-onboarding.model';
|
||||||
|
|
||||||
|
import { OrganizationServerDraftResponse } from '../../models/organization-onboarding.model';
|
||||||
|
|
||||||
const INITIAL_DATA: OrganizationOnboardingData = {
|
const INITIAL_DATA: OrganizationOnboardingData = {
|
||||||
basics: null,
|
basics: null,
|
||||||
@@ -33,6 +35,7 @@ const INITIAL_DRAFT_STATE: OnboardingDraftState = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationOnboardingStateService {
|
export class OrganizationOnboardingStateService {
|
||||||
|
private readonly organizationIdState = signal<string | null>(null);
|
||||||
private readonly onboardingDataState = signal<OrganizationOnboardingData>(INITIAL_DATA);
|
private readonly onboardingDataState = signal<OrganizationOnboardingData>(INITIAL_DATA);
|
||||||
private readonly onboardingDraftState = signal<OnboardingDraftState>(INITIAL_DRAFT_STATE);
|
private readonly onboardingDraftState = signal<OnboardingDraftState>(INITIAL_DRAFT_STATE);
|
||||||
private readonly currentStepIndexState = signal(0);
|
private readonly currentStepIndexState = signal(0);
|
||||||
@@ -40,6 +43,7 @@ export class OrganizationOnboardingStateService {
|
|||||||
private readonly savedDraftIdState = signal<string | null>(null);
|
private readonly savedDraftIdState = signal<string | null>(null);
|
||||||
private readonly savedAtState = signal<string | null>(null);
|
private readonly savedAtState = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly organizationId = this.organizationIdState.asReadonly();
|
||||||
readonly onboardingData = this.onboardingDataState.asReadonly();
|
readonly onboardingData = this.onboardingDataState.asReadonly();
|
||||||
readonly onboardingDraft = this.onboardingDraftState.asReadonly();
|
readonly onboardingDraft = this.onboardingDraftState.asReadonly();
|
||||||
readonly currentStepIndex = this.currentStepIndexState.asReadonly();
|
readonly currentStepIndex = this.currentStepIndexState.asReadonly();
|
||||||
@@ -56,6 +60,10 @@ export class OrganizationOnboardingStateService {
|
|||||||
return !!(data.basics || data.localization || data.planLimits || data.admin);
|
return !!(data.basics || data.localization || data.planLimits || data.admin);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setOrganizationId(id: string | null): void {
|
||||||
|
this.organizationIdState.set(id);
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentStepIndex(index: number): void {
|
setCurrentStepIndex(index: number): void {
|
||||||
this.currentStepIndexState.set(Math.min(3, Math.max(0, index)));
|
this.currentStepIndexState.set(Math.min(3, Math.max(0, index)));
|
||||||
}
|
}
|
||||||
@@ -156,6 +164,36 @@ export class OrganizationOnboardingStateService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreServerDraft(serverDraft: OrganizationServerDraftResponse): void {
|
||||||
|
this.organizationIdState.set(serverDraft.id);
|
||||||
|
this.onboardingDraftState.set({
|
||||||
|
basics: serverDraft.basics ? { ...serverDraft.basics } : null,
|
||||||
|
localization: serverDraft.localization ? { ...serverDraft.localization } : null,
|
||||||
|
planLimits: serverDraft.planLimits ? { ...serverDraft.planLimits } : null,
|
||||||
|
admin: serverDraft.admin ? { ...serverDraft.admin } : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedIndexes = serverDraft.completedStepIndexes ?? [];
|
||||||
|
this.onboardingDataState.set({
|
||||||
|
basics: completedIndexes.includes(0) && serverDraft.basics
|
||||||
|
? { ...serverDraft.basics } as OrganizationBasicsValue
|
||||||
|
: null,
|
||||||
|
localization: completedIndexes.includes(1) && serverDraft.localization
|
||||||
|
? { ...serverDraft.localization } as OrganizationLocalizationValue
|
||||||
|
: null,
|
||||||
|
planLimits: completedIndexes.includes(2) && serverDraft.planLimits
|
||||||
|
? { ...serverDraft.planLimits } as OrganizationPlanLimitsValue
|
||||||
|
: null,
|
||||||
|
admin: completedIndexes.includes(3) && serverDraft.admin
|
||||||
|
? { ...serverDraft.admin } as OrganizationAdminValue
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepIndex = typeof serverDraft.currentStepIndex === 'number' ? serverDraft.currentStepIndex : 0;
|
||||||
|
this.currentStepIndexState.set(Math.min(3, Math.max(0, stepIndex)));
|
||||||
|
this.setCompletedStepIndexes(completedIndexes);
|
||||||
|
}
|
||||||
|
|
||||||
restoreDraft(draft: OrganizationOnboardingDraft): void {
|
restoreDraft(draft: OrganizationOnboardingDraft): void {
|
||||||
this.onboardingDraftState.set({
|
this.onboardingDraftState.set({
|
||||||
basics: draft.basics ? { ...draft.basics } : null,
|
basics: draft.basics ? { ...draft.basics } : null,
|
||||||
@@ -189,6 +227,7 @@ export class OrganizationOnboardingStateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
this.organizationIdState.set(null);
|
||||||
this.onboardingDataState.set(INITIAL_DATA);
|
this.onboardingDataState.set(INITIAL_DATA);
|
||||||
this.onboardingDraftState.set(INITIAL_DRAFT_STATE);
|
this.onboardingDraftState.set(INITIAL_DRAFT_STATE);
|
||||||
this.currentStepIndexState.set(0);
|
this.currentStepIndexState.set(0);
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable, catchError, map, of } from 'rxjs';
|
||||||
|
import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
|
||||||
|
import { ORGANIZATION_ONBOARDING_ENDPOINTS } from '../organization-onboarding.endpoints';
|
||||||
|
import {
|
||||||
|
CountryLocalizationDefaults,
|
||||||
|
CreateOrganizationRequest,
|
||||||
|
DateFormatValue,
|
||||||
|
FiscalYearConventionValue,
|
||||||
|
NumberFormatValue,
|
||||||
|
OnboardingLookupValue,
|
||||||
|
OrganizationLicenseType,
|
||||||
|
OrganizationPlanDefaults,
|
||||||
|
OrganizationPlanLookupValue,
|
||||||
|
OrganizationServerDraftResponse,
|
||||||
|
SubscriptionPlanDto,
|
||||||
|
TimeFormatValue,
|
||||||
|
UpdateOrganizationAdminContactApiRequest,
|
||||||
|
UpdateOrganizationBasicsApiRequest,
|
||||||
|
UpdateOrganizationLocalizationApiRequest,
|
||||||
|
UpdateOrganizationPlanApiRequest,
|
||||||
|
} from '../../models/organization-onboarding.model';
|
||||||
|
import { ORGANIZATION_TYPE_OPTIONS } from '../../constants/organization-onboarding.constants';
|
||||||
|
|
||||||
|
const DATE_FORMAT_OPTIONS: readonly FormSelectOption<DateFormatValue>[] = [
|
||||||
|
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
|
||||||
|
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
|
||||||
|
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIME_FORMAT_OPTIONS: readonly FormSelectOption<TimeFormatValue>[] = [
|
||||||
|
{ value: 0, label: '12 hour' },
|
||||||
|
{ value: 1, label: '24 hour' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NUMBER_FORMAT_OPTIONS: readonly FormSelectOption<NumberFormatValue>[] = [
|
||||||
|
{ value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' },
|
||||||
|
{ value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FISCAL_YEAR_OPTIONS: readonly FormSelectOption<FiscalYearConventionValue>[] = [
|
||||||
|
{ value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' },
|
||||||
|
{ value: 'AprilToMarch', label: 'April - March' },
|
||||||
|
{ value: 'JulyToJune', label: 'July - June' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LICENSE_TYPE_OPTIONS: readonly FormSelectOption<OrganizationLicenseType>[] = [
|
||||||
|
{ value: 'Trial', label: 'Trial' },
|
||||||
|
{ value: 'Paid', label: 'Paid' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
|
||||||
|
{
|
||||||
|
countryId: 'IN',
|
||||||
|
timeZone: { id: 'asia-kolkata', label: 'India Standard Time', secondaryLabel: 'Asia/Kolkata' },
|
||||||
|
currency: { id: 'inr', label: 'Indian Rupee', secondaryLabel: 'INR' },
|
||||||
|
defaultLanguage: { id: 'en-in', label: 'English', secondaryLabel: 'en-IN' },
|
||||||
|
dateFormat: 'DD/MM/YYYY',
|
||||||
|
timeFormat: 0,
|
||||||
|
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
||||||
|
fiscalYearConvention: 'AprilToMarch',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
countryId: 'US',
|
||||||
|
timeZone: { id: 'america-new-york', label: 'Eastern Time', secondaryLabel: 'America/New_York' },
|
||||||
|
currency: { id: 'usd', label: 'US Dollar', secondaryLabel: 'USD' },
|
||||||
|
defaultLanguage: { id: 'en-us', label: 'English', secondaryLabel: 'en-US' },
|
||||||
|
dateFormat: 'MM/DD/YYYY',
|
||||||
|
timeFormat: 0,
|
||||||
|
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
||||||
|
fiscalYearConvention: 'CalendarYear',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrganizationOnboardingService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
// --- Server API Draft Methods ---
|
||||||
|
|
||||||
|
createDraft(request: CreateOrganizationRequest): Observable<OrganizationServerDraftResponse> {
|
||||||
|
return this.http.post<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.createDraft, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrganizationById(id: string): Observable<OrganizationServerDraftResponse> {
|
||||||
|
return this.http.get<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.getById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBasics(id: string, request: UpdateOrganizationBasicsApiRequest): Observable<OrganizationServerDraftResponse> {
|
||||||
|
return this.http.patch<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.updateBasics(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLocalization(id: string, request: UpdateOrganizationLocalizationApiRequest): Observable<OrganizationServerDraftResponse> {
|
||||||
|
return this.http.patch<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.updateLocalization(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlan(id: string, request: UpdateOrganizationPlanApiRequest): Observable<OrganizationServerDraftResponse> {
|
||||||
|
return this.http.patch<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.updatePlan(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAdminContact(id: string, request: UpdateOrganizationAdminContactApiRequest): Observable<OrganizationServerDraftResponse> {
|
||||||
|
return this.http.patch<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.updateAdminContact(id), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishOnboarding(id: string): Observable<OrganizationServerDraftResponse> {
|
||||||
|
return this.http.post<OrganizationServerDraftResponse>(ORGANIZATION_ONBOARDING_ENDPOINTS.finish(id), {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lookups and Defaults ---
|
||||||
|
|
||||||
|
searchOrganizationTypes(term: string | null, limit = 10): Observable<readonly OnboardingLookupValue[]> {
|
||||||
|
return of(this.filterLookupValues(ORGANIZATION_TYPE_OPTIONS, term, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveOrganizationType(id: string): Observable<OnboardingLookupValue | null> {
|
||||||
|
const numIndex = Number(id);
|
||||||
|
if (!isNaN(numIndex) && numIndex >= 0 && numIndex < ORGANIZATION_TYPE_OPTIONS.length) {
|
||||||
|
return of(ORGANIZATION_TYPE_OPTIONS[numIndex]);
|
||||||
|
}
|
||||||
|
return of(
|
||||||
|
ORGANIZATION_TYPE_OPTIONS.find(
|
||||||
|
option => option.id.toLowerCase() === String(id).toLowerCase()
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSubscriptionPlans(term = '', limit = 50): Observable<readonly SubscriptionPlanDto[]> {
|
||||||
|
const params = new HttpParams().set('term', term).set('limit', limit);
|
||||||
|
return this.http.get<readonly SubscriptionPlanDto[]>(ORGANIZATION_ONBOARDING_ENDPOINTS.subscriptionPlans, { params }).pipe(
|
||||||
|
catchError(() => of([
|
||||||
|
{ id: 'starter', name: 'Starter', code: 'STARTER', maxCompanies: 1, maxUsers: 10, maxStorageGb: 10, defaultTrialDays: 14 },
|
||||||
|
{ id: 'growth', name: 'Growth', code: 'GROWTH', maxCompanies: 5, maxUsers: 100, maxStorageGb: 100, defaultTrialDays: 30 },
|
||||||
|
{ id: 'enterprise', name: 'Enterprise', code: 'ENTERPRISE', maxCompanies: 25, maxUsers: 1000, maxStorageGb: 500, defaultTrialDays: 30 },
|
||||||
|
]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchSubscriptionPlans(term: string | null, limit = 10): Observable<readonly OrganizationPlanLookupValue[]> {
|
||||||
|
return this.getSubscriptionPlans(term ?? '', limit).pipe(
|
||||||
|
map(plans => plans.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.name,
|
||||||
|
code: p.code,
|
||||||
|
maxCompanies: p.maxCompanies,
|
||||||
|
maxUsers: p.maxUsers,
|
||||||
|
maxStorageGb: p.maxStorageGb,
|
||||||
|
defaultTrialDays: p.defaultTrialDays,
|
||||||
|
})))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveSubscriptionPlan(id: string): Observable<OrganizationPlanLookupValue | null> {
|
||||||
|
if (!id) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
return this.http.get<SubscriptionPlanDto>(ORGANIZATION_ONBOARDING_ENDPOINTS.subscriptionPlanById(id)).pipe(
|
||||||
|
map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.name,
|
||||||
|
code: p.code,
|
||||||
|
maxCompanies: p.maxCompanies,
|
||||||
|
maxUsers: p.maxUsers,
|
||||||
|
maxStorageGb: p.maxStorageGb,
|
||||||
|
defaultTrialDays: p.defaultTrialDays,
|
||||||
|
})),
|
||||||
|
catchError(() => this.searchSubscriptionPlans('', 50).pipe(
|
||||||
|
map(plans => plans.find(p => p.id === id || p.code === id) ?? null)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlanDefaults(planId: string): Observable<OrganizationPlanDefaults | null> {
|
||||||
|
return this.resolveSubscriptionPlan(planId).pipe(
|
||||||
|
map(plan => {
|
||||||
|
if (!plan) return null;
|
||||||
|
const codeUpper = (plan.code || '').toUpperCase();
|
||||||
|
const maxCompanies = plan.maxCompanies ?? (codeUpper === 'ENTERPRISE' ? 25 : codeUpper === 'GROWTH' ? 5 : 1);
|
||||||
|
const maxUsers = plan.maxUsers ?? (codeUpper === 'ENTERPRISE' ? 1000 : codeUpper === 'GROWTH' ? 100 : 10);
|
||||||
|
const maxStorageGb = plan.maxStorageGb ?? (codeUpper === 'ENTERPRISE' ? 500 : codeUpper === 'GROWTH' ? 100 : 10);
|
||||||
|
const licenseType: OrganizationLicenseType = codeUpper === 'STARTER' ? 'Trial' : 'Paid';
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
licenseType,
|
||||||
|
maximumCompanies: maxCompanies,
|
||||||
|
maximumUsers: maxUsers,
|
||||||
|
maximumStorageGb: maxStorageGb,
|
||||||
|
defaultTrialDays: plan.defaultTrialDays ?? 14,
|
||||||
|
limitsEditable: true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDateFormatOptions(): readonly FormSelectOption<DateFormatValue>[] {
|
||||||
|
return DATE_FORMAT_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeFormatOptions(): readonly FormSelectOption<TimeFormatValue>[] {
|
||||||
|
return TIME_FORMAT_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNumberFormatOptions(): readonly FormSelectOption<NumberFormatValue>[] {
|
||||||
|
return NUMBER_FORMAT_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFiscalYearOptions(): readonly FormSelectOption<FiscalYearConventionValue>[] {
|
||||||
|
return FISCAL_YEAR_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLicenseTypeOptions(): readonly FormSelectOption<OrganizationLicenseType>[] {
|
||||||
|
return LICENSE_TYPE_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCountryLocalizationDefaults(countryIso2: string | null): Observable<CountryLocalizationDefaults | null> {
|
||||||
|
if (!countryIso2?.trim()) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(LOCALIZATION_DEFAULTS.find(item => item.countryId === countryIso2.trim().toUpperCase()) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterLookupValues<TValue extends OnboardingLookupValue>(
|
||||||
|
source: readonly TValue[],
|
||||||
|
term: string | null,
|
||||||
|
limit: number,
|
||||||
|
): readonly TValue[] {
|
||||||
|
const normalizedTerm = term?.trim().toLowerCase() ?? '';
|
||||||
|
|
||||||
|
return source
|
||||||
|
.filter(option => {
|
||||||
|
if (!normalizedTerm) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [option.label, option.secondaryLabel]
|
||||||
|
.filter((value): value is string => !!value)
|
||||||
|
.some(value => value.toLowerCase().includes(normalizedTerm));
|
||||||
|
})
|
||||||
|
.slice(0, Math.max(1, limit));
|
||||||
|
}
|
||||||
|
}
|
||||||
+230
-3
@@ -29,7 +29,7 @@ export interface OrganizationBasicsValue {
|
|||||||
readonly registrationCountry: CountryLookupValue;
|
readonly registrationCountry: CountryLookupValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TimeFormatValue = 'TwelveHour' | 'TwentyFourHour';
|
export type TimeFormatValue = 0 | 1 | 'TwelveHour' | 'TwentyFourHour';
|
||||||
|
|
||||||
export type DateFormatValue =
|
export type DateFormatValue =
|
||||||
| 'DD/MM/YYYY'
|
| 'DD/MM/YYYY'
|
||||||
@@ -45,6 +45,42 @@ export type FiscalYearConventionValue =
|
|||||||
| 'AprilToMarch'
|
| 'AprilToMarch'
|
||||||
| 'JulyToJune';
|
| 'JulyToJune';
|
||||||
|
|
||||||
|
export function mapFiscalYearToMmDd(fiscalYear: string | null | undefined): string {
|
||||||
|
if (!fiscalYear) return '01-01';
|
||||||
|
if (/^\d{2}-\d{2}$/.test(fiscalYear)) return fiscalYear;
|
||||||
|
switch (fiscalYear) {
|
||||||
|
case 'AprilToMarch':
|
||||||
|
return '04-01';
|
||||||
|
case 'JulyToJune':
|
||||||
|
return '07-01';
|
||||||
|
case 'CalendarYear':
|
||||||
|
default:
|
||||||
|
return '01-01';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMmDdToFiscalYear(mmDd: string | null | undefined): FiscalYearConventionValue {
|
||||||
|
if (!mmDd) return 'CalendarYear';
|
||||||
|
if (mmDd === '04-01') return 'AprilToMarch';
|
||||||
|
if (mmDd === '07-01') return 'JulyToJune';
|
||||||
|
if (mmDd === '01-01') return 'CalendarYear';
|
||||||
|
return (mmDd as FiscalYearConventionValue) ?? 'CalendarYear';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapNumberFormatToApi(numberFormat: string | null | undefined): string {
|
||||||
|
if (!numberFormat) return '123,456.78';
|
||||||
|
if (numberFormat === 'OneTwoThreeCommaFourFiveSixPointSevenEight') return '123,456.78';
|
||||||
|
if (numberFormat === 'OneTwoThreePointFourFiveSixCommaSevenEight') return '123.456,78';
|
||||||
|
return numberFormat.length <= 20 ? numberFormat : '123,456.78';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapApiToNumberFormat(numberFormat: string | null | undefined): NumberFormatValue {
|
||||||
|
if (!numberFormat) return 'OneTwoThreeCommaFourFiveSixPointSevenEight';
|
||||||
|
if (numberFormat === '123,456.78') return 'OneTwoThreeCommaFourFiveSixPointSevenEight';
|
||||||
|
if (numberFormat === '123.456,78') return 'OneTwoThreePointFourFiveSixCommaSevenEight';
|
||||||
|
return (numberFormat as NumberFormatValue) ?? 'OneTwoThreeCommaFourFiveSixPointSevenEight';
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrganizationLocalizationValue {
|
export interface OrganizationLocalizationValue {
|
||||||
readonly timeZone: OnboardingLookupValue;
|
readonly timeZone: OnboardingLookupValue;
|
||||||
readonly currency: OnboardingLookupValue;
|
readonly currency: OnboardingLookupValue;
|
||||||
@@ -52,16 +88,45 @@ export interface OrganizationLocalizationValue {
|
|||||||
readonly additionalLanguageIds: readonly string[];
|
readonly additionalLanguageIds: readonly string[];
|
||||||
readonly additionalLanguageSelections: readonly OnboardingLookupValue[];
|
readonly additionalLanguageSelections: readonly OnboardingLookupValue[];
|
||||||
readonly dateFormat: DateFormatValue;
|
readonly dateFormat: DateFormatValue;
|
||||||
readonly timeFormat: TimeFormatValue;
|
readonly timeFormat: TimeFormatValue | number | null; // 0 = TwelveHour, 1 = TwentyFourHour
|
||||||
readonly numberFormat: NumberFormatValue;
|
readonly numberFormat: NumberFormatValue;
|
||||||
readonly fiscalYearConvention: FiscalYearConventionValue;
|
readonly fiscalYearConvention: FiscalYearConventionValue;
|
||||||
readonly localizationDefaultsCountryId: string | null;
|
readonly localizationDefaultsCountryId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrganizationLicenseType = 'Trial' | 'Paid';
|
export type OrganizationLicenseType = 0 | 1 | 'Trial' | 'Paid';
|
||||||
|
|
||||||
|
export function mapLicenseTypeToApi(licenseType: unknown): number {
|
||||||
|
if (licenseType === 1 || licenseType === '1' || licenseType === 'Paid') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapApiToLicenseType(licenseType: unknown): OrganizationLicenseType {
|
||||||
|
if (licenseType === 1 || licenseType === '1' || licenseType === 'Paid') {
|
||||||
|
return 'Paid';
|
||||||
|
}
|
||||||
|
return 'Trial';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionPlanDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
maxCompanies?: number;
|
||||||
|
maxUsers?: number;
|
||||||
|
maxStorageGb?: number;
|
||||||
|
defaultTrialDays?: number | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrganizationPlanLookupValue extends OnboardingLookupValue {
|
export interface OrganizationPlanLookupValue extends OnboardingLookupValue {
|
||||||
readonly code?: string | null;
|
readonly code?: string | null;
|
||||||
|
readonly maxCompanies?: number;
|
||||||
|
readonly maxUsers?: number;
|
||||||
|
readonly maxStorageGb?: number;
|
||||||
|
readonly defaultTrialDays?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizationPlanLimitsValue {
|
export interface OrganizationPlanLimitsValue {
|
||||||
@@ -108,6 +173,7 @@ export interface OrganizationPlanDefaults {
|
|||||||
readonly maximumCompanies: number;
|
readonly maximumCompanies: number;
|
||||||
readonly maximumUsers: number;
|
readonly maximumUsers: number;
|
||||||
readonly maximumStorageGb: number;
|
readonly maximumStorageGb: number;
|
||||||
|
readonly defaultTrialDays?: number | null;
|
||||||
readonly limitsEditable: boolean;
|
readonly limitsEditable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,4 +182,165 @@ export interface OnboardingStepForm<TValue> {
|
|||||||
getValue(): TValue;
|
getValue(): TValue;
|
||||||
getDraftValue(): Partial<TValue>;
|
getDraftValue(): Partial<TValue>;
|
||||||
patchValue(value: Partial<TValue>): void;
|
patchValue(value: Partial<TValue>): void;
|
||||||
|
markAsUntouched?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrganizationRequest {
|
||||||
|
name: string;
|
||||||
|
shortName?: string | null;
|
||||||
|
localLanguageName?: string | null;
|
||||||
|
orgType?: number | null;
|
||||||
|
industryId?: string | null; // GUID string
|
||||||
|
countryId?: string | null; // GUID string
|
||||||
|
markComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrganizationBasicsApiRequest {
|
||||||
|
Name?: string | null;
|
||||||
|
OrganizationName?: string | null;
|
||||||
|
ShortName?: string | null;
|
||||||
|
LocalLanguageName?: string | null;
|
||||||
|
OrgType?: number | null;
|
||||||
|
OrganizationTypeId?: number | null;
|
||||||
|
IndustryId?: string | null;
|
||||||
|
CountryId?: string | null;
|
||||||
|
RegistrationCountryId?: string | null;
|
||||||
|
MarkComplete: boolean;
|
||||||
|
}
|
||||||
|
export type UpdateBasicsStepRequest = UpdateOrganizationBasicsApiRequest;
|
||||||
|
export type BasicsStepRequest = UpdateOrganizationBasicsApiRequest;
|
||||||
|
|
||||||
|
export function mapBasicsStepToApiRequest(
|
||||||
|
basics: Partial<OrganizationBasicsValue>,
|
||||||
|
markComplete: boolean
|
||||||
|
): UpdateOrganizationBasicsApiRequest {
|
||||||
|
const orgTypeParsed = basics.organizationType?.id != null && !isNaN(Number(basics.organizationType.id))
|
||||||
|
? Number(basics.organizationType.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const orgName = basics.organizationName ? basics.organizationName.trim() : null;
|
||||||
|
const countryId = basics.registrationCountry?.id ? basics.registrationCountry.id : null;
|
||||||
|
const industryId = basics.industry?.id ? basics.industry.id : null;
|
||||||
|
const shortName = basics.shortName ? basics.shortName.trim() : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Name: orgName ?? (markComplete ? '' : null),
|
||||||
|
OrganizationName: orgName ?? (markComplete ? '' : null),
|
||||||
|
ShortName: shortName ?? (markComplete ? '' : null),
|
||||||
|
LocalLanguageName: basics.localLanguageName ? basics.localLanguageName.trim() : null,
|
||||||
|
OrgType: orgTypeParsed,
|
||||||
|
OrganizationTypeId: orgTypeParsed,
|
||||||
|
IndustryId: industryId,
|
||||||
|
CountryId: countryId,
|
||||||
|
RegistrationCountryId: countryId,
|
||||||
|
MarkComplete: markComplete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrganizationLocalizationApiRequest {
|
||||||
|
DefaultTimezoneId?: string | null;
|
||||||
|
DefaultCurrencyId?: string | null;
|
||||||
|
DefaultLanguageId?: string | null;
|
||||||
|
AdditionalLanguageIds?: readonly string[];
|
||||||
|
DateFormat?: string | null;
|
||||||
|
TimeFormat?: number | null; // 0 = TwelveHour, 1 = TwentyFourHour
|
||||||
|
NumberFormat?: string | null;
|
||||||
|
FiscalYearStart?: string | null;
|
||||||
|
MarkComplete: boolean;
|
||||||
|
}
|
||||||
|
export type UpdateLocalizationStepRequest = UpdateOrganizationLocalizationApiRequest;
|
||||||
|
export type LocalizationStepRequest = UpdateOrganizationLocalizationApiRequest;
|
||||||
|
|
||||||
|
export function mapLocalizationStepToApiRequest(
|
||||||
|
loc: Partial<OrganizationLocalizationValue>,
|
||||||
|
markComplete: boolean
|
||||||
|
): UpdateOrganizationLocalizationApiRequest {
|
||||||
|
const timeFormatNumeric =
|
||||||
|
typeof loc.timeFormat === 'number'
|
||||||
|
? loc.timeFormat
|
||||||
|
: loc.timeFormat === 'TwentyFourHour' || (loc.timeFormat as any) === '1'
|
||||||
|
? 1
|
||||||
|
: loc.timeFormat === 'TwelveHour' || (loc.timeFormat as any) === '0'
|
||||||
|
? 0
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
DefaultTimezoneId: loc.timeZone?.id || null,
|
||||||
|
DefaultCurrencyId: loc.currency?.id || null,
|
||||||
|
DefaultLanguageId: loc.defaultLanguage?.id || null,
|
||||||
|
AdditionalLanguageIds: loc.additionalLanguageIds ?? [],
|
||||||
|
DateFormat: loc.dateFormat ? String(loc.dateFormat) : null,
|
||||||
|
TimeFormat: timeFormatNumeric,
|
||||||
|
NumberFormat: loc.numberFormat ? mapNumberFormatToApi(loc.numberFormat) : null,
|
||||||
|
FiscalYearStart: loc.fiscalYearConvention ? mapFiscalYearToMmDd(loc.fiscalYearConvention) : null,
|
||||||
|
MarkComplete: markComplete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrganizationPlanApiRequest {
|
||||||
|
PlanId?: string | null;
|
||||||
|
LicenseType?: number | null; // 0 = Trial, 1 = Paid
|
||||||
|
MaxCompanies?: number | null;
|
||||||
|
MaxUsers?: number | null;
|
||||||
|
MaxStorageGb?: number | null;
|
||||||
|
GoLiveDate?: string | null;
|
||||||
|
SystemAccessFrom?: string | null;
|
||||||
|
SystemAccessTo?: string | null;
|
||||||
|
MarkComplete: boolean;
|
||||||
|
}
|
||||||
|
export type UpdatePlanStepRequest = UpdateOrganizationPlanApiRequest;
|
||||||
|
export type PlanStepRequest = UpdateOrganizationPlanApiRequest;
|
||||||
|
|
||||||
|
export function mapPlanStepToApiRequest(
|
||||||
|
plan: Partial<OrganizationPlanLimitsValue>,
|
||||||
|
markComplete: boolean
|
||||||
|
): UpdateOrganizationPlanApiRequest {
|
||||||
|
const licenseTypeNumeric = plan.licenseType != null ? mapLicenseTypeToApi(plan.licenseType) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
PlanId: plan.subscriptionPlan?.id || null,
|
||||||
|
LicenseType: licenseTypeNumeric,
|
||||||
|
MaxCompanies: plan.maximumCompanies ?? null,
|
||||||
|
MaxUsers: plan.maximumUsers ?? null,
|
||||||
|
MaxStorageGb: plan.maximumStorageGb ?? null,
|
||||||
|
GoLiveDate: plan.goLiveDate || null,
|
||||||
|
SystemAccessFrom: plan.systemAccessStartDate || null,
|
||||||
|
SystemAccessTo: plan.systemAccessEndDate || null,
|
||||||
|
MarkComplete: markComplete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrganizationAdminContactApiRequest {
|
||||||
|
AdminEmail?: string | null;
|
||||||
|
AdminFullName?: string | null;
|
||||||
|
OrgEmail?: string | null;
|
||||||
|
OrgPhone?: string | null;
|
||||||
|
MarkComplete: boolean;
|
||||||
|
}
|
||||||
|
export type UpdateAdminContactStepRequest = UpdateOrganizationAdminContactApiRequest;
|
||||||
|
export type AdminContactStepRequest = UpdateOrganizationAdminContactApiRequest;
|
||||||
|
|
||||||
|
export function mapAdminContactStepToApiRequest(
|
||||||
|
admin: Partial<OrganizationAdminValue>,
|
||||||
|
markComplete: boolean
|
||||||
|
): UpdateOrganizationAdminContactApiRequest {
|
||||||
|
return {
|
||||||
|
AdminEmail: admin.administratorEmail || null,
|
||||||
|
AdminFullName: admin.administratorFullName || null,
|
||||||
|
OrgEmail: admin.organizationEmail || null,
|
||||||
|
OrgPhone: admin.organizationPhone || null,
|
||||||
|
MarkComplete: markComplete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrganizationServerDraftResponse {
|
||||||
|
id: string;
|
||||||
|
code?: string | null;
|
||||||
|
status: string;
|
||||||
|
currentStepIndex?: number;
|
||||||
|
completedStepIndexes?: readonly number[];
|
||||||
|
basics?: OrganizationBasicsValue | Partial<OrganizationBasicsValue> | null;
|
||||||
|
localization?: OrganizationLocalizationValue | Partial<OrganizationLocalizationValue> | null;
|
||||||
|
planLimits?: OrganizationPlanLimitsValue | Partial<OrganizationPlanLimitsValue> | null;
|
||||||
|
admin?: OrganizationAdminValue | Partial<OrganizationAdminValue> | null;
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
<div class="mb-4 sm:mb-6">
|
|
||||||
<h1 class="text-xl font-semibold text-defaulttextcolor">
|
|
||||||
Create Organization
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="rounded-xl border border-defaultborder bg-white
|
class="rounded-xl border border-defaultborder bg-white
|
||||||
|
|||||||
+152
-41
@@ -8,9 +8,9 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { catchError, finalize, of } from 'rxjs';
|
import { catchError, finalize, Observable, of, switchMap } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
OnboardingStep,
|
OnboardingStep,
|
||||||
OnboardingStepper
|
OnboardingStepper
|
||||||
@@ -19,10 +19,18 @@ import { OrganizationBasicsStepComponent } from './steps/organization-basics/org
|
|||||||
import { OrganizationLocalizationStepComponent } from './steps/organization-localization/organization-localization';
|
import { OrganizationLocalizationStepComponent } from './steps/organization-localization/organization-localization';
|
||||||
import { OrganizationPlanLimitsStepComponent } from './steps/organization-plan-limits/organization-plan-limits';
|
import { OrganizationPlanLimitsStepComponent } from './steps/organization-plan-limits/organization-plan-limits';
|
||||||
import { OrganizationAdminStepComponent } from './steps/organization-admin/organization-admin';
|
import { OrganizationAdminStepComponent } from './steps/organization-admin/organization-admin';
|
||||||
import { OrganizationOnboardingStateService } from './services/organization-onboarding-state.service';
|
import { OrganizationOnboardingStateService } from './data-access/services/organization-onboarding-state.service';
|
||||||
import { OrganizationOnboardingService } from './services/organization-onboarding.service';
|
import { OrganizationOnboardingService } from './data-access/services/organization-onboarding.service';
|
||||||
import { ConfirmDialog } from '../../../shared/components/confirm-dialog/confirm-dialog';
|
import { ConfirmDialog } from '../../../shared/components/confirm-dialog/confirm-dialog';
|
||||||
import { OnboardingStepForm } from './models/organization-onboarding.model';
|
import {
|
||||||
|
OnboardingStepForm,
|
||||||
|
OrganizationAdminValue,
|
||||||
|
OrganizationServerDraftResponse,
|
||||||
|
mapAdminContactStepToApiRequest,
|
||||||
|
mapBasicsStepToApiRequest,
|
||||||
|
mapLocalizationStepToApiRequest,
|
||||||
|
mapPlanStepToApiRequest,
|
||||||
|
} from './models/organization-onboarding.model';
|
||||||
import { OrganizationProvisioningRequest } from './models/organization-provisioning.model';
|
import { OrganizationProvisioningRequest } from './models/organization-provisioning.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -44,6 +52,7 @@ import { OrganizationProvisioningRequest } from './models/organization-provision
|
|||||||
export class OrganizationOnboarding {
|
export class OrganizationOnboarding {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
readonly stateService = inject(OrganizationOnboardingStateService);
|
readonly stateService = inject(OrganizationOnboardingStateService);
|
||||||
private readonly onboardingService = inject(OrganizationOnboardingService);
|
private readonly onboardingService = inject(OrganizationOnboardingService);
|
||||||
@@ -75,25 +84,27 @@ export class OrganizationOnboarding {
|
|||||||
);
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.onboardingService.loadDraft()
|
const draftId = this.route.snapshot.queryParamMap.get('id');
|
||||||
|
|
||||||
|
if (draftId) {
|
||||||
|
this.onboardingService.getOrganizationById(draftId)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError(() => {
|
catchError(err => {
|
||||||
this.toastr.error('Unable to restore the onboarding draft.', 'Draft restore failed');
|
const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to restore draft from server.';
|
||||||
|
this.toastr.error(errorMsg, 'Draft Restore Failed');
|
||||||
return of(null);
|
return of(null);
|
||||||
}),
|
}),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
)
|
)
|
||||||
.subscribe(draft => {
|
.subscribe(draft => {
|
||||||
if (!draft) {
|
if (draft) {
|
||||||
this.savedDraftSnapshot = this.serializeDraftState();
|
this.stateService.restoreServerDraft(draft);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stateService.restoreDraft(draft);
|
|
||||||
this.savedDraftSnapshot = this.serializeDraftState();
|
this.savedDraftSnapshot = this.serializeDraftState();
|
||||||
this.hydrateActiveStep();
|
this.hydrateActiveStep();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onStepSelected(index: number): void {
|
onStepSelected(index: number): void {
|
||||||
if (!this.stateService.canOpenStep(index)) {
|
if (!this.stateService.canOpenStep(index)) {
|
||||||
@@ -132,8 +143,21 @@ export class OrganizationOnboarding {
|
|||||||
|
|
||||||
this.storeValidatedActiveStep(activeStep);
|
this.storeValidatedActiveStep(activeStep);
|
||||||
this.stateService.markStepCompleted(currentIndex);
|
this.stateService.markStepCompleted(currentIndex);
|
||||||
|
|
||||||
|
this.persistStepToServer(currentIndex, true)
|
||||||
|
.pipe(
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
this.stateService.setCurrentStepIndex(currentIndex + 1);
|
this.stateService.setCurrentStepIndex(currentIndex + 1);
|
||||||
this.hydrateActiveStep();
|
this.hydrateActiveStep();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
const errorMsg = err?.error?.detail || err?.error?.message || 'Failed to save step progress to server.';
|
||||||
|
this.toastr.error(errorMsg, 'Step Persistence Failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onSaveDraft(): void {
|
onSaveDraft(): void {
|
||||||
@@ -141,11 +165,17 @@ export class OrganizationOnboarding {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.captureActiveStepDraft();
|
const activeStep = this.getActiveStep();
|
||||||
|
if (!activeStep?.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.storeValidatedActiveStep(activeStep);
|
||||||
|
|
||||||
this.savingDraft.set(true);
|
this.savingDraft.set(true);
|
||||||
|
|
||||||
this.onboardingService.saveDraft(this.stateService.buildDraft())
|
const currentIndex = this.currentStepIndex();
|
||||||
|
this.persistStepToServer(currentIndex, false)
|
||||||
.pipe(
|
.pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
this.savingDraft.set(false);
|
this.savingDraft.set(false);
|
||||||
@@ -155,10 +185,11 @@ export class OrganizationOnboarding {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.savedDraftSnapshot = this.serializeDraftState();
|
this.savedDraftSnapshot = this.serializeDraftState();
|
||||||
this.toastr.success('Onboarding draft saved successfully.', 'Draft saved');
|
this.toastr.success('Onboarding draft saved successfully on server.', 'Draft Saved');
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (err) => {
|
||||||
this.toastr.error('Unable to save the onboarding draft.', 'Draft save failed');
|
const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to save onboarding draft to server.';
|
||||||
|
this.toastr.error(errorMsg, 'Draft Save Failed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -174,7 +205,8 @@ export class OrganizationOnboarding {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stateService.updateAdmin(activeStep.getValue());
|
const adminValue = activeStep.getValue();
|
||||||
|
this.stateService.updateAdmin(adminValue);
|
||||||
this.stateService.markStepCompleted(3);
|
this.stateService.markStepCompleted(3);
|
||||||
|
|
||||||
const data = this.stateService.onboardingData();
|
const data = this.stateService.onboardingData();
|
||||||
@@ -189,7 +221,13 @@ export class OrganizationOnboarding {
|
|||||||
planLimits: data.planLimits,
|
planLimits: data.planLimits,
|
||||||
admin: data.admin,
|
admin: data.admin,
|
||||||
});
|
});
|
||||||
void this.finishConfirmDialog()?.open();
|
|
||||||
|
const confirmModal = this.finishConfirmDialog();
|
||||||
|
if (confirmModal) {
|
||||||
|
void confirmModal.open();
|
||||||
|
} else {
|
||||||
|
this.onProvisioningConfirmed();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onCancel(): void {
|
onCancel(): void {
|
||||||
@@ -199,20 +237,106 @@ export class OrganizationOnboarding {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.leaveOnboarding(false);
|
void this.leaveOnboarding();
|
||||||
}
|
}
|
||||||
|
|
||||||
onDiscardAndLeave(): void {
|
onDiscardAndLeave(): void {
|
||||||
void this.leaveOnboarding(true);
|
void this.leaveOnboarding();
|
||||||
}
|
}
|
||||||
|
|
||||||
onProvisioningConfirmed(): void {
|
onProvisioningConfirmed(): void {
|
||||||
this.toastr.info(
|
const orgId = this.stateService.organizationId();
|
||||||
'The provisioning request is ready. Backend provisioning is not connected yet.',
|
const adminData = this.stateService.admin();
|
||||||
'Provisioning pending'
|
|
||||||
|
if (!orgId || !adminData) {
|
||||||
|
this.toastr.error('Missing organization ID or admin contact details.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.executeFinishAndProvisionFlow(orgId, adminData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeFinishAndProvisionFlow(orgId: string, adminData: OrganizationAdminValue): void {
|
||||||
|
this.finishing.set(true);
|
||||||
|
|
||||||
|
const adminPayload = mapAdminContactStepToApiRequest(adminData, true);
|
||||||
|
|
||||||
|
this.onboardingService.updateAdminContact(orgId, adminPayload)
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => this.onboardingService.finishOnboarding(orgId)),
|
||||||
|
finalize(() => {
|
||||||
|
this.finishing.set(false);
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastr.success('Organization onboarding submitted successfully. Status updated to AwaitingDatabase.', 'Onboarding Completed');
|
||||||
|
this.stateService.clear();
|
||||||
|
void this.router.navigate(['/organizations/list']);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
const errorMsg = err?.error?.detail || err?.error?.message || 'Server error during organization provisioning.';
|
||||||
|
this.toastr.error(errorMsg, 'Provisioning Failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistStepToServer(stepIndex: number, markComplete: boolean): Observable<OrganizationServerDraftResponse> {
|
||||||
|
const orgId = this.stateService.organizationId();
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
const basicsDraft = this.basicsStep()?.getDraftValue();
|
||||||
|
const orgTypeParsed = basicsDraft?.organizationType?.id != null && !isNaN(Number(basicsDraft.organizationType.id))
|
||||||
|
? Number(basicsDraft.organizationType.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return this.onboardingService.createDraft({
|
||||||
|
name: basicsDraft?.organizationName?.trim() || 'New Organization Draft',
|
||||||
|
shortName: basicsDraft?.shortName?.trim() || null,
|
||||||
|
localLanguageName: basicsDraft?.localLanguageName?.trim() || null,
|
||||||
|
orgType: orgTypeParsed,
|
||||||
|
countryId: basicsDraft?.registrationCountry?.id || null,
|
||||||
|
industryId: basicsDraft?.industry?.id || null,
|
||||||
|
markComplete: false,
|
||||||
|
}).pipe(
|
||||||
|
switchMap(res => {
|
||||||
|
this.stateService.setOrganizationId(res.id);
|
||||||
|
return this.updateServerStep(res.id, stepIndex, markComplete);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.updateServerStep(orgId, stepIndex, markComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateServerStep(orgId: string, stepIndex: number, markComplete: boolean): Observable<OrganizationServerDraftResponse> {
|
||||||
|
switch (stepIndex) {
|
||||||
|
case 0: {
|
||||||
|
const basics = this.basicsStep()?.getDraftValue() ?? {};
|
||||||
|
const requestPayload = mapBasicsStepToApiRequest(basics, markComplete);
|
||||||
|
return this.onboardingService.updateBasics(orgId, requestPayload);
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
const loc = this.localizationStep()?.getDraftValue() ?? {};
|
||||||
|
const requestPayload = mapLocalizationStepToApiRequest(loc, markComplete);
|
||||||
|
return this.onboardingService.updateLocalization(orgId, requestPayload);
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
const plan = this.planLimitsStep()?.getDraftValue() ?? {};
|
||||||
|
const requestPayload = mapPlanStepToApiRequest(plan, markComplete);
|
||||||
|
return this.onboardingService.updatePlan(orgId, requestPayload);
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
const admin = this.adminStep()?.getDraftValue() ?? {};
|
||||||
|
const requestPayload = mapAdminContactStepToApiRequest(admin, markComplete);
|
||||||
|
return this.onboardingService.updateAdminContact(orgId, requestPayload);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return of({ id: orgId, status: 'Draft' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
|
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
|
||||||
switch (this.currentStepIndex()) {
|
switch (this.currentStepIndex()) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -329,21 +453,8 @@ export class OrganizationOnboarding {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async leaveOnboarding(clearDraft: boolean): Promise<void> {
|
private async leaveOnboarding(): Promise<void> {
|
||||||
if (clearDraft) {
|
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
this.onboardingService.clearDraft()
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe({ next: resolve, error: reject });
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
this.toastr.error('Unable to clear the saved draft.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stateService.clear();
|
this.stateService.clear();
|
||||||
await this.router.navigate(['/organizations']);
|
await this.router.navigate(['/organizations/list']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-335
@@ -1,335 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Observable, map, of, throwError } from 'rxjs';
|
|
||||||
import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models';
|
|
||||||
import {
|
|
||||||
CountryLookupDto,
|
|
||||||
CountryService,
|
|
||||||
} from '../../../global-masters/countries/public-api';
|
|
||||||
import {
|
|
||||||
CurrencyLookupDto,
|
|
||||||
CurrencyService,
|
|
||||||
} from '../../../global-masters/currencies/public-api';
|
|
||||||
import {
|
|
||||||
LanguageLookupDto,
|
|
||||||
LanguageService,
|
|
||||||
} from '../../../global-masters/languages/public-api';
|
|
||||||
import {
|
|
||||||
TimezoneLookupDto,
|
|
||||||
TimezoneService,
|
|
||||||
} from '../../../global-masters/timezones/public-api';
|
|
||||||
import { OrganizationOnboardingDraft } from '../models/organization-draft.model';
|
|
||||||
import {
|
|
||||||
CountryLocalizationDefaults,
|
|
||||||
DateFormatValue,
|
|
||||||
FiscalYearConventionValue,
|
|
||||||
NumberFormatValue,
|
|
||||||
OnboardingLookupValue,
|
|
||||||
OrganizationLicenseType,
|
|
||||||
OrganizationPlanDefaults,
|
|
||||||
OrganizationPlanLookupValue,
|
|
||||||
TimeFormatValue,
|
|
||||||
} from '../models/organization-onboarding.model';
|
|
||||||
|
|
||||||
const ORGANIZATION_ONBOARDING_DRAFT_KEY = 'organization-onboarding-draft-v1';
|
|
||||||
|
|
||||||
const ORGANIZATION_TYPE_OPTIONS: readonly OnboardingLookupValue[] = [
|
|
||||||
{ id: 'enterprise', label: 'Enterprise' },
|
|
||||||
{ id: 'group', label: 'Group' },
|
|
||||||
{ id: 'non-profit', label: 'Non Profit' },
|
|
||||||
{ id: 'public-sector', label: 'Public Sector' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const INDUSTRY_OPTIONS: readonly OnboardingLookupValue[] = [
|
|
||||||
{ id: 'healthcare', label: 'Healthcare' },
|
|
||||||
{ id: 'manufacturing', label: 'Manufacturing' },
|
|
||||||
{ id: 'professional-services', label: 'Professional Services' },
|
|
||||||
{ id: 'retail', label: 'Retail' },
|
|
||||||
{ id: 'technology', label: 'Technology' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUBSCRIPTION_PLAN_OPTIONS: readonly OrganizationPlanLookupValue[] = [
|
|
||||||
{ id: 'starter', label: 'Starter', code: 'STARTER' },
|
|
||||||
{ id: 'growth', label: 'Growth', code: 'GROWTH' },
|
|
||||||
{ id: 'enterprise', label: 'Enterprise', code: 'ENTERPRISE' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PLAN_DEFAULTS: readonly OrganizationPlanDefaults[] = [
|
|
||||||
{
|
|
||||||
plan: { id: 'starter', label: 'Starter', code: 'STARTER' },
|
|
||||||
licenseType: 'Trial',
|
|
||||||
maximumCompanies: 1,
|
|
||||||
maximumUsers: 10,
|
|
||||||
maximumStorageGb: 10,
|
|
||||||
limitsEditable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
plan: { id: 'growth', label: 'Growth', code: 'GROWTH' },
|
|
||||||
licenseType: 'Paid',
|
|
||||||
maximumCompanies: 5,
|
|
||||||
maximumUsers: 100,
|
|
||||||
maximumStorageGb: 100,
|
|
||||||
limitsEditable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
plan: { id: 'enterprise', label: 'Enterprise', code: 'ENTERPRISE' },
|
|
||||||
licenseType: 'Paid',
|
|
||||||
maximumCompanies: 25,
|
|
||||||
maximumUsers: 1000,
|
|
||||||
maximumStorageGb: 500,
|
|
||||||
limitsEditable: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const DATE_FORMAT_OPTIONS: readonly FormSelectOption<DateFormatValue>[] = [
|
|
||||||
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
|
|
||||||
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
|
|
||||||
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TIME_FORMAT_OPTIONS: readonly FormSelectOption<TimeFormatValue>[] = [
|
|
||||||
{ value: 'TwelveHour', label: '12 hour' },
|
|
||||||
{ value: 'TwentyFourHour', label: '24 hour' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const NUMBER_FORMAT_OPTIONS: readonly FormSelectOption<NumberFormatValue>[] = [
|
|
||||||
{ value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' },
|
|
||||||
{ value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FISCAL_YEAR_OPTIONS: readonly FormSelectOption<FiscalYearConventionValue>[] = [
|
|
||||||
{ value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' },
|
|
||||||
{ value: 'AprilToMarch', label: 'April - March' },
|
|
||||||
{ value: 'JulyToJune', label: 'July - June' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const LICENSE_TYPE_OPTIONS: readonly FormSelectOption<OrganizationLicenseType>[] = [
|
|
||||||
{ value: 'Trial', label: 'Trial' },
|
|
||||||
{ value: 'Paid', label: 'Paid' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
|
|
||||||
{
|
|
||||||
countryId: 'IN',
|
|
||||||
timeZone: { id: 'asia-kolkata', label: 'India Standard Time', secondaryLabel: 'Asia/Kolkata' },
|
|
||||||
currency: { id: 'inr', label: 'Indian Rupee', secondaryLabel: 'INR' },
|
|
||||||
defaultLanguage: { id: 'en-in', label: 'English', secondaryLabel: 'en-IN' },
|
|
||||||
dateFormat: 'DD/MM/YYYY',
|
|
||||||
timeFormat: 'TwelveHour',
|
|
||||||
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
|
||||||
fiscalYearConvention: 'AprilToMarch',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
countryId: 'US',
|
|
||||||
timeZone: { id: 'america-new-york', label: 'Eastern Time', secondaryLabel: 'America/New_York' },
|
|
||||||
currency: { id: 'usd', label: 'US Dollar', secondaryLabel: 'USD' },
|
|
||||||
defaultLanguage: { id: 'en-us', label: 'English', secondaryLabel: 'en-US' },
|
|
||||||
dateFormat: 'MM/DD/YYYY',
|
|
||||||
timeFormat: 'TwelveHour',
|
|
||||||
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
|
||||||
fiscalYearConvention: 'CalendarYear',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OrganizationOnboardingService {
|
|
||||||
private readonly countryService = inject(CountryService);
|
|
||||||
private readonly currencyService = inject(CurrencyService);
|
|
||||||
private readonly languageService = inject(LanguageService);
|
|
||||||
private readonly timezoneService = inject(TimezoneService);
|
|
||||||
|
|
||||||
searchOrganizationTypes(term: string | null, limit = 10): Observable<readonly OnboardingLookupValue[]> {
|
|
||||||
return of(this.filterLookupValues(ORGANIZATION_TYPE_OPTIONS, term, limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveOrganizationType(id: string): Observable<OnboardingLookupValue | null> {
|
|
||||||
return of(ORGANIZATION_TYPE_OPTIONS.find(option => option.id === id) ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchIndustries(term: string | null, limit = 10): Observable<readonly OnboardingLookupValue[]> {
|
|
||||||
return of(this.filterLookupValues(INDUSTRY_OPTIONS, term, limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveIndustry(id: string): Observable<OnboardingLookupValue | null> {
|
|
||||||
return of(INDUSTRY_OPTIONS.find(option => option.id === id) ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchRegistrationCountries(term: string | null, limit = 10): Observable<readonly CountryLookupDto[]> {
|
|
||||||
return this.countryService.autocomplete(term ?? '', limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveCountry(id: string): Observable<CountryLookupDto | null> {
|
|
||||||
return this.countryService.getCountryById(id).pipe(
|
|
||||||
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchTimezones(term: string | null, limit = 10): Observable<readonly TimezoneLookupDto[]> {
|
|
||||||
return this.timezoneService.autocomplete(term, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveTimezone(id: string): Observable<TimezoneLookupDto | null> {
|
|
||||||
return this.timezoneService.getById(id).pipe(
|
|
||||||
map(timezone => ({ id: timezone.id, ianaId: timezone.ianaId, displayName: timezone.displayName }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchCurrencies(term: string | null, limit = 10): Observable<readonly CurrencyLookupDto[]> {
|
|
||||||
return this.currencyService.autocomplete(term, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveCurrency(id: string): Observable<CurrencyLookupDto | null> {
|
|
||||||
return this.currencyService.getCurrencyById(id).pipe(
|
|
||||||
map(currency => ({ id: currency.id, code: currency.code, name: currency.name, symbol: currency.symbol }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchLanguages(term: string | null, limit = 10): Observable<readonly LanguageLookupDto[]> {
|
|
||||||
return this.languageService.autocomplete(term, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveLanguage(id: string): Observable<LanguageLookupDto | null> {
|
|
||||||
return this.languageService.getById(id).pipe(
|
|
||||||
map(language => ({
|
|
||||||
id: language.id,
|
|
||||||
code: language.code,
|
|
||||||
name: language.name,
|
|
||||||
nativeName: language.nativeName,
|
|
||||||
isRightToLeft: language.isRightToLeft,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
loadLanguageOptions(limit = 50): Observable<readonly FormSelectOption<string>[]> {
|
|
||||||
return this.languageService.autocomplete('', limit).pipe(
|
|
||||||
map(items => items.map(item => ({
|
|
||||||
value: item.id,
|
|
||||||
label: `${item.code} - ${item.name}`,
|
|
||||||
})))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getDateFormatOptions(): readonly FormSelectOption<DateFormatValue>[] {
|
|
||||||
return DATE_FORMAT_OPTIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTimeFormatOptions(): readonly FormSelectOption<TimeFormatValue>[] {
|
|
||||||
return TIME_FORMAT_OPTIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
getNumberFormatOptions(): readonly FormSelectOption<NumberFormatValue>[] {
|
|
||||||
return NUMBER_FORMAT_OPTIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFiscalYearOptions(): readonly FormSelectOption<FiscalYearConventionValue>[] {
|
|
||||||
return FISCAL_YEAR_OPTIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
getLicenseTypeOptions(): readonly FormSelectOption<OrganizationLicenseType>[] {
|
|
||||||
return LICENSE_TYPE_OPTIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchSubscriptionPlans(term: string | null, limit = 10): Observable<readonly OrganizationPlanLookupValue[]> {
|
|
||||||
return of(this.filterLookupValues(SUBSCRIPTION_PLAN_OPTIONS, term, limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveSubscriptionPlan(id: string): Observable<OrganizationPlanLookupValue | null> {
|
|
||||||
return of(SUBSCRIPTION_PLAN_OPTIONS.find(option => option.id === id) ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
getPlanDefaults(planId: string): Observable<OrganizationPlanDefaults | null> {
|
|
||||||
return of(PLAN_DEFAULTS.find(item => item.plan.id === planId) ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCountryLocalizationDefaults(countryIso2: string | null): Observable<CountryLocalizationDefaults | null> {
|
|
||||||
if (!countryIso2?.trim()) {
|
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return of(LOCALIZATION_DEFAULTS.find(item => item.countryId === countryIso2.trim().toUpperCase()) ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadDraft(): Observable<OrganizationOnboardingDraft | null> {
|
|
||||||
try {
|
|
||||||
const rawDraft = sessionStorage.getItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
|
||||||
|
|
||||||
if (!rawDraft) {
|
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedDraft = JSON.parse(rawDraft) as Partial<OrganizationOnboardingDraft>;
|
|
||||||
|
|
||||||
if (!this.isValidDraft(parsedDraft)) {
|
|
||||||
sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return of(parsedDraft);
|
|
||||||
} catch (error) {
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
|
||||||
} catch {
|
|
||||||
// The original storage or parsing error is the useful error to report.
|
|
||||||
}
|
|
||||||
return throwError(() => error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDraft(draft: OrganizationOnboardingDraft): Observable<void> {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(ORGANIZATION_ONBOARDING_DRAFT_KEY, JSON.stringify(draft));
|
|
||||||
return of(void 0);
|
|
||||||
} catch (error) {
|
|
||||||
return throwError(() => error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearDraft(): Observable<void> {
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
|
||||||
return of(void 0);
|
|
||||||
} catch (error) {
|
|
||||||
return throwError(() => error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private filterLookupValues<TValue extends OnboardingLookupValue>(
|
|
||||||
source: readonly TValue[],
|
|
||||||
term: string | null,
|
|
||||||
limit: number,
|
|
||||||
): readonly TValue[] {
|
|
||||||
const normalizedTerm = term?.trim().toLowerCase() ?? '';
|
|
||||||
|
|
||||||
return source
|
|
||||||
.filter(option => {
|
|
||||||
if (!normalizedTerm) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [option.label, option.secondaryLabel]
|
|
||||||
.filter((value): value is string => !!value)
|
|
||||||
.some(value => value.toLowerCase().includes(normalizedTerm));
|
|
||||||
})
|
|
||||||
.slice(0, Math.max(1, limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidDraft(value: Partial<OrganizationOnboardingDraft>): value is OrganizationOnboardingDraft {
|
|
||||||
return (
|
|
||||||
value.schemaVersion === 1 &&
|
|
||||||
typeof value.currentStepIndex === 'number' &&
|
|
||||||
Number.isInteger(value.currentStepIndex) &&
|
|
||||||
value.currentStepIndex >= 0 &&
|
|
||||||
value.currentStepIndex <= 3 &&
|
|
||||||
Array.isArray(value.completedStepIndexes) &&
|
|
||||||
value.completedStepIndexes.every(index => Number.isInteger(index) && index >= 0 && index <= 3) &&
|
|
||||||
typeof value.savedAt === 'string' &&
|
|
||||||
(value.draftId === null || typeof value.draftId === 'string') &&
|
|
||||||
this.isDraftSection(value.basics) &&
|
|
||||||
this.isDraftSection(value.localization) &&
|
|
||||||
this.isDraftSection(value.planLimits) &&
|
|
||||||
this.isDraftSection(value.admin)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isDraftSection(value: unknown): boolean {
|
|
||||||
return value === null || (typeof value === 'object' && !Array.isArray(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-6
@@ -56,6 +56,11 @@ export class OrganizationAdminStepComponent implements OnboardingStepForm<Organi
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markAsUntouched(): void {
|
||||||
|
this.form.markAsUntouched();
|
||||||
|
this.submitAttempted.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
getValue(): OrganizationAdminValue {
|
getValue(): OrganizationAdminValue {
|
||||||
const value = this.form.getRawValue() as OrganizationAdminFormModel;
|
const value = this.form.getRawValue() as OrganizationAdminFormModel;
|
||||||
return this.normalize(value);
|
return this.normalize(value);
|
||||||
@@ -65,13 +70,13 @@ export class OrganizationAdminStepComponent implements OnboardingStepForm<Organi
|
|||||||
return this.normalize(this.form.getRawValue() as OrganizationAdminFormModel);
|
return this.normalize(this.form.getRawValue() as OrganizationAdminFormModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
patchValue(value: Partial<OrganizationAdminValue>): void {
|
patchValue(value: Partial<OrganizationAdminValue> & Record<string, any>): void {
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
organizationEmail: value.organizationEmail ?? '',
|
organizationEmail: value.organizationEmail ?? value['OrgEmail'] ?? value['orgEmail'] ?? '',
|
||||||
organizationPhone: value.organizationPhone ?? '',
|
organizationPhone: value.organizationPhone ?? value['OrgPhone'] ?? value['orgPhone'] ?? '',
|
||||||
administratorFullName: value.administratorFullName ?? '',
|
administratorFullName: value.administratorFullName ?? value['AdminFullName'] ?? value['adminFullName'] ?? '',
|
||||||
administratorEmail: value.administratorEmail ?? '',
|
administratorEmail: value.administratorEmail ?? value['AdminEmail'] ?? value['adminEmail'] ?? '',
|
||||||
administratorMobile: value.administratorMobile ?? '',
|
administratorMobile: value.administratorMobile ?? value['AdminMobile'] ?? value['adminMobile'] ?? '',
|
||||||
}, { emitEvent: false });
|
}, { emitEvent: false });
|
||||||
this.submitAttempted.set(false);
|
this.submitAttempted.set(false);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -71,6 +71,8 @@
|
|||||||
variant="floating"
|
variant="floating"
|
||||||
label="Organization Type"
|
label="Organization Type"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[showDropdownOnFocus]="true"
|
||||||
[searchFn]="searchOrganizationTypes"
|
[searchFn]="searchOrganizationTypes"
|
||||||
[displayWith]="displayLookup"
|
[displayWith]="displayLookup"
|
||||||
[valueWith]="lookupValue"
|
[valueWith]="lookupValue"
|
||||||
@@ -93,6 +95,8 @@
|
|||||||
variant="floating"
|
variant="floating"
|
||||||
label="Industry"
|
label="Industry"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[showDropdownOnFocus]="true"
|
||||||
[searchFn]="searchIndustries"
|
[searchFn]="searchIndustries"
|
||||||
[displayWith]="displayLookup"
|
[displayWith]="displayLookup"
|
||||||
[valueWith]="lookupValue"
|
[valueWith]="lookupValue"
|
||||||
@@ -115,6 +119,8 @@
|
|||||||
variant="floating"
|
variant="floating"
|
||||||
label="Country of Registration"
|
label="Country of Registration"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[showDropdownOnFocus]="true"
|
||||||
[searchFn]="searchCountries"
|
[searchFn]="searchCountries"
|
||||||
[displayWith]="displayCountry"
|
[displayWith]="displayCountry"
|
||||||
[valueWith]="countryValue"
|
[valueWith]="countryValue"
|
||||||
|
|||||||
+37
-7
@@ -5,7 +5,11 @@ import { catchError, map, of } from 'rxjs';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CountryLookupDto,
|
CountryLookupDto,
|
||||||
|
CountryService,
|
||||||
} from '../../../../global-masters/countries/public-api';
|
} from '../../../../global-masters/countries/public-api';
|
||||||
|
import {
|
||||||
|
IndustryApiService,
|
||||||
|
} from '../../../../industries/public-api';
|
||||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
import {
|
import {
|
||||||
AutocompleteDisplayFn,
|
AutocompleteDisplayFn,
|
||||||
@@ -19,7 +23,7 @@ import {
|
|||||||
OnboardingStepForm,
|
OnboardingStepForm,
|
||||||
OrganizationBasicsValue,
|
OrganizationBasicsValue,
|
||||||
} from '../../models/organization-onboarding.model';
|
} from '../../models/organization-onboarding.model';
|
||||||
import { OrganizationOnboardingService } from '../../services/organization-onboarding.service';
|
import { OrganizationOnboardingService } from '../../data-access/services/organization-onboarding.service';
|
||||||
|
|
||||||
interface OrganizationBasicsFormModel {
|
interface OrganizationBasicsFormModel {
|
||||||
readonly organizationName: string;
|
readonly organizationName: string;
|
||||||
@@ -40,6 +44,8 @@ interface OrganizationBasicsFormModel {
|
|||||||
export class OrganizationBasicsStepComponent implements OnboardingStepForm<OrganizationBasicsValue> {
|
export class OrganizationBasicsStepComponent implements OnboardingStepForm<OrganizationBasicsValue> {
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
private readonly onboardingService = inject(OrganizationOnboardingService);
|
private readonly onboardingService = inject(OrganizationOnboardingService);
|
||||||
|
private readonly countryService = inject(CountryService);
|
||||||
|
private readonly industryApiService = inject(IndustryApiService);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
|
||||||
@@ -67,7 +73,12 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
|
|||||||
);
|
);
|
||||||
|
|
||||||
readonly searchIndustries: AutocompleteSearchFn<OnboardingLookupValue> = (term, limit) =>
|
readonly searchIndustries: AutocompleteSearchFn<OnboardingLookupValue> = (term, limit) =>
|
||||||
this.onboardingService.searchIndustries(term, limit).pipe(
|
this.industryApiService.autocomplete(term ?? '', limit).pipe(
|
||||||
|
map(items => items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.industryName,
|
||||||
|
secondaryLabel: item.industryCode,
|
||||||
|
}))),
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.toastr.error('Unable to load industries.');
|
this.toastr.error('Unable to load industries.');
|
||||||
return of<readonly OnboardingLookupValue[]>([]);
|
return of<readonly OnboardingLookupValue[]>([]);
|
||||||
@@ -75,7 +86,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
|
|||||||
);
|
);
|
||||||
|
|
||||||
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> = (term, limit) =>
|
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> = (term, limit) =>
|
||||||
this.onboardingService.searchRegistrationCountries(term, limit).pipe(
|
this.countryService.autocomplete(term ?? '', limit).pipe(
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.toastr.error('Unable to load countries.');
|
this.toastr.error('Unable to load countries.');
|
||||||
return of<readonly CountryLookupDto[]>([]);
|
return of<readonly CountryLookupDto[]>([]);
|
||||||
@@ -90,15 +101,29 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
|
|||||||
readonly resolveOrganizationType: AutocompleteResolveValueFn<OnboardingLookupValue, string> = value =>
|
readonly resolveOrganizationType: AutocompleteResolveValueFn<OnboardingLookupValue, string> = value =>
|
||||||
this.onboardingService.resolveOrganizationType(value);
|
this.onboardingService.resolveOrganizationType(value);
|
||||||
|
|
||||||
readonly resolveIndustry: AutocompleteResolveValueFn<OnboardingLookupValue, string> = value =>
|
readonly resolveIndustry: AutocompleteResolveValueFn<OnboardingLookupValue, string> = value => {
|
||||||
this.onboardingService.resolveIndustry(value);
|
if (!value) return of(null);
|
||||||
|
return this.industryApiService.getIndustryById(value).pipe(
|
||||||
|
map(item => item ? ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.industryName,
|
||||||
|
secondaryLabel: item.industryCode,
|
||||||
|
}) : null),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
|
|
||||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||||
|
|
||||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> = value =>
|
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> = value => {
|
||||||
this.onboardingService.resolveCountry(value);
|
if (!value) return of(null);
|
||||||
|
return this.countryService.getCountryById(value).pipe(
|
||||||
|
map(country => ({ id: country.id, iso2: country.iso2, name: country.name })),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
validate(): boolean {
|
validate(): boolean {
|
||||||
this.submitAttempted.set(true);
|
this.submitAttempted.set(true);
|
||||||
@@ -112,6 +137,11 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markAsUntouched(): void {
|
||||||
|
this.form.markAsUntouched();
|
||||||
|
this.submitAttempted.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
getValue(): OrganizationBasicsValue {
|
getValue(): OrganizationBasicsValue {
|
||||||
const value = this.form.getRawValue() as OrganizationBasicsFormModel;
|
const value = this.form.getRawValue() as OrganizationBasicsFormModel;
|
||||||
const organizationType = this.organizationTypeSelection();
|
const organizationType = this.organizationTypeSelection();
|
||||||
|
|||||||
+6
@@ -7,6 +7,8 @@
|
|||||||
variant="floating"
|
variant="floating"
|
||||||
label="Time Zone"
|
label="Time Zone"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[showDropdownOnFocus]="true"
|
||||||
[searchFn]="searchTimezones"
|
[searchFn]="searchTimezones"
|
||||||
[displayWith]="displayTimezone"
|
[displayWith]="displayTimezone"
|
||||||
[valueWith]="timeZoneValue"
|
[valueWith]="timeZoneValue"
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
variant="floating"
|
variant="floating"
|
||||||
label="Currency"
|
label="Currency"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[showDropdownOnFocus]="true"
|
||||||
[searchFn]="searchCurrencies"
|
[searchFn]="searchCurrencies"
|
||||||
[displayWith]="displayCurrency"
|
[displayWith]="displayCurrency"
|
||||||
[valueWith]="currencyValue"
|
[valueWith]="currencyValue"
|
||||||
@@ -47,6 +51,8 @@
|
|||||||
variant="floating"
|
variant="floating"
|
||||||
label="Default Language"
|
label="Default Language"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[showDropdownOnFocus]="true"
|
||||||
[searchFn]="searchLanguages"
|
[searchFn]="searchLanguages"
|
||||||
[displayWith]="displayLanguage"
|
[displayWith]="displayLanguage"
|
||||||
[valueWith]="languageValue"
|
[valueWith]="languageValue"
|
||||||
|
|||||||
+104
-30
@@ -2,16 +2,19 @@ import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, i
|
|||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, map, of } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CurrencyLookupDto,
|
CurrencyLookupDto,
|
||||||
|
CurrencyService,
|
||||||
} from '../../../../global-masters/currencies/public-api';
|
} from '../../../../global-masters/currencies/public-api';
|
||||||
import {
|
import {
|
||||||
LanguageLookupDto,
|
LanguageLookupDto,
|
||||||
|
LanguageService,
|
||||||
} from '../../../../global-masters/languages/public-api';
|
} from '../../../../global-masters/languages/public-api';
|
||||||
import {
|
import {
|
||||||
TimezoneLookupDto,
|
TimezoneLookupDto,
|
||||||
|
TimezoneService,
|
||||||
} from '../../../../global-masters/timezones/public-api';
|
} from '../../../../global-masters/timezones/public-api';
|
||||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
||||||
@@ -31,8 +34,10 @@ import {
|
|||||||
OnboardingStepForm,
|
OnboardingStepForm,
|
||||||
OrganizationLocalizationValue,
|
OrganizationLocalizationValue,
|
||||||
TimeFormatValue,
|
TimeFormatValue,
|
||||||
|
mapApiToNumberFormat,
|
||||||
|
mapMmDdToFiscalYear,
|
||||||
} from '../../models/organization-onboarding.model';
|
} from '../../models/organization-onboarding.model';
|
||||||
import { OrganizationOnboardingService } from '../../services/organization-onboarding.service';
|
import { OrganizationOnboardingService } from '../../data-access/services/organization-onboarding.service';
|
||||||
|
|
||||||
interface OrganizationLocalizationFormModel {
|
interface OrganizationLocalizationFormModel {
|
||||||
readonly timeZoneId: string | null;
|
readonly timeZoneId: string | null;
|
||||||
@@ -65,6 +70,9 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
private readonly onboardingService = inject(OrganizationOnboardingService);
|
private readonly onboardingService = inject(OrganizationOnboardingService);
|
||||||
|
private readonly timezoneService = inject(TimezoneService);
|
||||||
|
private readonly currencyService = inject(CurrencyService);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
|
||||||
@@ -98,7 +106,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
});
|
});
|
||||||
|
|
||||||
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> = (term, limit) =>
|
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> = (term, limit) =>
|
||||||
this.onboardingService.searchTimezones(term, limit).pipe(
|
this.timezoneService.autocomplete(term ?? '', limit).pipe(
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.toastr.error('Unable to load timezones.');
|
this.toastr.error('Unable to load timezones.');
|
||||||
return of<readonly TimezoneLookupDto[]>([]);
|
return of<readonly TimezoneLookupDto[]>([]);
|
||||||
@@ -106,7 +114,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
);
|
);
|
||||||
|
|
||||||
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
|
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
|
||||||
this.onboardingService.searchCurrencies(term, limit).pipe(
|
this.currencyService.autocomplete(term ?? '', limit).pipe(
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.toastr.error('Unable to load currencies.');
|
this.toastr.error('Unable to load currencies.');
|
||||||
return of<readonly CurrencyLookupDto[]>([]);
|
return of<readonly CurrencyLookupDto[]>([]);
|
||||||
@@ -114,7 +122,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
);
|
);
|
||||||
|
|
||||||
readonly searchLanguages: AutocompleteSearchFn<LanguageLookupDto> = (term, limit) =>
|
readonly searchLanguages: AutocompleteSearchFn<LanguageLookupDto> = (term, limit) =>
|
||||||
this.onboardingService.searchLanguages(term, limit).pipe(
|
this.languageService.autocomplete(term ?? '', limit).pipe(
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.toastr.error('Unable to load languages.');
|
this.toastr.error('Unable to load languages.');
|
||||||
return of<readonly LanguageLookupDto[]>([]);
|
return of<readonly LanguageLookupDto[]>([]);
|
||||||
@@ -126,28 +134,53 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
|
|
||||||
readonly timeZoneValue: AutocompleteValueFn<TimezoneLookupDto, string> = item => item.id;
|
readonly timeZoneValue: AutocompleteValueFn<TimezoneLookupDto, string> = item => item.id;
|
||||||
|
|
||||||
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> = value =>
|
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> = value => {
|
||||||
this.onboardingService.resolveTimezone(value);
|
if (!value) return of(null);
|
||||||
|
return this.timezoneService.getById(value).pipe(
|
||||||
|
map(timezone => ({ id: timezone.id, ianaId: timezone.ianaId, displayName: timezone.displayName })),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = item =>
|
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = item =>
|
||||||
[item.code, item.name, item.symbol ? `(${item.symbol})` : null].filter(Boolean).join(' ');
|
[item.code, item.name, item.symbol ? `(${item.symbol})` : null].filter(Boolean).join(' ');
|
||||||
|
|
||||||
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = item => item.id;
|
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = item => item.id;
|
||||||
|
|
||||||
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = value =>
|
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = value => {
|
||||||
this.onboardingService.resolveCurrency(value);
|
if (!value) return of(null);
|
||||||
|
return this.currencyService.getCurrencyById(value).pipe(
|
||||||
|
map(currency => ({ id: currency.id, code: currency.code, name: currency.name, symbol: currency.symbol })),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
readonly displayLanguage: AutocompleteDisplayFn<LanguageLookupDto> = item =>
|
readonly displayLanguage: AutocompleteDisplayFn<LanguageLookupDto> = item =>
|
||||||
[item.code, item.name].filter(Boolean).join(' - ');
|
[item.code, item.name].filter(Boolean).join(' - ');
|
||||||
|
|
||||||
readonly languageValue: AutocompleteValueFn<LanguageLookupDto, string> = item => item.id;
|
readonly languageValue: AutocompleteValueFn<LanguageLookupDto, string> = item => item.id;
|
||||||
|
|
||||||
readonly resolveLanguage: AutocompleteResolveValueFn<LanguageLookupDto, string> = value =>
|
readonly resolveLanguage: AutocompleteResolveValueFn<LanguageLookupDto, string> = value => {
|
||||||
this.onboardingService.resolveLanguage(value);
|
if (!value) return of(null);
|
||||||
|
return this.languageService.getById(value).pipe(
|
||||||
|
map(language => ({
|
||||||
|
id: language.id,
|
||||||
|
code: language.code,
|
||||||
|
name: language.name,
|
||||||
|
nativeName: language.nativeName,
|
||||||
|
isRightToLeft: language.isRightToLeft,
|
||||||
|
})),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.onboardingService.loadLanguageOptions(100)
|
this.languageService.autocomplete('', 100)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
map(items => items.map(item => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.code} - ${item.name}`,
|
||||||
|
}))),
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
this.toastr.error('Unable to load language options.');
|
this.toastr.error('Unable to load language options.');
|
||||||
return of<readonly FormSelectOption<string>[]>([]);
|
return of<readonly FormSelectOption<string>[]>([]);
|
||||||
@@ -185,23 +218,30 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markAsUntouched(): void {
|
||||||
|
this.form.markAsUntouched();
|
||||||
|
this.submitAttempted.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
getValue(): OrganizationLocalizationValue {
|
getValue(): OrganizationLocalizationValue {
|
||||||
const value = this.form.getRawValue() as OrganizationLocalizationFormModel;
|
const value = this.form.getRawValue() as OrganizationLocalizationFormModel;
|
||||||
const timeZone = this.timeZoneSelection();
|
const timeZone = this.timeZoneSelection();
|
||||||
const currency = this.currencySelection();
|
const currency = this.currencySelection();
|
||||||
const defaultLanguage = this.defaultLanguageSelection();
|
const defaultLanguage = this.defaultLanguageSelection();
|
||||||
|
|
||||||
if (!timeZone || !currency || !defaultLanguage || !value.dateFormat || !value.timeFormat || !value.numberFormat || !value.fiscalYearConvention) {
|
if (!timeZone || !currency || !defaultLanguage || !value.dateFormat || value.timeFormat === null || value.timeFormat === undefined || !value.numberFormat || !value.fiscalYearConvention) {
|
||||||
throw new Error('Organization localization values are incomplete.');
|
throw new Error('Organization localization values are incomplete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const additionalLanguageSelections = this.resolveAdditionalLanguageSelections(value.additionalLanguageIds);
|
const defaultLanguageId = defaultLanguage.id;
|
||||||
|
const filteredAdditionalLanguageIds = value.additionalLanguageIds.filter(id => id && id !== defaultLanguageId);
|
||||||
|
const additionalLanguageSelections = this.resolveAdditionalLanguageSelections(filteredAdditionalLanguageIds);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timeZone: { id: timeZone.id, label: timeZone.displayName, secondaryLabel: timeZone.ianaId },
|
timeZone: { id: timeZone.id, label: timeZone.displayName, secondaryLabel: timeZone.ianaId },
|
||||||
currency: { id: currency.id, label: currency.name, secondaryLabel: currency.code },
|
currency: { id: currency.id, label: currency.name, secondaryLabel: currency.code },
|
||||||
defaultLanguage: { id: defaultLanguage.id, label: defaultLanguage.name, secondaryLabel: defaultLanguage.code },
|
defaultLanguage: { id: defaultLanguage.id, label: defaultLanguage.name, secondaryLabel: defaultLanguage.code },
|
||||||
additionalLanguageIds: value.additionalLanguageIds.filter(id => id !== defaultLanguage.id),
|
additionalLanguageIds: filteredAdditionalLanguageIds,
|
||||||
additionalLanguageSelections,
|
additionalLanguageSelections,
|
||||||
dateFormat: value.dateFormat,
|
dateFormat: value.dateFormat,
|
||||||
timeFormat: value.timeFormat,
|
timeFormat: value.timeFormat,
|
||||||
@@ -213,6 +253,8 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
|
|
||||||
getDraftValue(): Partial<OrganizationLocalizationValue> {
|
getDraftValue(): Partial<OrganizationLocalizationValue> {
|
||||||
const value = this.form.getRawValue() as OrganizationLocalizationFormModel;
|
const value = this.form.getRawValue() as OrganizationLocalizationFormModel;
|
||||||
|
const defaultLanguageId = this.defaultLanguageSelection()?.id;
|
||||||
|
const filteredAdditionalLanguageIds = (value.additionalLanguageIds ?? []).filter(id => id && id !== defaultLanguageId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timeZone: this.timeZoneSelection()
|
timeZone: this.timeZoneSelection()
|
||||||
@@ -236,8 +278,8 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
secondaryLabel: this.defaultLanguageSelection()!.code,
|
secondaryLabel: this.defaultLanguageSelection()!.code,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
additionalLanguageIds: value.additionalLanguageIds,
|
additionalLanguageIds: filteredAdditionalLanguageIds,
|
||||||
additionalLanguageSelections: this.resolveAdditionalLanguageSelections(value.additionalLanguageIds),
|
additionalLanguageSelections: this.resolveAdditionalLanguageSelections(filteredAdditionalLanguageIds),
|
||||||
dateFormat: value.dateFormat ?? undefined,
|
dateFormat: value.dateFormat ?? undefined,
|
||||||
timeFormat: value.timeFormat ?? undefined,
|
timeFormat: value.timeFormat ?? undefined,
|
||||||
numberFormat: value.numberFormat ?? undefined,
|
numberFormat: value.numberFormat ?? undefined,
|
||||||
@@ -246,16 +288,30 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
patchValue(value: Partial<OrganizationLocalizationValue>): void {
|
patchValue(value: Partial<OrganizationLocalizationValue> & Record<string, any>): void {
|
||||||
|
const rawTf = value.timeFormat !== undefined && value.timeFormat !== null ? value.timeFormat : value['TimeFormat'] ?? value['timeFormat'];
|
||||||
|
const timeFormatNormalized: TimeFormatValue | null =
|
||||||
|
rawTf === 1 || rawTf === '1' || rawTf === 'TwentyFourHour'
|
||||||
|
? 1
|
||||||
|
: rawTf === 0 || rawTf === '0' || rawTf === 'TwelveHour'
|
||||||
|
? 0
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rawFiscalYear = value.fiscalYearConvention ?? value['FiscalYearStart'] ?? value['fiscalYearStart'] ?? null;
|
||||||
|
const fiscalYearNormalized = mapMmDdToFiscalYear(rawFiscalYear);
|
||||||
|
|
||||||
|
const rawNumberFormat = value.numberFormat ?? value['NumberFormat'] ?? value['numberFormat'] ?? null;
|
||||||
|
const numberFormatNormalized = mapApiToNumberFormat(rawNumberFormat);
|
||||||
|
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
timeZoneId: value.timeZone?.id ?? null,
|
timeZoneId: value.timeZone?.id ?? value['DefaultTimezoneId'] ?? value['defaultTimezoneId'] ?? value['timeZoneId'] ?? null,
|
||||||
currencyId: value.currency?.id ?? null,
|
currencyId: value.currency?.id ?? value['DefaultCurrencyId'] ?? value['defaultCurrencyId'] ?? value['currencyId'] ?? null,
|
||||||
defaultLanguageId: value.defaultLanguage?.id ?? null,
|
defaultLanguageId: value.defaultLanguage?.id ?? value['DefaultLanguageId'] ?? value['defaultLanguageId'] ?? value['defaultLanguageId'] ?? null,
|
||||||
additionalLanguageIds: value.additionalLanguageIds ?? [],
|
additionalLanguageIds: value.additionalLanguageIds ?? value['AdditionalLanguageIds'] ?? value['additionalLanguageIds'] ?? [],
|
||||||
dateFormat: value.dateFormat ?? null,
|
dateFormat: value.dateFormat ?? value['DateFormat'] ?? value['dateFormat'] ?? null,
|
||||||
timeFormat: value.timeFormat ?? null,
|
timeFormat: timeFormatNormalized,
|
||||||
numberFormat: value.numberFormat ?? null,
|
numberFormat: numberFormatNormalized,
|
||||||
fiscalYearConvention: value.fiscalYearConvention ?? null,
|
fiscalYearConvention: fiscalYearNormalized,
|
||||||
}, { emitEvent: false });
|
}, { emitEvent: false });
|
||||||
|
|
||||||
this.timeZoneSelection.set(
|
this.timeZoneSelection.set(
|
||||||
@@ -293,12 +349,13 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyCountryDefaults(defaults: CountryLocalizationDefaults, replaceExisting: boolean): void {
|
applyCountryDefaults(defaults: CountryLocalizationDefaults, replaceExisting: boolean): void {
|
||||||
|
const currentTf = this.form.controls.timeFormat.value;
|
||||||
const nextValue = {
|
const nextValue = {
|
||||||
timeZoneId: replaceExisting || !this.form.controls.timeZoneId.value ? defaults.timeZone.id : this.form.controls.timeZoneId.value,
|
timeZoneId: replaceExisting || !this.form.controls.timeZoneId.value ? defaults.timeZone.id : this.form.controls.timeZoneId.value,
|
||||||
currencyId: replaceExisting || !this.form.controls.currencyId.value ? defaults.currency.id : this.form.controls.currencyId.value,
|
currencyId: replaceExisting || !this.form.controls.currencyId.value ? defaults.currency.id : this.form.controls.currencyId.value,
|
||||||
defaultLanguageId: replaceExisting || !this.form.controls.defaultLanguageId.value ? defaults.defaultLanguage.id : this.form.controls.defaultLanguageId.value,
|
defaultLanguageId: replaceExisting || !this.form.controls.defaultLanguageId.value ? defaults.defaultLanguage.id : this.form.controls.defaultLanguageId.value,
|
||||||
dateFormat: replaceExisting || !this.form.controls.dateFormat.value ? defaults.dateFormat : this.form.controls.dateFormat.value,
|
dateFormat: replaceExisting || !this.form.controls.dateFormat.value ? defaults.dateFormat : this.form.controls.dateFormat.value,
|
||||||
timeFormat: replaceExisting || !this.form.controls.timeFormat.value ? defaults.timeFormat : this.form.controls.timeFormat.value,
|
timeFormat: replaceExisting || currentTf === null || currentTf === undefined ? defaults.timeFormat : currentTf,
|
||||||
numberFormat: replaceExisting || !this.form.controls.numberFormat.value ? defaults.numberFormat : this.form.controls.numberFormat.value,
|
numberFormat: replaceExisting || !this.form.controls.numberFormat.value ? defaults.numberFormat : this.form.controls.numberFormat.value,
|
||||||
fiscalYearConvention: replaceExisting || !this.form.controls.fiscalYearConvention.value ? defaults.fiscalYearConvention : this.form.controls.fiscalYearConvention.value,
|
fiscalYearConvention: replaceExisting || !this.form.controls.fiscalYearConvention.value ? defaults.fiscalYearConvention : this.form.controls.fiscalYearConvention.value,
|
||||||
};
|
};
|
||||||
@@ -350,7 +407,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
value.defaultLanguageId ||
|
value.defaultLanguageId ||
|
||||||
value.additionalLanguageIds.length ||
|
value.additionalLanguageIds.length ||
|
||||||
value.dateFormat ||
|
value.dateFormat ||
|
||||||
value.timeFormat ||
|
(value.timeFormat !== null && value.timeFormat !== undefined) ||
|
||||||
value.numberFormat ||
|
value.numberFormat ||
|
||||||
value.fiscalYearConvention
|
value.fiscalYearConvention
|
||||||
);
|
);
|
||||||
@@ -374,6 +431,13 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
|
|
||||||
onDefaultLanguageSelected(item: LanguageLookupDto): void {
|
onDefaultLanguageSelected(item: LanguageLookupDto): void {
|
||||||
this.defaultLanguageSelection.set(item);
|
this.defaultLanguageSelection.set(item);
|
||||||
|
const defaultLanguageId = item.id;
|
||||||
|
const currentAdditional = this.form.controls.additionalLanguageIds.value;
|
||||||
|
if (currentAdditional.includes(defaultLanguageId)) {
|
||||||
|
this.form.controls.additionalLanguageIds.setValue(
|
||||||
|
currentAdditional.filter(id => id !== defaultLanguageId)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onDefaultLanguageCleared(): void {
|
onDefaultLanguageCleared(): void {
|
||||||
@@ -396,7 +460,17 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
}
|
}
|
||||||
|
|
||||||
onTimeFormatChanged(value: ThemeSelectValue): void {
|
onTimeFormatChanged(value: ThemeSelectValue): void {
|
||||||
this.form.controls.timeFormat.setValue(typeof value === 'string' ? value as TimeFormatValue : null);
|
const numValue: TimeFormatValue | null =
|
||||||
|
typeof value === 'number'
|
||||||
|
? (value === 1 ? 1 : value === 0 ? 0 : null)
|
||||||
|
: typeof value === 'string'
|
||||||
|
? value === 'TwentyFourHour' || value === '1'
|
||||||
|
? 1
|
||||||
|
: value === 'TwelveHour' || value === '0'
|
||||||
|
? 0
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
this.form.controls.timeFormat.setValue(numValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
onNumberFormatChanged(value: ThemeSelectValue): void {
|
onNumberFormatChanged(value: ThemeSelectValue): void {
|
||||||
@@ -452,7 +526,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private toThemeOptions<TValue extends string>(options: readonly FormSelectOption<TValue>[]) {
|
private toThemeOptions<TValue extends string | number>(options: readonly FormSelectOption<TValue>[]) {
|
||||||
return options.map(option => ({ label: option.label, value: option.value }));
|
return options.map(option => ({ label: option.label, value: option.value }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user