diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts deleted file mode 100644 index 25a123f3..00000000 --- a/src/app/core/auth/auth.service.ts +++ /dev/null @@ -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(null); - readonly user$ = this.userSubject.asObservable(); - readonly currentUserSignal = signal(null); - private readonly accessTokenSignal = signal(null); - readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal()); - - private refreshRequest$: Observable | null = null; - - constructor() { - this.restoreAuthState(); - } - - login(payload: LoginRequest, rememberMe: boolean): Observable { - return this.http.post(`${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 { - 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(`${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; - } -} diff --git a/src/app/core/auth/token-storage.service.ts b/src/app/core/auth/token-storage.service.ts deleted file mode 100644 index 1bc3ab7d..00000000 --- a/src/app/core/auth/token-storage.service.ts +++ /dev/null @@ -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; - } -} diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts index cb0fab9b..cbb7a8b7 100644 --- a/src/app/core/guards/auth.guard.ts +++ b/src/app/core/guards/auth.guard.ts @@ -1,8 +1,8 @@ import { inject } from '@angular/core'; import { CanActivateChildFn, Router } from '@angular/router'; import { catchError, map, of } from 'rxjs'; -import { AuthService } from '../auth/auth.service'; -import { TokenStorageService } from '../auth/token-storage.service'; +import { AuthService } from '../../features/authentication/data-access/auth.service'; +import { TokenStorageService } from '../services/auth/token-storage.service'; export const authGuard: CanActivateChildFn = (_childRoute, state) => { const authService = inject(AuthService); diff --git a/src/app/core/guards/auth/auth.guard.ts b/src/app/core/guards/auth/auth.guard.ts index 764b2048..b30e5221 100644 --- a/src/app/core/guards/auth/auth.guard.ts +++ b/src/app/core/guards/auth/auth.guard.ts @@ -1,7 +1,7 @@ import { inject } from '@angular/core'; import { CanActivateChildFn, Router } from '@angular/router'; import { catchError, map, of } from 'rxjs'; -import { AuthService } from '../../services/auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; import { TokenStorageService } from '../../services/auth/token-storage.service'; export const authGuard: CanActivateChildFn = (_childRoute, state) => { diff --git a/src/app/core/guards/auth/guest.guard.ts b/src/app/core/guards/auth/guest.guard.ts index e62d18af..d39f66e8 100644 --- a/src/app/core/guards/auth/guest.guard.ts +++ b/src/app/core/guards/auth/guest.guard.ts @@ -1,7 +1,7 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { catchError, map, of } from 'rxjs'; -import { AuthService } from '../../services/auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; import { TokenStorageService } from '../../services/auth/token-storage.service'; const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm'; diff --git a/src/app/core/guards/auth/super-admin.guard.ts b/src/app/core/guards/auth/super-admin.guard.ts index d109777b..1541a35f 100644 --- a/src/app/core/guards/auth/super-admin.guard.ts +++ b/src/app/core/guards/auth/super-admin.guard.ts @@ -1,11 +1,11 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; -import { AuthService } from '../../services/auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; export const superAdminGuard: CanActivateFn = () => { const auth = inject(AuthService); const router = inject(Router); - return (auth.currentUser?.roles ?? []).includes('super_admin') + return (auth.currentUser()?.roles ?? []).includes('super_admin') ? true : router.createUrlTree(['/dashboards/crm']); }; diff --git a/src/app/core/guards/guest.guard.ts b/src/app/core/guards/guest.guard.ts index 42629512..f580219c 100644 --- a/src/app/core/guards/guest.guard.ts +++ b/src/app/core/guards/guest.guard.ts @@ -1,8 +1,8 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { catchError, map, of } from 'rxjs'; -import { AuthService } from '../auth/auth.service'; -import { TokenStorageService } from '../auth/token-storage.service'; +import { AuthService } from '../../features/authentication/data-access/auth.service'; +import { TokenStorageService } from '../services/auth/token-storage.service'; const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm'; diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 8418add5..68dc20b9 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { catchError, switchMap, throwError } from 'rxjs'; -import { AuthService } from '../services/auth/auth.service'; +import { AuthService } from '../../features/authentication/data-access/auth.service'; import { API_CONFIG } from '../config/api.config'; import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints'; diff --git a/src/app/core/interceptors/loading-interceptor.ts b/src/app/core/interceptors/loading-interceptor.ts index 260cff1f..227e4af4 100644 --- a/src/app/core/interceptors/loading-interceptor.ts +++ b/src/app/core/interceptors/loading-interceptor.ts @@ -12,7 +12,7 @@ export const loadingInterceptor: HttpInterceptorFn = (req, next) => { : req; if (!shouldSkipLoader) { - loadingService.show(); + // loadingService.show(); } return next(request).pipe( diff --git a/src/app/core/models/api-response.model.ts b/src/app/core/models/api-response.model.ts deleted file mode 100644 index 0ec47b3b..00000000 --- a/src/app/core/models/api-response.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface ApiResponse { - data: T; - message?: string; - success: boolean; -} - -export interface ProblemDetails { - title?: string; - status?: number; - detail?: string; - errors?: Record; -} diff --git a/src/app/core/models/auth/auth.model.ts b/src/app/core/models/auth/auth.model.ts deleted file mode 100644 index 0c747c92..00000000 --- a/src/app/core/models/auth/auth.model.ts +++ /dev/null @@ -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; -} diff --git a/src/app/core/models/city/city.model.ts b/src/app/core/models/city/city.model.ts deleted file mode 100644 index 69dfe3b2..00000000 --- a/src/app/core/models/city/city.model.ts +++ /dev/null @@ -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' ; diff --git a/src/app/core/models/context.model.ts b/src/app/core/models/context.model.ts deleted file mode 100644 index aab308a8..00000000 --- a/src/app/core/models/context.model.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/app/core/models/context/context.model.ts b/src/app/core/models/context/context.model.ts index 57efe873..90d3ab3f 100644 --- a/src/app/core/models/context/context.model.ts +++ b/src/app/core/models/context/context.model.ts @@ -1,4 +1,4 @@ -import { UserProfile } from '../auth/auth.model'; +import { UserProfile } from '../../../features/authentication/models/auth.model'; import { Menu } from '../../services/common/nav.service'; export interface CurrentUserContext extends UserProfile { diff --git a/src/app/core/models/country/country.model.ts b/src/app/core/models/country/country.model.ts deleted file mode 100644 index 9c4b220f..00000000 --- a/src/app/core/models/country/country.model.ts +++ /dev/null @@ -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'; diff --git a/src/app/core/models/currency/currency.model.ts b/src/app/core/models/currency/currency.model.ts deleted file mode 100644 index 012e2a19..00000000 --- a/src/app/core/models/currency/currency.model.ts +++ /dev/null @@ -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'; diff --git a/src/app/core/models/language/language.model.ts b/src/app/core/models/language/language.model.ts deleted file mode 100644 index fc1f7539..00000000 --- a/src/app/core/models/language/language.model.ts +++ /dev/null @@ -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'; diff --git a/src/app/core/models/state/state.model.ts b/src/app/core/models/state/state.model.ts deleted file mode 100644 index 384358d9..00000000 --- a/src/app/core/models/state/state.model.ts +++ /dev/null @@ -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'; diff --git a/src/app/core/models/tenant/tenant-currencies.model.ts b/src/app/core/models/tenant/tenant-currencies.model.ts deleted file mode 100644 index 8ba9c947..00000000 --- a/src/app/core/models/tenant/tenant-currencies.model.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/src/app/core/models/tenant/tenant.model.ts b/src/app/core/models/tenant/tenant.model.ts deleted file mode 100644 index 5aa875b4..00000000 --- a/src/app/core/models/tenant/tenant.model.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/src/app/core/models/timezone/timezone.model.ts b/src/app/core/models/timezone/timezone.model.ts deleted file mode 100644 index 31a75ee6..00000000 --- a/src/app/core/models/timezone/timezone.model.ts +++ /dev/null @@ -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'; diff --git a/src/app/core/models/user/user.model.ts b/src/app/core/models/user/user.model.ts deleted file mode 100644 index b7cd6b9c..00000000 --- a/src/app/core/models/user/user.model.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/src/app/core/services/app-context.service.ts b/src/app/core/services/app-context.service.ts deleted file mode 100644 index eaa14305..00000000 --- a/src/app/core/services/app-context.service.ts +++ /dev/null @@ -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 | null = null; - private menuLoadRequest$: Observable | null = null; - private loaded = false; - - ensureMenuInitialized(forceReload = false): Observable { - 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 { - 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 { - 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: [] }, - }; - } -} \ No newline at end of file diff --git a/src/app/core/services/auth/session-timeout.service.ts b/src/app/core/services/auth/session-timeout.service.ts index 1404257c..138cbe6b 100644 --- a/src/app/core/services/auth/session-timeout.service.ts +++ b/src/app/core/services/auth/session-timeout.service.ts @@ -5,7 +5,7 @@ import { Router } from '@angular/router'; import { merge, fromEvent, Subscription } from 'rxjs'; import { throttleTime } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; -import { AuthService } from '../auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; @Injectable() export class SessionTimeoutService implements OnDestroy { diff --git a/src/app/core/services/auth/token-storage.service.ts b/src/app/core/services/auth/token-storage.service.ts index b62a6bec..4bdcb281 100644 --- a/src/app/core/services/auth/token-storage.service.ts +++ b/src/app/core/services/auth/token-storage.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { LoginResponse, UserProfile } from '../../models/auth/auth.model'; +import { LoginResponse, UserProfile } from '../../../features/authentication/models/auth.model'; type StorageType = 'local' | 'session'; diff --git a/src/app/core/services/base-api.service.ts b/src/app/core/services/base-api.service.ts deleted file mode 100644 index f432d80b..00000000 --- a/src/app/core/services/base-api.service.ts +++ /dev/null @@ -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(resource: string, params?: Record): Observable { - let httpParams = new HttpParams(); - - if (params) { - Object.entries(params).forEach(([key, value]) => { - httpParams = httpParams.set(key, String(value)); - }); - } - - return this.http.get(`${API_CONFIG.baseUrl}${resource}`, { params: httpParams }); - } - - protected post(resource: string, body: T): Observable { - return this.http.post(`${API_CONFIG.baseUrl}${resource}`, body); - } - - protected put(resource: string, body: T): Observable { - return this.http.put(`${API_CONFIG.baseUrl}${resource}`, body); - } - - protected delete(resource: string): Observable { - return this.http.delete(`${API_CONFIG.baseUrl}${resource}`); - } -} diff --git a/src/app/core/services/common/app-state.service.ts b/src/app/core/services/common/app-state.service.ts index 76d41601..a9adbaf5 100644 --- a/src/app/core/services/common/app-state.service.ts +++ b/src/app/core/services/common/app-state.service.ts @@ -1,55 +1,56 @@ -import { DOCUMENT, ElementRef, inject, Injectable, Renderer2 } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; +import { DOCUMENT, inject, Injectable } from '@angular/core'; +import { signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; -interface StateType { +export interface StateType { direction: string; theme: string; - navigationStyles: string, // vertical, horizontal - menuStyles: string, // menu-click, menu-hover, icon-click, icon-hover - layoutStyles: string, // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu - pageStyles: string, // regular, classic, modern - widthStyles: string, // fullwidth, boxed - menuPosition: string, // fixed, scrollable - headerPosition: string, // fixed, scrollable - menuColor: string, // light, dark, color, gradient, transparent - headerColor: string, // light, dark, color, gradient, transparent - themePrimary: string, // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90' - themeBackground: string, - backgroundImage: string, -}; + navigationStyles: string; + menuStyles: string; + layoutStyles: string; + pageStyles: string; + widthStyles: string; + menuPosition: string; + headerPosition: string; + menuColor: string; + headerColor: string; + themePrimary: string; + themeBackground: any; + backgroundImage: string; +} + @Injectable({ providedIn: 'root' }) export class AppStateService { - private readonly localStorageKey = 'Ynex-ng'; // Customize this key - private initialState: StateType = { - theme: 'light', // light, dark - direction: 'ltr', // ltr, rtl - navigationStyles: 'vertical', // vertical, horizontal - menuStyles: '', // menu-click, menu-hover, icon-click, icon-hover - layoutStyles: 'default', // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu - pageStyles: 'regular', // regular, classic, modern - widthStyles: 'fullwidth', // fullwidth, boxed - menuPosition: 'fixed', // fixed, scrollable - headerPosition: 'fixed', // fixed, scrollable - menuColor: 'dark', // light, dark, color, gradient, transparent - headerColor: 'light', // light, dark, color, gradient, transparent - themePrimary: '', // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90' + private readonly localStorageKey = 'Ynex-ng'; + private readonly initialState: StateType = { + theme: 'light', + direction: 'ltr', + navigationStyles: 'vertical', + menuStyles: '', + layoutStyles: 'default', + pageStyles: 'regular', + widthStyles: 'fullwidth', + menuPosition: 'fixed', + headerPosition: 'fixed', + menuColor: 'dark', + headerColor: 'light', + themePrimary: '', themeBackground: '', - backgroundImage: '', // bgimg1, bgimg2, bgimg3, bgimg4, bgimg5 - } // Store initial state - private stateSubject = new BehaviorSubject(this.initialState); // Use any for initial null value - state$ = this.stateSubject.asObservable(); + backgroundImage: '', + }; + + private readonly stateSignal = signal(this.getInitialStateFromLocalStorage()); + readonly state = this.stateSignal.asReadonly(); + readonly state$ = toObservable(this.stateSignal); + private document = inject(DOCUMENT); navigationStyles: any; private html = this.document.documentElement; constructor() { - - const initialState: StateType = this.getInitialStateFromLocalStorage(); - // this.initializeState(); - this.stateSubject.next(initialState); - + this.updateStateAndEmit(this.stateSignal()); } private getInitialStateFromLocalStorage(): StateType { @@ -64,40 +65,33 @@ export class AppStateService { return this.initialState; } - - - - getupdateState() { - const currentState = this.stateSubject.getValue(); - return currentState + getupdateState(): StateType { + return this.stateSignal(); } - updateState(newState?: Partial) { // Use any for partial updates - const currentState = this.stateSubject.getValue(); // Get current state + updateState(newState?: Partial) { + const currentState = this.stateSignal(); if (!currentState) { - - // Handle initial update case (no state emitted yet) - this.updateStateAndEmit(newState); + if (newState) { + this.updateStateAndEmit({ ...this.initialState, ...newState }); + } return; } if (newState) { - - const updatedState = { ...currentState, ...newState }; // Merge updates - this.updateStateAndEmit(updatedState); // Update and emit combined state + const updatedState = { ...currentState, ...newState }; + this.updateStateAndEmit(updatedState); } else { this.updateStateAndEmit(currentState); - - return; } } - private state: { [key: string]: any } = {}; + private stateStore: { [key: string]: any } = {}; getState(menuStyles: string): any { - return this.state[menuStyles]; + return this.stateStore[menuStyles]; } - private applyThemeBackgroundSpecificChanges(background: any) { + private applyThemeBackgroundSpecificChanges(background: any) { this.html?.style.setProperty('--color-bodybg', background.main); this.html?.style.setProperty('--color-bodybg2', background.secondary); this.html?.style.setProperty('--color-light', background.accent); @@ -107,48 +101,30 @@ export class AppStateService { this.applythemeSpecificChanges(background.theme); } - private applyDirectionSpecificChanges(direction: string) { - - this.html?.setAttribute('dir', direction); - } + private applythemeSpecificChanges(theme: string) { - - - this.html?.setAttribute('class', theme); //setting theme style - this.html?.setAttribute('data-header-styles', theme); //setting header style - - - - - + this.html?.setAttribute('class', theme); + this.html?.setAttribute('data-header-styles', theme); } - - private applyNavigationStylesSpecificChanges(navigationStyles: string) { - - - this.html?.setAttribute('data-nav-layout', navigationStyles); - if (navigationStyles == 'horizontal') { + if (navigationStyles === 'horizontal') { this.html?.setAttribute('data-nav-style', 'menu-click'); this.html?.removeAttribute('data-vertical-style'); - } } + private applyMenuStylesSpecificChanges(menuStyles: string) { - - - this.html?.setAttribute('data-nav-style', menuStyles); this.html?.setAttribute('data-toggled', menuStyles + '-closed'); this.html?.removeAttribute('data-vertical-style'); } - private applyLayoutStylesSpecificChanges(layoutStyles: string) { + private applyLayoutStylesSpecificChanges(layoutStyles: string) { this.html?.setAttribute('data-vertical-style', layoutStyles); this.html?.removeAttribute('data-nav-style'); switch (layoutStyles) { @@ -175,64 +151,57 @@ export class AppStateService { if (layoutStyles === 'icon-text') { this.html?.setAttribute('icon-text', 'open'); } else { - // If not 'icon-text', remove the icon-text attribute this.html?.removeAttribute('icon-text'); } } - private applypageStylesSpecificChanges(pageStyles: string) { + private applypageStylesSpecificChanges(pageStyles: string) { this.html?.setAttribute('data-page-style', pageStyles); const slideRight = document.querySelector('.slide-right') as HTMLElement | null; if (slideRight) { - // If the element exists, toggle the 'd-none' class if (slideRight.classList.contains('d-none')) { slideRight.classList.remove('d-none'); } else { slideRight.classList.add('d-none'); } } else { - // If the element does not exist (is null), create a safe fallback by adding 'd-none' const dummySlideRight = document.createElement('div'); - dummySlideRight.classList.add('slide-right', 'd-none'); // Add classes to the new element - document.body.appendChild(dummySlideRight); // Append it to the DOM as a fallback + dummySlideRight.classList.add('slide-right', 'd-none'); + document.body.appendChild(dummySlideRight); } } - private applywidthStylesSpecificChanges(widthStyles: string) { + private applywidthStylesSpecificChanges(widthStyles: string) { this.html?.setAttribute('data-width', widthStyles); } - private applymenuPositionSpecificChanges(menuPosition: string) { + private applymenuPositionSpecificChanges(menuPosition: string) { this.html?.setAttribute('data-menu-position', menuPosition); } - private applyheaderPositionSpecificChanges(headerPosition: string) { + private applyheaderPositionSpecificChanges(headerPosition: string) { this.html?.setAttribute('data-header-position', headerPosition); } - private applyheaderColorSpecificChanges(headerColor: string) { + private applyheaderColorSpecificChanges(headerColor: string) { this.html?.setAttribute('data-header-styles', headerColor); } - private applymenuColorSpecificChanges(menuColor: string) { + private applymenuColorSpecificChanges(menuColor: string) { this.html?.setAttribute('data-menu-styles', menuColor); } - private applyPrimarySpecificChanges(primary: string) { + private applyPrimarySpecificChanges(primary: string) { this.html?.style.setProperty('--color-primaryrgb', primary); this.html?.style.setProperty('--color-primary', primary); } - private applybackgroundImageSpecificChanges(backgroundImage: string) { + private applybackgroundImageSpecificChanges(backgroundImage: string) { this.html?.setAttribute('bg-img', backgroundImage); } - - - public applyReset() { - if (this.html) { this.html?.style.removeProperty('--color-bodybg'); this.html?.style.removeProperty('--color-gray3'); @@ -242,76 +211,70 @@ export class AppStateService { this.html?.style.removeProperty('--color-inputborder'); this.html?.style.removeProperty('--color-primary'); this.html?.style.removeProperty('--color-primaryrgb'); - } this.html?.removeAttribute('bg-img'); this.html?.setAttribute('data-vertical-style', 'overlay'); - this.stateSubject.next(this.initialState); this.updateStateAndEmit(this.initialState); - localStorage.clear(); + try { + localStorage.removeItem(this.localStorageKey); + } catch (error) { + console.error('Error resetting local storage:', error); + } if (window.innerWidth <= 992) { this.html?.setAttribute('data-toggled', 'close'); } } - private updateStateAndEmit(state: any) { - // Conditional logic based on direction changes - - const currentState = this.stateSubject.getValue(); // Get current state - // Conditional logic based on theme changes - if (state['theme']) { - - this.applythemeSpecificChanges(state['theme']); + private updateStateAndEmit(state: StateType) { + if (state.theme) { + this.applythemeSpecificChanges(state.theme); } - if (state['direction']) { - - this.applyDirectionSpecificChanges(state['direction']); + if (state.direction) { + this.applyDirectionSpecificChanges(state.direction); } - // Conditional logic based on theme changes - if (state['navigationStyles']) { - this.applyNavigationStylesSpecificChanges(state['navigationStyles']); + if (state.navigationStyles) { + this.applyNavigationStylesSpecificChanges(state.navigationStyles); } - // Conditional logic based on theme changes - if (state['menuStyles'] && !state['layoutStyles']) { - this.applyMenuStylesSpecificChanges(state['menuStyles']); + if (state.menuStyles && !state.layoutStyles) { + this.applyMenuStylesSpecificChanges(state.menuStyles); } - if (state['layoutStyles'] && !state['menuStyles']) { - this.applyLayoutStylesSpecificChanges(state['layoutStyles']); + if (state.layoutStyles && !state.menuStyles) { + this.applyLayoutStylesSpecificChanges(state.layoutStyles); } - if (state['pageStyles']) { - this.applypageStylesSpecificChanges(state['pageStyles']); + if (state.pageStyles) { + this.applypageStylesSpecificChanges(state.pageStyles); } - if (state['widthStyles']) { - this.applywidthStylesSpecificChanges(state['widthStyles']); + if (state.widthStyles) { + this.applywidthStylesSpecificChanges(state.widthStyles); } - if (state['menuPosition']) { - this.applymenuPositionSpecificChanges(state['menuPosition']); + if (state.menuPosition) { + this.applymenuPositionSpecificChanges(state.menuPosition); } - if (state['headerPosition']) { - this.applyheaderPositionSpecificChanges(state['headerPosition']); + if (state.headerPosition) { + this.applyheaderPositionSpecificChanges(state.headerPosition); } - if (state['themePrimary']) { - this.applyPrimarySpecificChanges(state['themePrimary']); + if (state.themePrimary) { + this.applyPrimarySpecificChanges(state.themePrimary); } - if (state['themeBackground']) { - this.applyThemeBackgroundSpecificChanges(state['themeBackground']); + if (state.themeBackground) { + this.applyThemeBackgroundSpecificChanges(state.themeBackground); } - if (state['headerColor']) { - this.applyheaderColorSpecificChanges(state['headerColor']); + if (state.headerColor) { + this.applyheaderColorSpecificChanges(state.headerColor); } - if (state['menuColor']) { - this.applymenuColorSpecificChanges(state['menuColor']); + if (state.menuColor) { + this.applymenuColorSpecificChanges(state.menuColor); } - if (state['backgroundImage']) { - this.applybackgroundImageSpecificChanges(state['backgroundImage']); + if (state.backgroundImage) { + this.applybackgroundImageSpecificChanges(state.backgroundImage); } - this.stateSubject.next(state); + this.stateSignal.set(state); this.updateLocalStorage(state); } - private updateLocalStorage(state: any) { + private updateLocalStorage(state: StateType) { try { localStorage.setItem(this.localStorageKey, JSON.stringify(state)); } catch (error) { diff --git a/src/app/core/services/common/menu.data.ts b/src/app/core/services/common/menu.data.ts index 137a2564..2aa1b2e4 100644 --- a/src/app/core/services/common/menu.data.ts +++ b/src/app/core/services/common/menu.data.ts @@ -24,67 +24,47 @@ export const SAAS_MENU_DATA: MenuContext = { selected: false, dirchange: false, children: [ - { - title: 'Global Master', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false }, - { path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false }, - { path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false }, - { path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false }, - { path: '/global-masters/states', title: 'State', type: 'link', dirchange: false }, - { path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false }, - ], - }, - { - title: 'Tenant Master', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false }, - { path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false } - ], - }, - { path: '/users', title: 'Users', type: 'link', dirchange: false }, - { - title: 'Configuration', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ + { path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false }, + { path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false }, + { path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false }, + { path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false }, + { path: '/global-masters/states', title: 'State', type: 'link', dirchange: false }, + { path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false }, - { path: '/localization', title: 'Localization', type: 'link', dirchange: false }, - { - title: 'Branding', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/theming', title: 'Theming', type: 'link', dirchange: false }, - { path: '/platform', title: 'Platform', type: 'link', dirchange: false }, - ], - }, - ], - }, + // { + // title: 'Tenant Master', + // type: 'sub', + // active: false, + // selected: false, + // dirchange: false, + // children: [ + // { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false }, + // { path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false } + // ], + // } ], }, { - title: 'Operations', - icon: '', + title: 'Organizations', + icon: '', type: 'sub', active: false, selected: false, dirchange: false, children: [ - { path: '/billing', title: 'Billing', type: 'link', dirchange: false }, - { path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false }, + { path: '/organizations', title: 'Dashboard', type: 'link', dirchange: false }, + { path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false }, + ], + }, + { + title: 'Settings', + icon: '', + type: 'sub', + active: false, + selected: false, + dirchange: false, + children: [ + { path: '/settings/branding', title: 'Branding', type: 'link', dirchange: false }, ], }, ], diff --git a/src/app/core/services/common/menu.service.spec.ts b/src/app/core/services/common/menu.service.spec.ts deleted file mode 100644 index ec059a31..00000000 --- a/src/app/core/services/common/menu.service.spec.ts +++ /dev/null @@ -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, path: string): boolean { - return items.some( - (item) => - item.path === path || - (item.children ? hasPath(item.children, path) : false) || - (item.children2 ? hasPath(item.children2, path) : false), - ); -} diff --git a/src/app/core/services/common/nav.service.ts b/src/app/core/services/common/nav.service.ts index 450e2863..00c81291 100644 --- a/src/app/core/services/common/nav.service.ts +++ b/src/app/core/services/common/nav.service.ts @@ -1,8 +1,9 @@ -import { Injectable, OnDestroy } from '@angular/core'; -import { Subject, BehaviorSubject, fromEvent } from 'rxjs'; +import { Injectable, OnDestroy, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { Subject, fromEvent } from 'rxjs'; import { takeUntil, debounceTime } from 'rxjs/operators'; import { Router } from '@angular/router'; -// Menu + export interface Menu { headTitle?: string; headTitle2?: string; @@ -20,10 +21,9 @@ export interface Menu { children2?: Menu[]; Menusub?: boolean; target?: boolean; - menutype?: string, - dirchange?: boolean, - nochild?: any - + menutype?: string; + dirchange?: boolean; + nochild?: any; } @Injectable({ @@ -31,9 +31,14 @@ export interface Menu { }) export class NavService implements OnDestroy { private unsubscriber: Subject = new Subject(); - public screenWidth: BehaviorSubject = new BehaviorSubject( - window.innerWidth - ); + + private readonly screenWidthSignal = signal(window.innerWidth); + readonly screenWidth = this.screenWidthSignal.asReadonly(); + readonly screenWidth$ = toObservable(this.screenWidthSignal); + + private readonly itemsSignal = signal([]); + readonly items = this.itemsSignal.asReadonly(); + readonly items$ = toObservable(this.itemsSignal); // Search Box public search = false; @@ -72,8 +77,7 @@ export class NavService implements OnDestroy { } }); if (window.innerWidth < 991) { - // Detect Route change sidebar close - this.router.events.subscribe((event) => { + this.router.events.subscribe(() => { this.collapseSidebar = true; this.megaMenu = false; this.levelMenu = false; @@ -82,21 +86,19 @@ export class NavService implements OnDestroy { } ngOnDestroy() { - this.unsubscriber.next; + this.unsubscriber.next(true); this.unsubscriber.complete(); } private setScreenWidth(width: number): void { - this.screenWidth.next(width); + this.screenWidthSignal.set(width); } - items = new BehaviorSubject([]); - setMenuItems(menuItems: Menu[]): void { - this.items.next(menuItems); + this.itemsSignal.set(menuItems); } clearMenuItems(): void { - this.items.next([]); + this.itemsSignal.set([]); } } diff --git a/src/app/core/services/loading.service.ts b/src/app/core/services/loading.service.ts deleted file mode 100644 index fa48c82e..00000000 --- a/src/app/core/services/loading.service.ts +++ /dev/null @@ -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); - } - } -} diff --git a/src/app/core/services/menu.data.ts b/src/app/core/services/menu.data.ts deleted file mode 100644 index 60b32306..00000000 --- a/src/app/core/services/menu.data.ts +++ /dev/null @@ -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: '', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false }, - ], - }, - { headTitle: 'SAAS ADMIN' }, - { - title: 'Management', - icon: '', - 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: '', - 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 }, - ], - }, - ], -}; \ No newline at end of file diff --git a/src/app/core/services/menu.service.ts b/src/app/core/services/menu.service.ts deleted file mode 100644 index 51fc72e7..00000000 --- a/src/app/core/services/menu.service.ts +++ /dev/null @@ -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(null); - - loadMenu(): Observable { - 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[]; - } -} \ No newline at end of file diff --git a/src/app/core/services/permission.service.ts b/src/app/core/services/permission.service.ts deleted file mode 100644 index 773ee393..00000000 --- a/src/app/core/services/permission.service.ts +++ /dev/null @@ -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(null); - - loadPermissions(): Observable { - return this.http.get(`${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); - } -} \ No newline at end of file diff --git a/src/app/core/services/session-timeout.service.ts b/src/app/core/services/session-timeout.service.ts deleted file mode 100644 index 7a20fd76..00000000 --- a/src/app/core/services/session-timeout.service.ts +++ /dev/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 | null = null; - private logoutTimer: ReturnType | null = null; - private countdownTimer: ReturnType | 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; - } - } -} diff --git a/src/app/core/services/tenant-context.service.ts b/src/app/core/services/tenant-context.service.ts deleted file mode 100644 index aa9e6f62..00000000 --- a/src/app/core/services/tenant-context.service.ts +++ /dev/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(null); - - loadTenantContext(): Observable { - return this.http.get(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`).pipe( - tap((context) => { - this.tenantContext.set(context); - }) - ); - } - - clear(): void { - this.tenantContext.set(null); - } -} \ No newline at end of file diff --git a/src/app/core/services/user-context.service.ts b/src/app/core/services/user-context.service.ts deleted file mode 100644 index 632f1511..00000000 --- a/src/app/core/services/user-context.service.ts +++ /dev/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(null); - - loadCurrentUserProfile(): Observable { - return this.http.get>(`${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); - } -} \ No newline at end of file diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/features/authentication/data-access/auth.service.ts similarity index 87% rename from src/app/core/services/auth/auth.service.ts rename to src/app/features/authentication/data-access/auth.service.ts index 1b6c4338..2804b925 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/features/authentication/data-access/auth.service.ts @@ -1,9 +1,10 @@ import { computed, Injectable, inject, signal } from '@angular/core'; -import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { LoginRequest, LoginResponse, UserProfile } from '../../models/auth/auth.model'; -import { TokenStorageService } from './token-storage.service'; -import { AppContextService } from '../../services/context/app-context.service'; +import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model'; +import { TokenStorageService } from '../../../core/services/auth/token-storage.service'; +import { AppContextService } from '../../../core/services/context/app-context.service'; import { AUTH_ENDPOINTS } from '../../../core/end-points/auth/auth.endpoints'; @Injectable({ providedIn: 'root' }) @@ -12,9 +13,10 @@ export class AuthService { private readonly tokenStorage = inject(TokenStorageService); private readonly appContextService = inject(AppContextService); - private readonly userSubject = new BehaviorSubject(null); - readonly user$ = this.userSubject.asObservable(); readonly currentUserSignal = signal(null); + readonly currentUser = this.currentUserSignal.asReadonly(); + readonly user$ = toObservable(this.currentUserSignal); + private readonly accessTokenSignal = signal(null); readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal()); @@ -91,7 +93,7 @@ export class AuthService { return this.isAuthenticatedSignal(); } - get currentUser(): UserProfile | null { + get currentUserValue(): UserProfile | null { return this.currentUserSignal(); } @@ -108,7 +110,6 @@ export class AuthService { } private setAuthState(user: UserProfile | null, token: string | null): void { - this.userSubject.next(user); this.currentUserSignal.set(user); this.accessTokenSignal.set(token); } diff --git a/src/app/core/models/auth.model.ts b/src/app/features/authentication/models/auth.model.ts similarity index 100% rename from src/app/core/models/auth.model.ts rename to src/app/features/authentication/models/auth.model.ts diff --git a/src/app/features/authentication/pages/login/login.ts b/src/app/features/authentication/pages/login/login.ts index 74b11327..b2c3eb8b 100644 --- a/src/app/features/authentication/pages/login/login.ts +++ b/src/app/features/authentication/pages/login/login.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, inject } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { AuthService } from '../../../../core/services/auth/auth.service'; +import { AuthService } from '../../data-access/auth.service'; import { ReactiveFormsModule } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; import { catchError, finalize, of, switchMap, tap } from 'rxjs'; diff --git a/src/app/features/billing/billing.routes.ts b/src/app/features/billing/billing.routes.ts deleted file mode 100644 index cdd6637a..00000000 --- a/src/app/features/billing/billing.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Routes } from '@angular/router'; - -export const billingRoutes: Routes = [ - { - path: '', - loadComponent: () => import('./pages/billing-list/billing-list').then((m) => m.BillingList), - data: { childTitle: 'Billing', parentTitle: 'Platform', subParentTitle: 'Subscriptions' }, - }, -]; diff --git a/src/app/features/billing/pages/billing-list/billing-list.html b/src/app/features/billing/pages/billing-list/billing-list.html deleted file mode 100644 index 0c7f54a7..00000000 --- a/src/app/features/billing/pages/billing-list/billing-list.html +++ /dev/null @@ -1,5 +0,0 @@ -
-

Billing

-

Plans, subscriptions, and tenant subscription upgrades will - be implemented here.

-
\ No newline at end of file diff --git a/src/app/features/billing/pages/billing-list/billing-list.ts b/src/app/features/billing/pages/billing-list/billing-list.ts deleted file mode 100644 index 95265016..00000000 --- a/src/app/features/billing/pages/billing-list/billing-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-billing-list', - standalone: true, - imports: [CommonModule], - templateUrl: './billing-list.html', - styleUrl: './billing-list.scss', -}) -export class BillingList {} diff --git a/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.html b/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.html new file mode 100644 index 00000000..d5af2add --- /dev/null +++ b/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.html @@ -0,0 +1,93 @@ + + @if (modalLoading()) { +
+ + Loading city... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
diff --git a/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.ts b/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.ts new file mode 100644 index 00000000..0c255aab --- /dev/null +++ b/src/app/features/global-masters/cities/components/city-form-modal/city-form-modal.ts @@ -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(false); + readonly mode = input('create'); + readonly cityId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedCity = signal(null); + readonly selectedFormCountry = signal(null); + readonly selectedFormState = signal(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(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 = (term, page) => + this.countryApi.autocomplete(term, page); + readonly countryValueFn: AutocompleteValueFn = country => country.id; + readonly countryDisplayFn: AutocompleteDisplayFn = country => country.name; + + readonly stateSearchFn: AutocompleteSearchFn = (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 = state => state.id; + readonly stateDisplayFn: AutocompleteDisplayFn = state => state.name; + + readonly timezoneSearchFn: AutocompleteSearchFn = (term, page) => + this.timezoneApi.autocomplete(term, page); + readonly timezoneValueFn: AutocompleteValueFn = tz => tz.id; + readonly timezoneDisplayFn: AutocompleteDisplayFn = 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.`); + } +} diff --git a/src/app/features/global-masters/cities/data-access/city.endpoints.ts b/src/app/features/global-masters/cities/data-access/city.endpoints.ts index 0c668c39..39048906 100644 --- a/src/app/features/global-masters/cities/data-access/city.endpoints.ts +++ b/src/app/features/global-masters/cities/data-access/city.endpoints.ts @@ -7,5 +7,9 @@ export const CITY_ENDPOINTS = { buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`), update: (id: string) => buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`), + delete: (id: string) => + buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}`), + changeStatus: (id: string) => + buildApiUrl('masterAdmin', `/v1/cities/${encodeURIComponent(id)}/status`), autocomplete: buildApiUrl('masterAdmin', '/v1/cities/autocomplete') } as const; diff --git a/src/app/features/global-masters/cities/data-access/city.service.ts b/src/app/features/global-masters/cities/data-access/city.service.ts index dd8a49a1..fb6caba2 100644 --- a/src/app/features/global-masters/cities/data-access/city.service.ts +++ b/src/app/features/global-masters/cities/data-access/city.service.ts @@ -6,7 +6,8 @@ import { CITY_ENDPOINTS } from './city.endpoints'; import { CityDto, CreateCityRequest, - UpdateCityRequest + UpdateCityRequest, + UpdateCityStatusRequest } from '../models/city.model'; import { DataTableQuery, @@ -40,6 +41,14 @@ export class CityService { return this.http.put(CITY_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateCityStatusRequest): Observable { + return this.http.patch(CITY_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(CITY_ENDPOINTS.delete(id)); + } + getCityById(id: string): Observable { return this.http.get(CITY_ENDPOINTS.getById(id)); } diff --git a/src/app/features/global-masters/cities/models/city.model.ts b/src/app/features/global-masters/cities/models/city.model.ts index 19a7dbf2..97a044e9 100644 --- a/src/app/features/global-masters/cities/models/city.model.ts +++ b/src/app/features/global-masters/cities/models/city.model.ts @@ -2,8 +2,10 @@ export interface CityDto { id: string; stateId: string; name: string; - state:string; - country:string; + state?: string | { name?: string }; + stateName?: string; + country?: string | { name?: string }; + countryName?: string; code: string | null; timezoneId: string | null; isActive: boolean; @@ -25,4 +27,9 @@ export interface UpdateCityRequest { isActive: boolean; } -export type CityModalMode = 'create' | 'edit'; +export interface UpdateCityStatusRequest { + isActive: boolean; +} + +export type CityModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/cities/pages/city-list/city-list.html b/src/app/features/global-masters/cities/pages/city-list/city-list.html index 280df3fd..ea08ca13 100644 --- a/src/app/features/global-masters/cities/pages/city-list/city-list.html +++ b/src/app/features/global-masters/cities/pages/city-list/city-list.html @@ -1,170 +1,97 @@ - -
-
- -
-
- -
+ + +
+ +
-
- -
-
- -
- -
-
-
- - - - - -
-
-
- -
- -
- -
- -
- -
- -
- -
- -
- -
+
+ +
+
+
- + + + + + + + diff --git a/src/app/features/global-masters/cities/pages/city-list/city-list.ts b/src/app/features/global-masters/cities/pages/city-list/city-list.ts index f535d001..c15fe4d8 100644 --- a/src/app/features/global-masters/cities/pages/city-list/city-list.ts +++ b/src/app/features/global-masters/cities/pages/city-list/city-list.ts @@ -1,53 +1,34 @@ -import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { - Subject, - catchError, - debounceTime, - distinctUntilChanged, - finalize, - map, - of, - switchMap, - take -} from 'rxjs'; +import { of } from 'rxjs'; +import { catchError, finalize } from 'rxjs/operators'; -import { - CityDto, - CityModalMode, - CreateCityRequest, - UpdateCityRequest -} from '../../models/city.model'; +import { CityDto, UpdateCityRequest } from '../../models/city.model'; import { CountryLookupDto } from '../../../countries/models/country.model'; import { StateLookupDto } from '../../../states/models/state.model'; -import { TimezoneDto, TimezoneLookupDto } from '../../../timezones/models/timezone.model'; -import { CityService } from '../../../cities/data-access/city.service'; +import { CityService } from '../../data-access/city.service'; import { CountryService } from '../../../countries/data-access/country.service'; import { StateService } from '../../../states/data-access/state.service'; -import { TimezoneService } from '../../../timezones/data-access/timezone.service'; import { DataTable } from '../../../../../shared/components/data-table/data-table'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { AutocompleteDisplayFn, - AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../../shared/components/form/autocomplete/autocomplete.types'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { FilterCard } from '../../../../../shared/components/filter-card/filter-card'; +import { Button } from '../../../../../shared/components/button/button'; +import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { CityFormModalComponent } from '../../components/city-form-modal/city-form-modal'; interface CityTableRow extends DataTableRecord { id: string; @@ -59,6 +40,8 @@ interface CityTableRow extends DataTableRecord { serialNumber: number; state: string; country: string; + stateName: string; + countryName: string; createdOn?: string; modifiedOn?: string | null; } @@ -66,495 +49,213 @@ interface CityTableRow extends DataTableRecord { @Component({ selector: 'city-list', standalone: true, - imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard], + imports: [ + DataTable, + ReactiveFormsModule, + Autocomplete, + FilterCard, + Button, + ConfirmDialog, + CityFormModalComponent + ], + providers: [DataTableStore], templateUrl: './city-list.html', styleUrl: './city-list.scss' }) -export class CityList { +export class CityList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly cityApi = inject(CityService); private readonly countryApi = inject(CountryService); private readonly stateApi = inject(StateService); - private readonly timezoneApi = inject(TimezoneService); private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly cityQueryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - readonly cities = signal([]); - readonly selectedCountryId = signal(null); - readonly selectedStateId = signal(null); - readonly appliedCountryId = signal(null); - readonly appliedStateId = signal(null); readonly selectedCountry = signal(null); readonly selectedFilterState = signal(null); - readonly selectedFormCountry = signal(null); - readonly selectedFormState = signal(null); - readonly totalRecords = signal(0); - readonly saving = signal(false); - readonly showCityModal = signal(false); - readonly modalMode = signal('create'); - readonly selectedCity = signal(null); - readonly submitAttempted = signal(false); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteCity = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly filterForm = this.formBuilder.nonNullable.group({ countryId: [''], stateId: [{ value: '', disabled: true }] }); - readonly cityForm = this.formBuilder.nonNullable.group({ - countryId: ['', Validators.required], - stateId: ['', Validators.required], - name: ['', [Validators.required, Validators.maxLength(150)]], - code: [ - '', - [ - Validators.required, - Validators.maxLength(16), - Validators.pattern(/^[A-Za-z0-9_-]+$/) - ] - ], - timezoneId: this.formBuilder.control(null) - }); - - readonly searchTimezones: AutocompleteSearchFn = - (term, limit) => this.timezoneApi.autocomplete(term, limit); - readonly searchCountries: AutocompleteSearchFn = - (term, limit) => this.countryApi.autocomplete(term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load countries.'); - return of([]); - }) - ); - readonly searchFilterStates: AutocompleteSearchFn = - (term, limit) => { - const countryId = this.selectedCountryId(); - if (!countryId) return of([]); - return this.stateApi.autocomplete(countryId, term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load states.'); - return of([]); - }) - ); - }; - readonly searchFormStates: AutocompleteSearchFn = - (term, limit) => { - const countryId = this.cityForm.controls.countryId.value; - if (!countryId) return of([]); - return this.stateApi.autocomplete(countryId, term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load states.'); - return of([]); - }) - ); - }; - readonly displayCountry: AutocompleteDisplayFn = country => country.name; - readonly countryValue: AutocompleteValueFn = country => country.id; - readonly resolveCountry: AutocompleteResolveValueFn = - value => this.countryApi.getCountryById(value).pipe( - map(country => ({ id: country.id, iso2: country.iso2, name: country.name })) - ); - readonly displayState: AutocompleteDisplayFn = state => state.name; - readonly stateValue: AutocompleteValueFn = state => state.id; - readonly resolveState: AutocompleteResolveValueFn = - value => this.stateApi.getStateById(value).pipe( - map(state => ({ id: state.id, name: state.name, code: state.code ?? '' })) - ); - readonly displayTimezone: AutocompleteDisplayFn = - timezone => `${timezone.ianaId} — ${timezone.displayName}`; - readonly timezoneValue: AutocompleteValueFn = - timezone => timezone.id; - readonly resolveTimezone: AutocompleteResolveValueFn = - 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[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '70px' }, - { key: 'name', label: 'City Name', header: 'City Name', sortable: true, align: 'left' }, - { key: 'code', label: 'Code', header: 'Code', sortable: true }, - { key: 'stateName', label: 'State', header: 'State', sortable: false }, - { key: 'countryName', label: 'Country', header: 'Country', sortable: false }, + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'name', label: 'City Name', header: 'City Name', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' }, + { key: 'stateName', label: 'State', header: 'State', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'countryName', label: 'Country', header: 'Country', sortable: true, headerAlign: 'center', align: 'center' }, { - key: 'isActive', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - width: '100px', - formatter: value => value ? 'Active' : 'Inactive', - badgeClass: value => value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger' + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', + formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ - // { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' }, { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'deactivate', - label: 'Deactivate', - icon: 'ti ti-ban', - className: 'text-danger', - visible: row => row.isActive + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id + }, + { + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.configureCityQueries(); - this.configureFilterChanges(); - this.configureFormChanges(); - } + readonly filterCountrySearchFn: AutocompleteSearchFn = (term, page) => + this.countryApi.autocomplete(term, page); + readonly filterCountryValueFn: AutocompleteValueFn = c => c.id; + readonly filterCountryDisplayFn: AutocompleteDisplayFn = c => c.name; + + readonly filterStateSearchFn: AutocompleteSearchFn = (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 = s => s.id; + readonly filterStateDisplayFn: AutocompleteDisplayFn = s => s.name; ngOnInit(): void { - this.loadCities(this.queryState.getQuery()); + this.tableStore.initialize({ + fetcher: query => { + const countryId = this.filterForm.controls.countryId.value || null; + const stateId = this.filterForm.controls.stateId.value || null; + return this.cityApi.getCityDataTable(query, countryId, stateId); + }, + mapRow: (city, serialNumber) => { + const resolvedStateName = city.stateName + || (typeof city.state === 'string' ? city.state : city.state?.name) + || '—'; + const resolvedCountryName = city.countryName + || (typeof city.country === 'string' ? city.country : city.country?.name) + || '—'; + + return { + id: city.id, + stateId: city.stateId, + name: city.name, + code: city.code, + timezoneId: city.timezoneId, + isActive: city.isActive, + serialNumber, + state: resolvedStateName, + country: resolvedCountryName, + stateName: resolvedStateName, + countryName: resolvedCountryName, + createdOn: city.createdOn, + modifiedOn: city.modifiedOn + }; + } + }); } - onFilterCountrySelected(country: CountryLookupDto): void { + onFilterCountryChanged(country: CountryLookupDto | null): void { this.selectedCountry.set(country); + this.filterForm.controls.countryId.setValue(country ? country.id : ''); + this.filterForm.controls.stateId.setValue(''); + this.selectedFilterState.set(null); + if (country) { + this.filterForm.controls.stateId.enable(); + } else { + this.filterForm.controls.stateId.disable(); + } } - onFilterCountryCleared(): void { - this.selectedCountry.set(null); - } - - onFilterStateSelected(state: StateLookupDto): void { + onFilterStateChanged(state: StateLookupDto | null): void { this.selectedFilterState.set(state); } - onFilterStateCleared(): void { - this.selectedFilterState.set(null); - } - - applyCityFilters(): void { - const countryId = this.filterForm.controls.countryId.value || null; - const stateId = this.filterForm.controls.stateId.value || null; - - this.appliedCountryId.set(countryId); - this.appliedStateId.set(stateId); - - this.loadCities(this.queryState.setPage({ - pageIndex: 1, - pageSize: this.queryState.pageSize() - })); - } - - onFormCountrySelected(country: CountryLookupDto): void { - this.selectedFormCountry.set(country); - } - - onFormCountryCleared(): void { - this.selectedFormCountry.set(null); - } - - onFormStateSelected(state: StateLookupDto): void { - this.selectedFormState.set(state); - } - - onFormStateCleared(): void { - this.selectedFormState.set(null); - } - - loadCities(query: DataTableQuery): void { - this.cityQueryRequests$.next(query); - } - - onSearch(value: string): void { - this.loadCities(this.queryState.setSearch(value.trim())); - } - - onPageChange(event: DataTablePageEvent): void { - this.loadCities(this.queryState.setPage(event)); - } - - onSortChange(event: DataTableSortEvent): void { - this.loadCities(this.queryState.setSort(event)); - } - - onActionClick(event: DataTableActionEvent): void { - const city = this.toCityDto(event.row); - switch (event.action.type) { - case 'edit': - this.openExistingCity(city, 'edit'); - break; - case 'activate': - this.updateCityStatus(city, true); - break; - case 'deactivate': - this.updateCityStatus(city, false); - break; + onApplyFilter(event?: Event): void { + event?.preventDefault(); + event?.stopPropagation(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); } + this.tableStore.refresh(); + } + + onResetFilter(): void { + this.filterForm.reset({ countryId: '', stateId: '' }); + this.filterForm.controls.stateId.disable(); + this.selectedCountry.set(null); + this.selectedFilterState.set(null); + this.tableStore.reset(); } onAddCity(): void { - this.modalMode.set('create'); - this.selectedCity.set(null); - this.submitAttempted.set(false); - this.selectedFormCountry.set(null); - this.selectedFormState.set(null); - this.cityForm.enable({ emitEvent: false }); - this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { emitEvent: false }); - this.resetFormState(); - this.showCityModal.set(true); + this.tableStore.openCreateModal(); } - closeCityModal(): void { - if (this.saving()) return; - this.showCityModal.set(false); - this.selectedCity.set(null); - this.selectedFormCountry.set(null); - this.selectedFormState.set(null); - this.submitAttempted.set(false); + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteCity(event.row); + if (event.action.type === 'activate') this.changeCityStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeCityStatus(event.row, false); } - saveCity(): void { - if (this.saving()) return; - if (this.cityForm.invalid) { - this.submitAttempted.set(true); - this.cityForm.markAllAsTouched(); - this.focusFirstInvalidControl(); - return; - } + onDeleteConfirmed(): void { + const city = this.pendingDeleteCity(); + if (!city) return; + this.pendingDeleteCity.set(null); + this.deletingId.set(city.id); - this.saving.set(true); - const city = this.selectedCity(); - const request$ = this.modalMode() === 'create' - ? this.cityApi.createCity(this.buildCreateRequest()) - : city - ? this.cityApi.updateCity(city.id, this.buildUpdateRequest(city.isActive)) - : null; - - if (!request$) { - this.saving.set(false); - return; - } - - request$.pipe(finalize(() => this.saving.set(false))).subscribe({ + this.cityApi.delete(city.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ next: () => { - this.toastr.success( - this.modalMode() === 'create' - ? 'City saved successfully.' - : 'City updated successfully.' - ); - this.showCityModal.set(false); - this.selectedCity.set(null); - this.loadCities(this.queryState.getQuery()); + this.toastr.success('City deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete city.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete city because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'City not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); } }); } - private configureCityQueries(): void { - this.cityQueryRequests$.pipe( - switchMap(query => { - const stateId = this.appliedStateId(); - const countryId = this.appliedCountryId(); - return this.cityApi.getCityDataTable(query, countryId, stateId).pipe( - catchError(() => { - this.toastr.error('Unable to load cities.'); - this.clearGrid(); - return of(null); - }) - ); - }), - takeUntilDestroyed(this.destroyRef) - ).subscribe(response => { - if (!response) return; - const query = this.queryState.getQuery(); - if (response.draw !== query.draw) return; - - this.cities.set(response.rows.map((city, index) => ({ - ...city, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - }))); - this.totalRecords.set(response.filtered); - }); + onDeleteCancelled(): void { + this.pendingDeleteCity.set(null); } - private configureFilterChanges(): void { - this.filterForm.controls.countryId.valueChanges.pipe( - distinctUntilChanged(), + private requestDeleteCity(city: CityTableRow): void { + this.pendingDeleteCity.set(city); + this.deleteConfirmDialog()?.open(); + } + + private changeCityStatus(city: CityTableRow, activate: boolean): void { + this.statusChangingId.set(city.id); + + this.cityApi.updateStatus(city.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), takeUntilDestroyed(this.destroyRef) - ).subscribe(countryId => { - if (!countryId || this.selectedCountry()?.id !== countryId) { - this.selectedCountry.set(null); + ).subscribe({ + next: () => { + this.toastr.success(`City ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} city.`; + this.toastr.error(msg); } - - this.selectedCountryId.set(countryId || null); - this.selectedStateId.set(null); - this.selectedFilterState.set(null); - - this.filterForm.controls.stateId.reset('', { emitEvent: false }); - countryId - ? this.filterForm.controls.stateId.enable({ emitEvent: false }) - : this.filterForm.controls.stateId.disable({ emitEvent: false }); - }); - - this.filterForm.controls.stateId.valueChanges.pipe( - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef) - ).subscribe(stateId => { - if (!stateId || this.selectedFilterState()?.id !== stateId) { - this.selectedFilterState.set(null); - } - - this.selectedStateId.set(stateId || null); - }); - } - - private configureFormChanges(): void { - this.cityForm.controls.countryId.valueChanges.pipe( - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef) - ).subscribe(countryId => { - if (!this.showCityModal() || this.modalMode() !== 'create') return; - - if (!countryId || this.selectedFormCountry()?.id !== countryId) { - this.selectedFormCountry.set(null); - } - - this.selectedFormState.set(null); - this.cityForm.controls.stateId.reset('', { emitEvent: false }); - this.cityForm.controls.stateId.enable({ emitEvent: false }); - }); - } - - private openExistingCity(city: CityDto, mode: 'edit'): void { - this.cityApi.getCityById(city.id).pipe( - switchMap(details => this.stateApi.getStateById(details.stateId).pipe( - map(stateDetails => ({ details, countryId: stateDetails.countryId })) - )), - take(1) - ).subscribe(({ details, countryId }) => { - this.selectedCity.set(details); - this.modalMode.set(mode); - this.submitAttempted.set(false); - this.selectedFormCountry.set(null); - this.selectedFormState.set(null); - this.cityForm.enable({ emitEvent: false }); - this.cityForm.reset({ - countryId, - stateId: details.stateId, - name: details.name ?? '', - code: details.code ?? '', - timezoneId: details.timezoneId - }, { emitEvent: false }); - // this.cityForm.controls.countryId.disable({ emitEvent: false }); - // this.cityForm.controls.stateId.disable({ emitEvent: false }); - this.resetFormState(); - this.showCityModal.set(true); - }); - } - - private updateCityStatus(city: CityDto, isActive: boolean): void { - this.cityApi.updateCity(city.id, { - name: city.name.trim(), - code: city.code?.trim().toUpperCase() ?? '', - timezoneId: city.timezoneId, - isActive - }).subscribe(() => { - this.toastr.success( - isActive ? 'City activated successfully.' : 'City deactivated successfully.' - ); - this.loadCities(this.queryState.getQuery()); - }); - } - - private buildCreateRequest(): CreateCityRequest { - const value = this.cityForm.getRawValue(); - return { - stateId: value.stateId, - name: value.name.trim(), - code: value.code.trim().toUpperCase(), - timezoneId: value.timezoneId - }; - } - - private buildUpdateRequest(isActive: boolean): UpdateCityRequest { - const value = this.cityForm.getRawValue(); - return { - name: value.name.trim(), - code: value.code.trim().toUpperCase(), - timezoneId: value.timezoneId, - isActive - }; - } - - private clearGrid(): void { - this.cities.set([]); - this.totalRecords.set(0); - } - - private resetFormState(): void { - this.cityForm.markAsPristine(); - this.cityForm.markAsUntouched(); - this.cityForm.updateValueAndValidity(); - } - - private focusFirstInvalidControl(): void { - queueMicrotask(() => { - const control = this.elementRef.nativeElement.querySelector( - '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 - }; } } diff --git a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html new file mode 100644 index 00000000..f19802ab --- /dev/null +++ b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html @@ -0,0 +1,96 @@ + + @if (modalLoading()) { +
+ + Loading country... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
\ No newline at end of file diff --git a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts new file mode 100644 index 00000000..f5eccd98 --- /dev/null +++ b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.ts @@ -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(false); + readonly mode = input('create'); + readonly countryId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly countrySubmitAttempted = signal(false); + readonly selectedCountry = signal(null); + readonly selectedCurrency = signal(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(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 = (term, page) => + this.currencyApi.autocomplete(term, page); + readonly currencyValueFn: AutocompleteValueFn = currency => currency.id; + readonly currencyDisplayFn: AutocompleteDisplayFn = 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.`); + } +} diff --git a/src/app/features/global-masters/countries/data-access/country.service.ts b/src/app/features/global-masters/countries/data-access/country.service.ts index 10d406b0..3865932a 100644 --- a/src/app/features/global-masters/countries/data-access/country.service.ts +++ b/src/app/features/global-masters/countries/data-access/country.service.ts @@ -11,6 +11,7 @@ import { CountryLookupDto, CreateCountryRequest, UpdateCountryRequest, + UpdateCountryStatusRequest, } from '../models/country.model'; import { COUNTRY_ENDPOINTS } from './country.endpoints'; @@ -32,6 +33,14 @@ export class CountryService { return this.http.put(COUNTRY_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateCountryStatusRequest): Observable { + return this.http.patch(COUNTRY_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(COUNTRY_ENDPOINTS.delete(id)); + } + getCountryById(id: string): Observable { return this.http.get(COUNTRY_ENDPOINTS.getById(id)); } @@ -42,3 +51,4 @@ export class CountryService { }); } } + diff --git a/src/app/features/global-masters/countries/models/country.model.ts b/src/app/features/global-masters/countries/models/country.model.ts index 9c4b220f..9020295f 100644 --- a/src/app/features/global-masters/countries/models/country.model.ts +++ b/src/app/features/global-masters/countries/models/country.model.ts @@ -28,4 +28,9 @@ export interface UpdateCountryRequest extends CreateCountryRequest { isActive: boolean; } -export type CountryModalMode = 'create' | 'edit'; +export interface UpdateCountryStatusRequest { + isActive: boolean; +} + +export type CountryModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/countries/pages/country-list/country-list.html b/src/app/features/global-masters/countries/pages/country-list/country-list.html index 43a71e14..1bfbf840 100644 --- a/src/app/features/global-masters/countries/pages/country-list/country-list.html +++ b/src/app/features/global-masters/countries/pages/country-list/country-list.html @@ -1,19 +1,34 @@ - +
@if (getFlagUrl(row.iso2); as flagUrl) { - + } - - - {{ value }} - + {{ value }}
@@ -27,70 +42,10 @@ (cancelled)="onDeleteCancelled()" /> - -
-
-
- -
-
- -
- -
- -
- -
- -
- -
- -
-
-
-
+ diff --git a/src/app/features/global-masters/countries/pages/country-list/country-list.ts b/src/app/features/global-masters/countries/pages/country-list/country-list.ts index a46ddcf1..24103be6 100644 --- a/src/app/features/global-masters/countries/pages/country-list/country-list.ts +++ b/src/app/features/global-masters/countries/pages/country-list/country-list.ts @@ -1,42 +1,21 @@ -import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - FormBuilder, - ReactiveFormsModule, - Validators -} from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs'; +import { finalize } from 'rxjs/operators'; -import { - CountryDto, - CountryModalMode, - CreateCountryRequest, - UpdateCountryRequest -} from '../../models/country.model'; -import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api'; +import { CountryDto, UpdateCountryRequest } from '../../models/country.model'; import { CountryService } from '../../data-access/country.service'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { DataTable } from '../../../../../shared/components/data-table/data-table'; import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; -import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; -import { - AutocompleteDisplayFn, - AutocompleteSearchFn, - AutocompleteValueFn -} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { CountryFormModalComponent } from '../../components/country-form-modal/country-form-modal'; interface CountryTableRow extends DataTableRecord { id: string; @@ -54,380 +33,110 @@ interface CountryTableRow extends DataTableRecord { @Component({ selector: 'country-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog], + imports: [DataTable, DataTableCellDirective, ConfirmDialog, CountryFormModalComponent], + providers: [DataTableStore], templateUrl: './country-list.html', styleUrl: './country-list.scss', }) -export class CountryList { +export class CountryList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly countryApi = inject(CountryService); - private readonly currencyApi = inject(CurrencyService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly countryQueryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - - readonly countries = signal([]); - readonly totalRecords = signal(0); - readonly filteredRecords = signal(0); - readonly saving = signal(false); - - readonly showCountryModal = signal(false); - readonly countryModalMode = signal('create'); - readonly selectedCountryId = signal(null); - readonly selectedCountry = signal(null); - readonly selectedCurrency = signal(null); - readonly countrySubmitAttempted = signal(false); - readonly pendingDeleteCountry = signal(null); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteCountry = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly countryForm = this.formBuilder.nonNullable.group({ - name: [ - '', - [ - Validators.required, - Validators.maxLength(150) - ] - ], - iso2: [ - '', - [ - Validators.required, - Validators.pattern(/^[A-Za-z]{2}$/) - ] - ], - iso3: [ - '', - [ - Validators.required, - Validators.pattern(/^[A-Za-z]{3}$/) - ] - ], - phoneCode: [ - '', - [ - Validators.maxLength(16), - Validators.pattern(/^\+?[0-9\- ]{1,15}$/) - ] - ], - defaultCurrencyId: this.formBuilder.control(null) - }); - - readonly searchCurrencies: AutocompleteSearchFn = - (term, limit) => this.currencyApi.autocomplete(term, limit); - readonly displayCurrency: AutocompleteDisplayFn = currency => { - const baseLabel = [currency.code, currency.name].filter(Boolean).join(' - '); - - return currency.symbol?.trim() - ? `${baseLabel} (${currency.symbol})` - : baseLabel; - }; - readonly currencyValue: AutocompleteValueFn = 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[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, - { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' }, - { key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true }, - { key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true }, - { key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true }, - { key: 'currencyName', label: 'Default Currency', header: 'Default Currency', sortable: true, align: 'left' }, + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true, + badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' + }, + { key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true, + badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' + }, { - key: 'isActive', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - badgeClass: value => - value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', + key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true, headerAlign: 'center', align: 'center', + formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' + }, + { + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.countryQueryRequests$ - .pipe( - switchMap(query => - this.countryApi.getCountryDataTable(query).pipe( - catchError(() => { - this.toastr.error('Unable to load countries.'); - this.clearCountryGrid(); - return of(null); - }) - ) - ), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(response => { - if (!response) { - return; - } - - const query = this.queryState.getQuery(); - - if (response.draw !== query.draw) { - return; - } - - const countriesWithSerialNumbers: CountryTableRow[] = response.rows.map((country, index) => ({ - ...country, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.countries.set(countriesWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }); - } - ngOnInit(): void { - this.loadCountries(this.queryState.getQuery()); - } - - loadCountries(query: DataTableQuery): void { - this.countryQueryRequests$.next(query); - } - - onSearch(value: string): void { - const query = this.queryState.setSearch(value.trim()); - this.loadCountries(query); - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.queryState.setPage(event); - this.loadCountries(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.queryState.setSort(event); - this.loadCountries(query); - } - - onRefresh(): void { - const currentQuery = this.queryState.getQuery(); - - this.loadCountries({ - ...currentQuery, - draw: currentQuery.draw + 1 + this.tableStore.initialize({ + fetcher: query => this.countryApi.getCountryDataTable(query) }); } - onReset(): void { - const query = this.queryState.reset(); - this.loadCountries(query); + onAddCountry(): void { + this.tableStore.openCreateModal(); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteCountry(event.row); + if (event.action.type === 'activate') this.changeCountryStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeCountryStatus(event.row, false); } onDeleteConfirmed(): void { const country = this.pendingDeleteCountry(); - - if (!country) { - return; - } - + if (!country) return; this.pendingDeleteCountry.set(null); - this.deleteCountry(country); + this.deletingId.set(country.id); + + this.countryApi.delete(country.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success('Country deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete country.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete country because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Country not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); + } + }); } onDeleteCancelled(): void { this.pendingDeleteCountry.set(null); } - onActionClick(event: DataTableActionEvent): void { - const country = this.toCountryDto(event.row); - - switch (event.action.type) { - case 'view': - this.viewCountry(country); - break; - case 'edit': - this.openEditCountry(country); - break; - case 'delete': - this.requestDeleteCountry(country); - break; - case 'activate': - this.activateCountry(country); - break; - } - } - - onAddCountry(): void { - this.countryModalMode.set('create'); - this.selectedCountryId.set(null); - this.selectedCountry.set(null); - this.selectedCurrency.set(null); - this.countrySubmitAttempted.set(false); - - this.countryForm.reset({ - name: '', - iso2: '', - iso3: '', - phoneCode: '', - defaultCurrencyId: null - }); - this.resetCountryFormState(); - - this.showCountryModal.set(true); - } - - closeCountryModal(): void { - if (this.saving()) { - return; - } - - this.showCountryModal.set(false); - this.selectedCountryId.set(null); - this.selectedCountry.set(null); - this.selectedCurrency.set(null); - this.countrySubmitAttempted.set(false); - } - - saveCountry(): void { - if (this.countryForm.invalid) { - this.countrySubmitAttempted.set(true); - this.countryForm.markAllAsTouched(); - this.focusFirstInvalidCountryControl(); - return; - } - - if (this.saving()) { - return; - } - - this.saving.set(true); - - if (this.countryModalMode() === 'create') { - this.countryApi - .createCountry(this.buildCreateCountryRequest()) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('Country saved successfully.'); - this.finishCountrySave(); - } - }); - - return; - } - - const countryId = this.selectedCountryId(); - - if (!countryId) { - this.saving.set(false); - return; - } - - this.countryApi - .updateCountry( - countryId, - this.buildUpdateCountryRequest(this.selectedCountry()?.isActive ?? true) - ) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('Country updated successfully.'); - this.finishCountrySave(); - } - }); - } - - openEditCountry(country: CountryDto): void { - this.countryModalMode.set('edit'); - this.selectedCountryId.set(country.id); - this.selectedCountry.set(null); - this.selectedCurrency.set(null); - this.countrySubmitAttempted.set(false); - - this.countryApi - .getCountryById(country.id) - .pipe( - switchMap(countryDetails => { - const currencyId = countryDetails.defaultCurrencyId; - - if (!currencyId) { - return of({ countryDetails, currency: null }); - } - - return this.currencyApi.getCurrencyById(currencyId).pipe( - map(currency => ({ countryDetails, currency })), - catchError(() => { - this.toastr.error('Unable to load the selected currency.'); - return of({ countryDetails, currency: null }); - }) - ); - }) - ) - .subscribe({ - next: ({ countryDetails, currency }) => { - this.selectedCountry.set(countryDetails); - this.selectedCurrency.set(currency); - this.countryForm.reset({ - name: countryDetails.name ?? '', - iso2: countryDetails.iso2 ?? '', - iso3: countryDetails.iso3 ?? '', - phoneCode: countryDetails.phoneCode ?? '', - defaultCurrencyId: countryDetails.defaultCurrencyId ?? null - }); - this.resetCountryFormState(); - - this.showCountryModal.set(true); - } - }); - } - getFlagUrl(iso2: string | null | undefined): string { const code = iso2?.trim().toLowerCase(); - return code && /^[a-z]{2}$/.test(code) ? `https://flagcdn.com/24x18/${code}.png` : ''; @@ -438,122 +147,26 @@ export class CountryList { image.style.display = 'none'; } - private viewCountry(country: CountryDto): void { - this.openEditCountry(country); - } - - private requestDeleteCountry(country: CountryDto): void { + private requestDeleteCountry(country: CountryTableRow): void { this.pendingDeleteCountry.set(country); this.deleteConfirmDialog()?.open(); } - private deleteCountry(country: CountryDto): void { - this.updateCountryStatus(country, false); - } + private changeCountryStatus(country: CountryTableRow, activate: boolean): void { + this.statusChangingId.set(country.id); - private activateCountry(country: CountryDto): void { - this.updateCountryStatus(country, true); - } - - private buildCreateCountryRequest(): CreateCountryRequest { - const value = this.countryForm.getRawValue(); - - return { - name: value.name.trim(), - iso2: value.iso2.trim().toUpperCase(), - iso3: value.iso3.trim().toUpperCase(), - phoneCode: this.nullWhenBlank(value.phoneCode), - defaultCurrencyId: this.nullWhenBlank(value.defaultCurrencyId) - }; - } - - private buildUpdateCountryRequest(isActive: boolean): UpdateCountryRequest { - return { - ...this.buildCreateCountryRequest(), - isActive - }; - } - - private countryToUpdateRequest(country: CountryDto, isActive: boolean): UpdateCountryRequest { - return { - name: country.name?.trim() ?? '', - iso2: country.iso2?.trim().toUpperCase() ?? '', - iso3: country.iso3?.trim().toUpperCase() ?? '', - phoneCode: this.nullWhenBlank(country.phoneCode), - defaultCurrencyId: this.nullWhenBlank(country.defaultCurrencyId), - isActive - }; - } - - private updateCountryStatus(country: CountryDto, isActive: boolean): void { - this.countryApi - .updateCountry(country.id, this.countryToUpdateRequest(country, isActive)) - .subscribe({ - next: () => { - this.toastr.success( - isActive - ? 'Country activated successfully.' - : 'Country deactivated successfully.' - ); - this.loadCountries(this.queryState.getQuery()); - } - }); - } - - private resetCountryFormState(): void { - this.countryForm.markAsPristine(); - this.countryForm.markAsUntouched(); - this.countryForm.updateValueAndValidity(); - } - - private finishCountrySave(): void { - this.showCountryModal.set(false); - this.selectedCountryId.set(null); - this.selectedCountry.set(null); - this.countrySubmitAttempted.set(false); - this.loadCountries(this.queryState.getQuery()); - } - - private clearCountryGrid(): void { - this.countries.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); - } - - private focusFirstInvalidCountryControl(): void { - queueMicrotask(() => { - const firstInvalidControl = - this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - - firstInvalidControl?.focus(); - firstInvalidControl?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); + this.countryApi.updateStatus(country.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(`Country ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} country.`; + this.toastr.error(msg); + } }); } - - private nullWhenBlank(value: string | null | undefined): string | null { - const normalized = value?.trim(); - - return normalized - ? normalized - : null; - } - - private toCountryDto(row: CountryTableRow): CountryDto { - return { - id: row.id, - iso2: row.iso2, - iso3: row.iso3, - name: row.name, - phoneCode: row.phoneCode, - defaultCurrencyId: row.defaultCurrencyId, - isActive: row.isActive, - createdOn: row.createdOn, - modifiedOn: row.modifiedOn - }; - } } diff --git a/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.html b/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.html new file mode 100644 index 00000000..22a5c2da --- /dev/null +++ b/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.html @@ -0,0 +1,97 @@ + + @if (modalLoading()) { +
+ + Loading currency... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
diff --git a/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.ts b/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.ts new file mode 100644 index 00000000..a2dea5df --- /dev/null +++ b/src/app/features/global-masters/currencies/components/currency-form-modal/currency-form-modal.ts @@ -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(false); + readonly mode = input('create'); + readonly currencyId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly currencySubmitAttempted = signal(false); + readonly selectedCurrency = signal(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.`); + } +} diff --git a/src/app/features/global-masters/currencies/data-access/currency.service.ts b/src/app/features/global-masters/currencies/data-access/currency.service.ts index cebb5a1c..a7e348c4 100644 --- a/src/app/features/global-masters/currencies/data-access/currency.service.ts +++ b/src/app/features/global-masters/currencies/data-access/currency.service.ts @@ -7,7 +7,8 @@ import { CreateCurrencyRequest, CurrencyDto, CurrencyLookupDto, - UpdateCurrencyRequest + UpdateCurrencyRequest, + UpdateCurrencyStatusRequest } from '../models/currency.model'; import { CURRENCY_ENDPOINTS } from './currency.endpoints'; @@ -29,6 +30,14 @@ export class CurrencyService { return this.http.put(CURRENCY_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateCurrencyStatusRequest): Observable { + return this.http.patch(CURRENCY_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(CURRENCY_ENDPOINTS.delete(id)); + } + getCurrencyById(id: string): Observable { return this.http.get(CURRENCY_ENDPOINTS.getById(id)); } diff --git a/src/app/features/global-masters/currencies/models/currency.model.ts b/src/app/features/global-masters/currencies/models/currency.model.ts index 8ea67172..5c844ea7 100644 --- a/src/app/features/global-masters/currencies/models/currency.model.ts +++ b/src/app/features/global-masters/currencies/models/currency.model.ts @@ -20,7 +20,6 @@ export interface CurrencyDto { modifiedOn?: string | null; } - export interface CurrencyLookupDto { readonly id: string; readonly code: string; @@ -40,4 +39,9 @@ export interface UpdateCurrencyRequest extends CreateCurrencyRequest { isActive: boolean; } -export type CurrencyModalMode = 'create' | 'edit'; +export interface UpdateCurrencyStatusRequest { + isActive: boolean; +} + +export type CurrencyModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html index 27f32995..f32b068e 100644 --- a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html +++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.html @@ -1,10 +1,24 @@ - - + + @if (visibleCountries(row); as countries) { @if (countries.length > 0) {
@@ -100,47 +114,22 @@ {{ value }} - + + - + - -
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
+ diff --git a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts index cfeeb807..b1ce5aa0 100644 --- a/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts +++ b/src/app/features/global-masters/currencies/pages/currency-list/currency-list.ts @@ -1,45 +1,35 @@ -import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ToastrService } from 'ngx-toastr'; +import { finalize } from 'rxjs/operators'; import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedOverlayPositionChange, ConnectedPosition } from '@angular/cdk/overlay'; -import { - FormBuilder, - ReactiveFormsModule, - Validators -} from '@angular/forms'; -import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; import { - CreateCurrencyRequest, CurrencyCountryFlag, CurrencyDto, CurrencyIso2Value, - CurrencyModalMode, UpdateCurrencyRequest } from '../../models/currency.model'; import { CurrencyService } from '../../data-access/currency.service'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { DataTable } from '../../../../../shared/components/data-table/data-table'; import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { CurrencyFormModalComponent } from '../../components/currency-form-modal/currency-form-modal'; type Iso2TooltipPlacement = 'above' | 'below' | 'left' | 'right'; + interface CurrencyTableRow extends DataTableRecord { id: string; code: string; @@ -57,478 +47,145 @@ interface CurrencyTableRow extends DataTableRecord { @Component({ selector: 'currency-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog, CdkOverlayOrigin, CdkConnectedOverlay], + imports: [ + DataTable, + DataTableCellDirective, + ConfirmDialog, + CdkOverlayOrigin, + CdkConnectedOverlay, + CurrencyFormModalComponent + ], + providers: [DataTableStore], templateUrl: './currency-list.html', styleUrl: './currency-list.scss', }) -export class CurrencyList { +export class CurrencyList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly currencyApi = inject(CurrencyService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly currencyQueryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - - readonly currencies = signal([]); - readonly totalRecords = signal(0); - readonly filteredRecords = signal(0); - readonly saving = signal(false); - - readonly showCurrencyModal = signal(false); - readonly currencyModalMode = signal('create'); - readonly selectedCurrencyId = signal(null); - readonly selectedCurrency = signal(null); - readonly currencySubmitAttempted = signal(false); - readonly pendingDeleteCurrency = signal(null); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteCurrency = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly openIso2TooltipCurrencyId = signal(null); + + readonly openIso2TooltipCurrencyId = signal(null); readonly iso2TooltipPlacement = signal('right'); readonly iso2TooltipPositions: ConnectedPosition[] = [ { - originX: 'end', - originY: 'center', - overlayX: 'start', - overlayY: 'center', - offsetX: 12 - } + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 12 + } ]; private iso2TooltipCloseTimer: ReturnType | 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[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, - { key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' }, - { key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true , align: 'left'}, - { key: 'code', label: 'Code', header: 'Code', sortable: true }, - { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true }, - { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true }, - { key: 'decimalDigits', label: 'Decimal Digits', header: 'Decimal Digits', sortable: true }, + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '90px' }, + { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, + { key: 'iso2', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'center', align: 'left' }, + { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: false, headerAlign: 'center', align: 'center', width: '90px' }, + { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true, headerAlign: 'center', align: 'center', width: '130px' }, { - key: 'isActive', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - badgeClass: value => - value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.currencyQueryRequests$ - .pipe( - switchMap(query => - this.currencyApi.getCurrencyDataTable(query).pipe( - catchError(() => { - this.toastr.error('Unable to load currencies.'); - this.clearCurrencyGrid(); - return of(null); - }) - ) - ), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(response => { - if (!response) { - return; - } - - const query = this.queryState.getQuery(); - - if (response.draw !== query.draw) { - return; - } - - const currenciesWithSerialNumbers: CurrencyTableRow[] = response.rows.map((currency, index) => ({ - ...currency, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.currencies.set(currenciesWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }); - } - ngOnInit(): void { - this.loadCurrencies(this.queryState.getQuery()); - } - - loadCurrencies(query: DataTableQuery): void { - this.currencyQueryRequests$.next(query); - } - - onSearch(value: string): void { - const query = this.queryState.setSearch(value.trim()); - this.loadCurrencies(query); - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.queryState.setPage(event); - this.loadCurrencies(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.queryState.setSort(event); - this.loadCurrencies(query); - } - - onRefresh(): void { - const currentQuery = this.queryState.getQuery(); - - this.loadCurrencies({ - ...currentQuery, - draw: currentQuery.draw + 1 + this.tableStore.initialize({ + fetcher: query => this.currencyApi.getCurrencyDataTable(query) }); } - onReset(): void { - const query = this.queryState.reset(); - this.loadCurrencies(query); + onAddCurrency(): void { + this.tableStore.openCreateModal(); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteCurrency(event.row); + if (event.action.type === 'activate') this.changeCurrencyStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeCurrencyStatus(event.row, false); } onDeleteConfirmed(): void { const currency = this.pendingDeleteCurrency(); - - if (!currency) { - return; - } - + if (!currency) return; this.pendingDeleteCurrency.set(null); - this.deleteCurrency(currency); + this.deletingId.set(currency.id); + + this.currencyApi.delete(currency.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success('Currency deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete currency.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete currency because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Currency not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); + } + }); } onDeleteCancelled(): void { this.pendingDeleteCurrency.set(null); } - onActionClick(event: DataTableActionEvent): void { - const currency = this.toCurrencyDto(event.row); - - switch (event.action.type) { - case 'view': - this.viewCurrency(currency); - break; - case 'edit': - this.openEditCurrency(currency); - break; - case 'delete': - this.requestDeleteCurrency(currency); - break; - case 'activate': - this.activateCurrency(currency); - break; - } - } - - onAddCurrency(): void { - this.currencyModalMode.set('create'); - this.selectedCurrencyId.set(null); - this.selectedCurrency.set(null); - this.currencySubmitAttempted.set(false); - - this.currencyForm.reset({ - name: '', - code: '', - symbol: '', - numericCode: 0, - decimalDigits: 2 - }); - this.resetCurrencyFormState(); - - this.showCurrencyModal.set(true); - } - - closeCurrencyModal(): void { - if (this.saving()) { - return; - } - - this.showCurrencyModal.set(false); - this.selectedCurrencyId.set(null); - this.selectedCurrency.set(null); - this.currencySubmitAttempted.set(false); - } - - saveCurrency(): void { - if (this.currencyForm.invalid) { - this.currencySubmitAttempted.set(true); - this.currencyForm.markAllAsTouched(); - this.focusFirstInvalidCurrencyControl(); - return; - } - - if (this.saving()) { - return; - } - - this.saving.set(true); - - if (this.currencyModalMode() === 'create') { - this.currencyApi - .createCurrency(this.buildCreateCurrencyRequest()) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('Currency saved successfully.'); - this.finishCurrencySave(); - } - }); - - return; - } - - const currencyId = this.selectedCurrencyId(); - - if (!currencyId) { - this.saving.set(false); - return; - } - - this.currencyApi - .updateCurrency( - currencyId, - this.buildUpdateCurrencyRequest(this.selectedCurrency()?.isActive ?? true) - ) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('Currency updated successfully.'); - this.finishCurrencySave(); - } - }); - } - - private viewCurrency(currency: CurrencyDto): void { - this.openEditCurrency(currency); - } - - private openEditCurrency(currency: CurrencyDto): void { - this.currencyModalMode.set('edit'); - this.selectedCurrencyId.set(currency.id); - this.selectedCurrency.set(currency); - this.currencySubmitAttempted.set(false); - - this.currencyApi - .getCurrencyById(currency.id) - .subscribe({ - next: currencyDetails => { - this.selectedCurrency.set(currencyDetails); - this.currencyForm.reset({ - name: currencyDetails.name ?? '', - code: currencyDetails.code ?? '', - symbol: currencyDetails.symbol ?? '', - numericCode: currencyDetails.numericCode ?? '0', - decimalDigits: currencyDetails.decimalDigits ?? 2 - }); - this.resetCurrencyFormState(); - - this.showCurrencyModal.set(true); - } - }); - } - - private requestDeleteCurrency(currency: CurrencyDto): void { + private requestDeleteCurrency(currency: CurrencyTableRow): void { this.pendingDeleteCurrency.set(currency); this.deleteConfirmDialog()?.open(); } - private deleteCurrency(currency: CurrencyDto): void { - this.updateCurrencyStatus(currency, false); - } + private changeCurrencyStatus(currency: CurrencyTableRow, activate: boolean): void { + this.statusChangingId.set(currency.id); - private activateCurrency(currency: CurrencyDto): void { - this.updateCurrencyStatus(currency, true); - } - - private buildCreateCurrencyRequest(): CreateCurrencyRequest { - const value = this.currencyForm.getRawValue(); - - return { - name: value.name.trim(), - code: value.code.trim().toUpperCase(), - symbol: value.symbol.trim(), - numericCode: value.numericCode, - decimalDigits: value.decimalDigits - }; - } - - private buildUpdateCurrencyRequest(isActive: boolean): UpdateCurrencyRequest { - return { - ...this.buildCreateCurrencyRequest(), - isActive - }; - } - - private currencyToUpdateRequest(currency: CurrencyDto, isActive: boolean): UpdateCurrencyRequest { - return { - name: currency.name?.trim() ?? '', - code: currency.code?.trim().toUpperCase() ?? '', - symbol: currency.symbol?.trim() ?? '', - numericCode: currency.numericCode, - decimalDigits: currency.decimalDigits, - isActive - }; - } - - private updateCurrencyStatus(currency: CurrencyDto, isActive: boolean): void { - this.currencyApi - .updateCurrency(currency.id, this.currencyToUpdateRequest(currency, isActive)) - .subscribe({ - next: () => { - this.toastr.success( - isActive - ? 'Currency activated successfully.' - : 'Currency deactivated successfully.' - ); - this.loadCurrencies(this.queryState.getQuery()); - } - }); - } - - private resetCurrencyFormState(): void { - this.currencyForm.markAsPristine(); - this.currencyForm.markAsUntouched(); - this.currencyForm.updateValueAndValidity(); - } - - private finishCurrencySave(): void { - this.showCurrencyModal.set(false); - this.selectedCurrencyId.set(null); - this.selectedCurrency.set(null); - this.currencySubmitAttempted.set(false); - this.loadCurrencies(this.queryState.getQuery()); - } - - private clearCurrencyGrid(): void { - this.currencies.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); - } - - private focusFirstInvalidCurrencyControl(): void { - queueMicrotask(() => { - const firstInvalidControl = - this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - - firstInvalidControl?.focus(); - firstInvalidControl?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); + this.currencyApi.updateStatus(currency.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(`Currency ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} currency.`; + this.toastr.error(msg); + } }); } - private toCurrencyDto(row: CurrencyTableRow): CurrencyDto { - return { - id: row.id, - code: row.code, - iso2: row.iso2, - name: row.name, - symbol: row.symbol, - numericCode: row.numericCode, - decimalDigits: row.decimalDigits, - isActive: row.isActive, - createdOn: row.createdOn, - modifiedOn: row.modifiedOn - }; - } - visibleCountries(row: CurrencyTableRow): readonly CurrencyCountryFlag[] { return this.normalizeIso2Codes(row.iso2).slice(0, 1); } @@ -589,7 +246,7 @@ export class CurrencyList { const rawCode = typeof item === 'string' ? item : item && typeof item === 'object' && 'iso2' in item - ? String(item.iso2) + ? String((item as { iso2?: unknown }).iso2) : ''; const iso2 = rawCode.trim().toUpperCase(); @@ -615,6 +272,7 @@ export class CurrencyList { ? `https://flagcdn.com/24x18/${code}.png` : ''; } + onFlagError(event: Event): void { const image = event.target as HTMLImageElement; image.classList.add('hidden'); diff --git a/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.html b/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.html new file mode 100644 index 00000000..b1c290fb --- /dev/null +++ b/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.html @@ -0,0 +1,38 @@ + + @if (modalLoading()) { +
+ + Loading language... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ } +
\ No newline at end of file diff --git a/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.ts b/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.ts new file mode 100644 index 00000000..1c5af17f --- /dev/null +++ b/src/app/features/global-masters/languages/components/language-form-modal/language-form-modal.ts @@ -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(false); + readonly mode = input('create'); + readonly languageId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedLanguage = signal(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.`); + } +} diff --git a/src/app/features/global-masters/languages/data-access/language.endpoints.ts b/src/app/features/global-masters/languages/data-access/language.endpoints.ts index 123ad1fa..374b2517 100644 --- a/src/app/features/global-masters/languages/data-access/language.endpoints.ts +++ b/src/app/features/global-masters/languages/data-access/language.endpoints.ts @@ -7,5 +7,9 @@ export const LANGUAGE_ENDPOINTS = { buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`), update: (id: string) => buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`), + delete: (id: string) => + buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}`), + changeStatus: (id: string) => + buildApiUrl('masterAdmin', `/v1/languages/${encodeURIComponent(id)}/status`), autocomplete: buildApiUrl('masterAdmin', '/v1/languages/autocomplete') } as const; diff --git a/src/app/features/global-masters/languages/data-access/language.service.ts b/src/app/features/global-masters/languages/data-access/language.service.ts index d491b95f..c2082875 100644 --- a/src/app/features/global-masters/languages/data-access/language.service.ts +++ b/src/app/features/global-masters/languages/data-access/language.service.ts @@ -7,7 +7,8 @@ import { CreateLanguageRequest, LanguageDto, LanguageLookupDto, - UpdateLanguageRequest + UpdateLanguageRequest, + UpdateLanguageStatusRequest } from '../models/language.model'; import { DataTableQuery, @@ -34,6 +35,14 @@ export class LanguageService { return this.http.put(LANGUAGE_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateLanguageStatusRequest): Observable { + return this.http.patch(LANGUAGE_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(LANGUAGE_ENDPOINTS.delete(id)); + } + autocomplete( term: string | null, limit = 10 diff --git a/src/app/features/global-masters/languages/models/language.model.ts b/src/app/features/global-masters/languages/models/language.model.ts index fc1f7539..224222e8 100644 --- a/src/app/features/global-masters/languages/models/language.model.ts +++ b/src/app/features/global-masters/languages/models/language.model.ts @@ -28,4 +28,9 @@ export interface UpdateLanguageRequest extends CreateLanguageRequest { isActive: boolean; } -export type LanguageModalMode = 'create' | 'edit'; +export interface UpdateLanguageStatusRequest { + isActive: boolean; +} + +export type LanguageModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.html b/src/app/features/global-masters/languages/pages/language-list/language-list.html index 6aaa3e77..edc48aab 100644 --- a/src/app/features/global-masters/languages/pages/language-list/language-list.html +++ b/src/app/features/global-masters/languages/pages/language-list/language-list.html @@ -1,67 +1,37 @@ - - - {{ value }} - - - {{ value }} - - + - + - - @if (modalLoading()) { -
- - Loading language... -
- } @else { -
-
-
- -
-
- -
-
- -
- - -
- -
-
-
- } -
\ No newline at end of file + \ No newline at end of file diff --git a/src/app/features/global-masters/languages/pages/language-list/language-list.ts b/src/app/features/global-masters/languages/pages/language-list/language-list.ts index 56e47501..8eb61544 100644 --- a/src/app/features/global-masters/languages/pages/language-list/language-list.ts +++ b/src/app/features/global-masters/languages/pages/language-list/language-list.ts @@ -1,32 +1,20 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; +import { finalize } from 'rxjs/operators'; -import { - CreateLanguageRequest, - LanguageDto, - LanguageModalMode, - UpdateLanguageRequest -} from '../../models/language.model'; +import { LanguageDto, UpdateLanguageRequest } from '../../models/language.model'; import { LanguageService } from '../../data-access/language.service'; import { DataTable } from '../../../../../shared/components/data-table/data-table'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../../shared/components/modal/modal'; -import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { LanguageFormModalComponent } from '../../components/language-form-modal/language-form-modal'; interface LanguageTableRow extends DataTableRecord { id: string; @@ -43,314 +31,125 @@ interface LanguageTableRow extends DataTableRecord { @Component({ selector: 'language-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog], + imports: [DataTable, ConfirmDialog, LanguageFormModalComponent], + providers: [DataTableStore], templateUrl: './language-list.html', styleUrl: './language-list.scss' }) -export class LanguageList { +export class LanguageList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly languageApi = inject(LanguageService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly queryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - readonly languages = signal([]); - readonly totalRecords = signal(0); - readonly modalLoading = signal(false); - readonly saving = signal(false); readonly statusChangingId = signal(null); - readonly showModal = signal(false); - readonly modalMode = signal('create'); - readonly selectedLanguageId = signal(null); - readonly selectedLanguage = signal(null); - readonly submitAttempted = signal(false); - readonly pendingDeleteLanguageId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteLanguage = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly languageForm = this.formBuilder.nonNullable.group({ - name: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]], - code: ['', [ - Validators.required, - Validators.maxLength(35), - Validators.pattern(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/) - ]], - nativeName: ['', [Validators.required, Validators.maxLength(100), Validators.pattern(/.*\S.*/)]], - isRightToLeft: [false] - }); - - readonly modalTitle = computed(() => - this.modalMode() === 'create' ? 'Add Language' : 'Edit Language' - ); - readonly submitLabel = computed(() => - this.modalMode() === 'create' ? 'Save' : 'Update' - ); - readonly loadingLabel = computed(() => - this.modalMode() === 'create' ? 'Saving...' : 'Updating...' - ); - readonly submitAction = computed<'save' | 'update'>(() => - this.modalMode() === 'create' ? 'save' : 'update' - ); - readonly columns = signal[]>([ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, { key: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left' }, { key: 'code', label: 'Language Code', header: 'Language Code', sortable: true }, { key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true }, { - key: 'isRightToLeft', label: 'Direction', header: 'Direction', sortable: true, - formatter: value => value ? 'RTL' : 'LTR' + key: 'isRightToLeft', label: 'RTL', header: 'RTL', sortable: false, badge: true, + badgeClass: value => value === true ? 'badge bg-info/10 text-info' : 'badge bg-light text-defaulttextcolor', + formatter: value => value ? 'Yes' : 'No' }, { key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, - badgeClass: value => value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive, - disabled: row => this.statusChangingId() === row.id + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive, - disabled: row => this.statusChangingId() === row.id + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.queryRequests$.pipe( - switchMap(query => this.languageApi.getDataTable(query).pipe( - catchError(() => { - this.toastr.error('Unable to load languages.'); - this.languages.set([]); - this.totalRecords.set(0); - return of(null); - }) - )), - takeUntilDestroyed(this.destroyRef) - ).subscribe(response => { - if (!response || response.draw !== this.queryState.getQuery().draw) { - return; - } - const query = this.queryState.getQuery(); - this.languages.set(response.rows.map((language, index) => ({ - ...language, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - }))); - this.totalRecords.set(response.total); - }); - } - ngOnInit(): void { - this.loadLanguages(this.queryState.getQuery()); - } - - loadLanguages(query: DataTableQuery): void { this.queryRequests$.next(query); } - onSearch(value: string): void { this.loadLanguages(this.queryState.setSearch(value.trim())); } - onPageChange(event: DataTablePageEvent): void { this.loadLanguages(this.queryState.setPage(event)); } - onSortChange(event: DataTableSortEvent): void { this.loadLanguages(this.queryState.setSort(event)); } - - onDeleteConfirmed(): void { - const languageId = this.pendingDeleteLanguageId(); - - if (!languageId) { - return; - } - - this.pendingDeleteLanguageId.set(null); - this.changeLanguageStatus(languageId, false); - } - - onDeleteCancelled(): void { - this.pendingDeleteLanguageId.set(null); - } - - onActionClick(event: DataTableActionEvent): void { - if (event.action.type === 'edit') { - this.openEditLanguage(event.row.id); - } else if (event.action.type === 'delete') { - this.requestDeleteLanguage(event.row.id); - } else if (event.action.type === 'activate') { - this.changeLanguageStatus(event.row.id, true); - } - } - - private requestDeleteLanguage(id: string): void { - this.pendingDeleteLanguageId.set(id); - this.deleteConfirmDialog()?.open(); + this.tableStore.initialize({ + fetcher: query => this.languageApi.getDataTable(query) + }); } onAddLanguage(): void { - this.modalMode.set('create'); - this.selectedLanguageId.set(null); - this.selectedLanguage.set(null); - this.submitAttempted.set(false); - this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false }); - this.resetFormState(); - this.showModal.set(true); + this.tableStore.openCreateModal(); } - openEditLanguage(id: string): void { - this.modalMode.set('edit'); - this.selectedLanguageId.set(id); - this.selectedLanguage.set(null); - this.submitAttempted.set(false); - this.languageForm.reset({ name: '', code: '', nativeName: '', isRightToLeft: false }); - this.resetFormState(); - this.modalLoading.set(true); - this.showModal.set(true); - - this.languageApi.getById(id).pipe( - finalize(() => this.modalLoading.set(false)), - takeUntilDestroyed(this.destroyRef) - ).subscribe({ - next: language => { - if (this.selectedLanguageId() !== language.id || !this.showModal()) { - return; - } - this.selectedLanguage.set(language); - this.languageForm.reset({ - name: language.name, - code: language.code, - nativeName: language.nativeName, - isRightToLeft: language.isRightToLeft - }); - this.resetFormState(); - }, - error: () => this.showModal.set(false) - }); + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteLanguage(event.row); + if (event.action.type === 'activate') this.changeLanguageStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeLanguageStatus(event.row, false); } - closeModal(): void { - if (this.saving()) return; - this.showModal.set(false); - this.selectedLanguageId.set(null); - this.selectedLanguage.set(null); - this.submitAttempted.set(false); - } + onDeleteConfirmed(): void { + const lang = this.pendingDeleteLanguage(); + if (!lang) return; + this.pendingDeleteLanguage.set(null); + this.deletingId.set(lang.id); - saveLanguage(): void { - if (this.languageForm.invalid) { - this.submitAttempted.set(true); - this.languageForm.markAllAsTouched(); - this.focusFirstInvalidControl(); - return; - } - if (this.saving() || this.modalLoading()) return; - - this.saving.set(true); - const request = this.buildCreateRequest(); - const operation = this.modalMode() === 'create' - ? this.languageApi.create(request) - : this.languageApi.update( - this.selectedLanguageId() ?? '', - { ...request, isActive: this.selectedLanguage()?.isActive ?? true } - ); - - operation.pipe( - finalize(() => this.saving.set(false)), + this.languageApi.delete(lang.id).pipe( + finalize(() => this.deletingId.set(null)), takeUntilDestroyed(this.destroyRef) ).subscribe({ next: () => { - this.toastr.success( - this.modalMode() === 'create' - ? 'Language saved successfully.' - : 'Language updated successfully.' - ); - this.finishSave(); + this.toastr.success('Language deleted successfully.'); + this.tableStore.refresh(); }, - error: (error: HttpErrorResponse) => this.handleSaveError(error) + error: (err) => { + let errorMsg = 'Unable to delete language.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete language because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Language not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); + } }); } - private buildCreateRequest(): CreateLanguageRequest { - const value = this.languageForm.getRawValue(); - return { - code: this.normalizeCode(value.code), - name: value.name.trim(), - nativeName: value.nativeName.trim(), - isRightToLeft: value.isRightToLeft - }; + onDeleteCancelled(): void { + this.pendingDeleteLanguage.set(null); } - private normalizeCode(code: string): string { - return code.trim().split('-').map((part, index) => - index === 0 ? part.toLowerCase() : part.toUpperCase() - ).join('-'); + private requestDeleteLanguage(language: LanguageTableRow): void { + this.pendingDeleteLanguage.set(language); + this.deleteConfirmDialog()?.open(); } - private changeLanguageStatus(id: string, isActive: boolean): void { - if (this.statusChangingId()) return; - this.statusChangingId.set(id); - this.languageApi.getById(id).pipe( - switchMap(language => this.languageApi.update(id, { - code: language.code, - name: language.name, - nativeName: language.nativeName, - isRightToLeft: language.isRightToLeft, - isActive - })), + private changeLanguageStatus(language: LanguageTableRow, activate: boolean): void { + this.statusChangingId.set(language.id); + + this.languageApi.updateStatus(language.id, { isActive: activate }).pipe( finalize(() => this.statusChangingId.set(null)), takeUntilDestroyed(this.destroyRef) ).subscribe({ next: () => { - this.toastr.success(isActive - ? 'Language activated successfully.' - : 'Language deleted successfully.'); - this.loadLanguages(this.queryState.getQuery()); + this.toastr.success(`Language ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); }, - error: (error: HttpErrorResponse) => { - if (error.status === 404) this.toastr.error('The language is no longer available.'); + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} language.`; + this.toastr.error(msg); } }); } - - private handleSaveError(error: HttpErrorResponse): void { - if (error.status === 409) { - this.toastr.error('A language with this code already exists.', 'Duplicate language code'); - } - } - - private finishSave(): void { - this.showModal.set(false); - this.selectedLanguageId.set(null); - this.selectedLanguage.set(null); - this.submitAttempted.set(false); - this.loadLanguages(this.queryState.getQuery()); - } - - private resetFormState(): void { - this.languageForm.markAsPristine(); - this.languageForm.markAsUntouched(); - this.languageForm.updateValueAndValidity(); - } - - private focusFirstInvalidControl(): void { - queueMicrotask(() => { - const control = this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - control?.focus(); - control?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - } } diff --git a/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.html b/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.html new file mode 100644 index 00000000..bfe8d384 --- /dev/null +++ b/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.html @@ -0,0 +1,72 @@ + + @if (modalLoading()) { +
+ + Loading state... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+
+ } +
diff --git a/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.ts b/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.ts new file mode 100644 index 00000000..1d069ce8 --- /dev/null +++ b/src/app/features/global-masters/states/components/state-form-modal/state-form-modal.ts @@ -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(false); + readonly mode = input('create'); + readonly stateId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedState = signal(null); + readonly selectedFormCountry = signal(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 = (term, page) => + this.countryApi.autocomplete(term, page).pipe(catchError(() => of([]))); + readonly countryValueFn: AutocompleteValueFn = country => country.id; + readonly countryDisplayFn: AutocompleteDisplayFn = 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.`); + } +} diff --git a/src/app/features/global-masters/states/data-access/state.service.ts b/src/app/features/global-masters/states/data-access/state.service.ts index ae925b34..9bee3f5c 100644 --- a/src/app/features/global-masters/states/data-access/state.service.ts +++ b/src/app/features/global-masters/states/data-access/state.service.ts @@ -7,7 +7,8 @@ import { CreateStateRequest, StateDto, StateLookupDto, - UpdateStateRequest + UpdateStateRequest, + UpdateStateStatusRequest } from "../models/state.model"; @Injectable({ @@ -30,6 +31,14 @@ export class StateService { return this.http.put(STATE_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateStateStatusRequest): Observable { + return this.http.patch(STATE_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(STATE_ENDPOINTS.delete(id)); + } + getStateById(id: string): Observable { return this.http.get(STATE_ENDPOINTS.getById(id)); } diff --git a/src/app/features/global-masters/states/models/state.model.ts b/src/app/features/global-masters/states/models/state.model.ts index 384358d9..ec82b129 100644 --- a/src/app/features/global-masters/states/models/state.model.ts +++ b/src/app/features/global-masters/states/models/state.model.ts @@ -21,10 +21,15 @@ export interface CreateStateRequest { } export interface UpdateStateRequest { - countryId: null; + countryId: string | null; name: string; code: string; isActive: boolean; } -export type StateModalMode = 'create' | 'edit'; +export interface UpdateStateStatusRequest { + isActive: boolean; +} + +export type StateModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.html b/src/app/features/global-masters/states/pages/state-list/state-list.html index 0728e910..4dbbdd43 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.html +++ b/src/app/features/global-masters/states/pages/state-list/state-list.html @@ -1,46 +1,61 @@ -
-
- -
-
- -
-
- -
-
-
-
-
+ +
+
+ +
+
+ +
+
+
- + - -
-
-
- -
- -
- -
- -
- -
-
-
-
+ diff --git a/src/app/features/global-masters/states/pages/state-list/state-list.ts b/src/app/features/global-masters/states/pages/state-list/state-list.ts index 1c7ed70a..075a46aa 100644 --- a/src/app/features/global-masters/states/pages/state-list/state-list.ts +++ b/src/app/features/global-masters/states/pages/state-list/state-list.ts @@ -1,35 +1,21 @@ -import { Component, DestroyRef, ElementRef, computed, inject, signal, viewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - FormBuilder, - ReactiveFormsModule, - Validators -} from '@angular/forms'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, distinctUntilChanged, finalize, map, of, switchMap } from 'rxjs'; +import { of } from 'rxjs'; +import { catchError, finalize, map } from 'rxjs/operators'; -import { - CountryLookupDto -} from '../../../countries/public-api'; -import { - CreateStateRequest, - StateDto, - StateModalMode, - UpdateStateRequest -} from '../../models/state.model'; -import { CountryService } from '../../../countries/public-api'; +import { CountryLookupDto, CountryService } from '../../../countries/public-api'; +import { StateDto, UpdateStateRequest } from '../../models/state.model'; import { StateService } from '../../data-access/state.service'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { DataTable } from '../../../../../shared/components/data-table/data-table'; import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { AutocompleteDisplayFn, @@ -37,10 +23,10 @@ import { AutocompleteSearchFn, AutocompleteValueFn } from '../../../../../shared/components/form/autocomplete/autocomplete.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; import { FilterCard } from '../../../../../shared/components/filter-card/filter-card'; +import { Button as AppButton } from '../../../../../shared/components/button/button'; +import { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal'; interface StateTableRow extends DataTableRecord { id: string; @@ -56,515 +42,165 @@ interface StateTableRow extends DataTableRecord { @Component({ selector: 'state-list', standalone: true, - imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard], + imports: [ + DataTable, + ReactiveFormsModule, + Autocomplete, + ConfirmDialog, + FilterCard, + AppButton, + StateFormModalComponent + ], + providers: [DataTableStore], templateUrl: './state-list.html', styleUrl: './state-list.scss', }) -export class StateList { - +export class StateList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly stateApi = inject(StateService); private readonly countryApi = inject(CountryService); private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly stateQueryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - - readonly states = signal([]); readonly selectedCountryLookup = signal(null); - readonly selectedFormCountry = signal(null); - readonly selectedCountryId = signal(null); - readonly appliedCountryId = signal(null); - readonly totalRecords = signal(0); - readonly filteredRecords = signal(0); - readonly saving = signal(false); - - readonly showStateModal = signal(false); - readonly stateModalMode = signal('create'); - readonly selectedStateId = signal(null); - readonly selectedState = signal(null); - readonly stateSubmitAttempted = signal(false); - readonly pendingDeleteState = signal(null); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteState = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly countryFilterForm = this.formBuilder.nonNullable.group({ countryId: [''] }); - readonly searchCountries: AutocompleteSearchFn = - (term, limit) => this.countryApi.autocomplete(term, limit); + readonly searchCountries: AutocompleteSearchFn = (term, limit) => + this.countryApi.autocomplete(term, limit); readonly displayCountry: AutocompleteDisplayFn = country => country.name; readonly countryValue: AutocompleteValueFn = country => country.id; - readonly resolveCountry: AutocompleteResolveValueFn = - value => this.countryApi.getCountryById(value).pipe( + readonly resolveCountry: AutocompleteResolveValueFn = value => + this.countryApi.getCountryById(value).pipe( map(country => ({ id: country.id, iso2: country.iso2, name: country.name })) ); - readonly stateForm = this.formBuilder.nonNullable.group({ - countryId: [ - '', - [ - Validators.required - ] - ], - name: [ - '', - [ - Validators.required, - Validators.maxLength(150) - ] - ], - code: [ - '', - [ - Validators.required, - Validators.maxLength(16), - Validators.pattern(/^[A-Za-z0-9_-]+$/) - ] - ] - }); - - readonly emptyMessage = computed(() => - this.appliedCountryId() - ? 'No states found' - : 'No records found' - ); - - readonly emptyDescription = computed(() => - this.appliedCountryId() - ? 'There are no states available for the selected country.' - : 'There is currently no data to display.' - ); - - readonly stateModalTitle = computed(() => - this.stateModalMode() === 'create' - ? 'Add State' - : 'Edit State' - ); - - readonly stateSubmitLabel = computed(() => - this.stateModalMode() === 'create' - ? 'Save' - : 'Update' - ); - - readonly stateLoadingLabel = computed(() => - this.stateModalMode() === 'create' - ? 'Saving...' - : 'Updating...' - ); - - readonly stateSubmitAction = computed<'save' | 'update'>(() => - this.stateModalMode() === 'create' - ? 'save' - : 'update' - ); - readonly columns = signal[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' }, - { key: 'name', header: 'Name', label: 'Name', sortable: true, align: 'left' }, - { key: 'code', header: 'Code', label: 'Code', sortable: true }, + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, + { key: 'name', label: 'State Name', header: 'State Name', sortable: true, headerAlign: 'center', align: 'left' }, { - key: 'isActive', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - badgeClass: value => - value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', - width: '100px', + key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, + badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary', + formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' + }, + { + key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', formatter: value => value ? 'Active' : 'Inactive' } ]); readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive + type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive + type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.countryFilterForm.controls.countryId.valueChanges - .pipe( - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(countryId => { - this.onCountrySelected(countryId || null); - }); - - this.stateQueryRequests$ - .pipe( - switchMap(query => { - const countryId = this.appliedCountryId(); - - return this.stateApi.getStateDataTable(query, countryId).pipe( - catchError(() => { - this.toastr.error('Unable to load states.'); - this.clearStateGrid(); - return of(null); - }) - ); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(response => { - if (!response) { - return; - } - - const query = this.queryState.getQuery(); - - if (response.draw !== query.draw) { - return; - } - - const statesWithSerialNumbers: StateTableRow[] = response.rows.map((state, index) => ({ - ...state, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.states.set(statesWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }); - } - ngOnInit(): void { - this.loadStates(this.queryState.getQuery()); + this.tableStore.initialize({ + fetcher: query => { + const countryId = this.countryFilterForm.controls.countryId.value || null; + return this.stateApi.getStateDataTable(query, countryId); + } + }); } - loadStates(query: DataTableQuery): void { - this.stateQueryRequests$.next(query); - } - - onCountryLookupSelected(country: CountryLookupDto): void { + onFilterCountrySelected(country: CountryLookupDto | null): void { this.selectedCountryLookup.set(country); + this.countryFilterForm.controls.countryId.setValue(country ? country.id : ''); } - applyCountryFilter(): void { - const countryId = this.countryFilterForm.controls.countryId.value || null; - this.appliedCountryId.set(countryId); - - this.loadStates(this.queryState.setPage({ - pageIndex: 1, - pageSize: this.queryState.pageSize() - })); + onApplyFilter(): void { + this.tableStore.refresh(); } - onFormCountrySelected(country: CountryLookupDto): void { - this.selectedFormCountry.set(country); + onResetFilter(): void { + this.countryFilterForm.reset({ countryId: '' }); + this.selectedCountryLookup.set(null); + this.tableStore.reset(); } - onFormCountryCleared(): void { - this.selectedFormCountry.set(null); + onAddState(): void { + this.tableStore.openCreateModal(); } - onSearch(value: string): void { - const query = this.queryState.setSearch(value.trim()); - this.loadStates(query); - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.queryState.setPage(event); - this.loadStates(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.queryState.setSort(event); - this.loadStates(query); - } - - onRefresh(): void { - const currentQuery = this.queryState.getQuery(); - - const query: DataTableQuery = { - ...currentQuery, - draw: currentQuery.draw + 1 - }; - - this.loadStates(query); - } - - onReset(): void { - const query = this.queryState.reset(); - this.loadStates(query); + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteState(event.row); + if (event.action.type === 'activate') this.changeStateStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeStateStatus(event.row, false); } onDeleteConfirmed(): void { const state = this.pendingDeleteState(); - - if (!state) { - return; - } - + if (!state) return; this.pendingDeleteState.set(null); - this.deleteState(state); + this.deletingId.set(state.id); + + this.stateApi.delete(state.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success('State deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete state.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete state because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'State not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); + } + }); } onDeleteCancelled(): void { this.pendingDeleteState.set(null); } - onActionClick(event: DataTableActionEvent): void { - const action = event.action.type; - const state = this.toStateDto(event.row); - - switch (action) { - case 'view': - this.viewState(state); - break; - case 'edit': - this.openEditState(state); - break; - case 'delete': - this.requestDeleteState(state); - break; - case 'activate': - this.activateState(state); - break; - } - } - - onAddState(): void { - this.stateModalMode.set('create'); - this.selectedStateId.set(null); - this.selectedState.set(null); - this.stateSubmitAttempted.set(false); - - this.selectedFormCountry.set(null); - this.stateForm.reset({ - countryId: '', - name: '', - code: '' - }); - this.resetStateFormState(); - - this.showStateModal.set(true); - } - - closeStateModal(): void { - if (this.saving()) { - return; - } - - this.showStateModal.set(false); - this.selectedStateId.set(null); - this.selectedState.set(null); - this.selectedFormCountry.set(null); - this.stateSubmitAttempted.set(false); - } - - saveState(): void { - if (this.stateForm.invalid) { - this.stateSubmitAttempted.set(true); - this.stateForm.markAllAsTouched(); - this.focusFirstInvalidStateControl(); - return; - } - - if (this.saving()) { - return; - } - - this.saving.set(true); - - if (this.stateModalMode() === 'create') { - this.stateApi - .createState(this.buildCreateStateRequest()) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('State saved successfully.'); - this.finishStateSave(); - } - }); - - return; - } - - const stateId = this.selectedStateId(); - - if (!stateId) { - this.saving.set(false); - return; - } - - this.stateApi - .updateState( - stateId, - this.buildUpdateStateRequest(this.selectedState()?.isActive ?? true) - ) - .pipe(finalize(() => this.saving.set(false))) - .subscribe({ - next: () => { - this.toastr.success('State updated successfully.'); - this.finishStateSave(); - } - }); - } - - private onCountrySelected(countryId: string | null): void { - this.selectedCountryId.set(countryId); - - if (!countryId || this.selectedCountryLookup()?.id !== countryId) { - this.selectedCountryLookup.set(null); - } - - } - - private clearStateGrid(): void { - this.states.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); - } - - private viewState(state: StateDto): void { - this.openEditState(state); - } - - private openEditState(state: StateDto): void { - this.stateModalMode.set('edit'); - this.selectedStateId.set(state.id); - this.selectedState.set(state); - this.stateSubmitAttempted.set(false); - - this.stateApi - .getStateById(state.id) - .subscribe({ - next: stateDetails => { - this.selectedState.set(stateDetails); - this.selectedFormCountry.set(null); - this.stateForm.reset({ - countryId: stateDetails.countryId ?? '', - name: stateDetails.name ?? '', - code: stateDetails.code ?? '' - }); - this.resetStateFormState(); - - this.showStateModal.set(true); - } - }); - } - - private requestDeleteState(state: StateDto): void { + private requestDeleteState(state: StateTableRow): void { this.pendingDeleteState.set(state); this.deleteConfirmDialog()?.open(); } - private deleteState(state: StateDto): void { - this.updateStateStatus(state, false); - } + private changeStateStatus(state: StateTableRow, activate: boolean): void { + this.statusChangingId.set(state.id); - private activateState(state: StateDto): void { - this.updateStateStatus(state, true); - } - - private buildCreateStateRequest(): CreateStateRequest { - const value = this.stateForm.getRawValue(); - - return { - countryId: value.countryId, - name: value.name.trim(), - code: value.code.trim().toUpperCase() - }; - } - - private buildUpdateStateRequest(isActive: boolean): UpdateStateRequest { - const value = this.stateForm.getRawValue(); - - return { - countryId: null, - name: value.name.trim(), - code: value.code.trim().toUpperCase(), - isActive - }; - } - - private stateToUpdateRequest(state: StateDto, isActive: boolean): UpdateStateRequest { - return { - countryId: null, - name: state.name?.trim() ?? '', - code: state.code?.trim().toUpperCase() ?? '', - isActive - }; - } - - private updateStateStatus(state: StateDto, isActive: boolean): void { - this.stateApi - .updateState(state.id, this.stateToUpdateRequest(state, isActive)) - .subscribe({ - next: () => { - this.toastr.success( - isActive - ? 'State activated successfully.' - : 'State deactivated successfully.' - ); - this.loadStates(this.queryState.getQuery()); - } - }); - } - - private resetStateFormState(): void { - this.stateForm.markAsPristine(); - this.stateForm.markAsUntouched(); - this.stateForm.updateValueAndValidity(); - } - - private finishStateSave(): void { - this.showStateModal.set(false); - this.selectedStateId.set(null); - this.selectedState.set(null); - this.selectedFormCountry.set(null); - this.stateSubmitAttempted.set(false); - this.loadStates(this.queryState.getQuery()); - } - - private focusFirstInvalidStateControl(): void { - queueMicrotask(() => { - const firstInvalidControl = - this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - - firstInvalidControl?.focus(); - firstInvalidControl?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); + this.stateApi.updateStatus(state.id, { isActive: activate }).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(`State ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} state.`; + this.toastr.error(msg); + } }); } - - private toStateDto(row: StateTableRow): StateDto { - return { - id: row.id, - countryId: row.countryId, - name: row.name, - code: row.code, - isActive: row.isActive, - createdOn: row.createdOn, - modifiedOn: row.modifiedOn - }; - } } diff --git a/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.html b/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.html new file mode 100644 index 00000000..20d7762f --- /dev/null +++ b/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.html @@ -0,0 +1,100 @@ + + @if (modalLoading()) { +
+ + Loading timezone... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+ @if (isViewMode() && selectedTimezone(); as timezone) { +
+ Formatted UTC Offset + {{ formatUtcOffset(timezone.utcOffsetMinutes) }} +
+
+ Status + + {{ timezone.isActive ? 'Active' : 'Inactive' }} + +
+ } +
+
+ } +
diff --git a/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.ts b/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.ts new file mode 100644 index 00000000..8f0b5581 --- /dev/null +++ b/src/app/features/global-masters/timezones/components/timezone-form-modal/timezone-form-modal.ts @@ -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(false); + readonly mode = input('create'); + readonly timezoneId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedTimezone = signal(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.`); + } +} diff --git a/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts b/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts index 6a5d5516..6374a957 100644 --- a/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts +++ b/src/app/features/global-masters/timezones/data-access/timezone.endpoints.ts @@ -7,5 +7,9 @@ export const TIMEZONE_ENDPOINTS = { buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`), update: (id: string) => buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`), + delete: (id: string) => + buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}`), + changeStatus: (id: string) => + buildApiUrl('masterAdmin', `/v1/timezones/${encodeURIComponent(id)}/status`), autocomplete: buildApiUrl('masterAdmin', '/v1/timezones/autocomplete') } as const; diff --git a/src/app/features/global-masters/timezones/data-access/timezone.service.ts b/src/app/features/global-masters/timezones/data-access/timezone.service.ts index 559982a6..73063b4f 100644 --- a/src/app/features/global-masters/timezones/data-access/timezone.service.ts +++ b/src/app/features/global-masters/timezones/data-access/timezone.service.ts @@ -7,7 +7,8 @@ import { CreateTimezoneRequest, TimezoneDto, TimezoneLookupDto, - UpdateTimezoneRequest + UpdateTimezoneRequest, + UpdateTimezoneStatusRequest } from '../models/timezone.model'; import { DataTableQuery, @@ -34,6 +35,14 @@ export class TimezoneService { return this.http.put(TIMEZONE_ENDPOINTS.update(id), request); } + updateStatus(id: string, request: UpdateTimezoneStatusRequest): Observable { + return this.http.patch(TIMEZONE_ENDPOINTS.changeStatus(id), request); + } + + delete(id: string): Observable { + return this.http.delete(TIMEZONE_ENDPOINTS.delete(id)); + } + autocomplete(term: string | null, limit = 10): Observable { const normalizedTerm = term?.trim() || null; let params = new HttpParams().set('limit', limit); diff --git a/src/app/features/global-masters/timezones/models/timezone.model.ts b/src/app/features/global-masters/timezones/models/timezone.model.ts index 31a75ee6..66447724 100644 --- a/src/app/features/global-masters/timezones/models/timezone.model.ts +++ b/src/app/features/global-masters/timezones/models/timezone.model.ts @@ -24,4 +24,9 @@ export interface UpdateTimezoneRequest extends CreateTimezoneRequest { readonly isActive: boolean; } +export interface UpdateTimezoneStatusRequest { + readonly isActive: boolean; +} + export type TimezoneModalMode = 'create' | 'edit' | 'view'; + diff --git a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html index bd22a860..8a085fe2 100644 --- a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html +++ b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.html @@ -1,10 +1,10 @@ @@ -35,103 +35,10 @@ (cancelled)="onDeleteCancelled()" /> - - @if (modalLoading()) { -
- - Loading timezone... -
- } @else { -
-
-
- -
-
- -
-
- -
- @if (isViewMode() && selectedTimezone(); as timezone) { -
- Formatted UTC Offset - {{ formatUtcOffset(timezone.utcOffsetMinutes) }} -
-
- Status - - {{ timezone.isActive ? 'Active' : 'Inactive' }} - -
- } -
-
- } -
+ diff --git a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts index a1ffe667..5821f660 100644 --- a/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts +++ b/src/app/features/global-masters/timezones/pages/timezone-list/timezone-list.ts @@ -1,32 +1,28 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, DestroyRef, ElementRef, OnInit, computed, inject, signal, viewChild } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, of, switchMap } from 'rxjs'; - import { - CreateTimezoneRequest, - TimezoneDto, - TimezoneModalMode, - UpdateTimezoneRequest -} from '../../models/timezone.model'; + Component, + DestroyRef, + OnInit, + inject, + signal, + viewChild +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ToastrService } from 'ngx-toastr'; +import { finalize } from 'rxjs/operators'; + +import { TimezoneDto, UpdateTimezoneRequest } from '../../models/timezone.model'; import { TimezoneService } from '../../data-access/timezone.service'; import { DataTable } from '../../../../../shared/components/data-table/data-table'; -import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, - DataTablePageEvent, - DataTableQuery, - DataTableRecord, - DataTableSortEvent + DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../../shared/components/modal/modal'; import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; +import { TimezoneFormModalComponent } from '../../components/timezone-form-modal/timezone-form-modal'; interface TimezoneTableRow extends DataTableRecord { readonly id: string; @@ -42,54 +38,27 @@ interface TimezoneTableRow extends DataTableRecord { @Component({ selector: 'timezone-list', standalone: true, - imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, FormInput, ConfirmDialog], + imports: [ + DataTable, + DataTableCellDirective, + ConfirmDialog, + TimezoneFormModalComponent + ], + providers: [DataTableStore], templateUrl: './timezone-list.html', styleUrl: './timezone-list.scss' }) export class TimezoneList implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly timezoneApi = inject(TimezoneService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); private readonly toastr = inject(ToastrService); - private readonly queryRequests$ = new Subject(); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - readonly timezones = signal([]); - readonly totalRecords = signal(0); - readonly modalLoading = signal(false); - readonly saving = signal(false); readonly statusChangingId = signal(null); - readonly showModal = signal(false); - readonly modalMode = signal('create'); - readonly selectedTimezoneId = signal(null); - readonly selectedTimezone = signal(null); - readonly submitAttempted = signal(false); - readonly pendingDeleteTimezoneId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteTimezone = signal(null); readonly deleteConfirmDialog = viewChild(ConfirmDialog); - readonly timezoneForm = this.formBuilder.nonNullable.group({ - ianaId: ['', [ - Validators.required, - Validators.maxLength(64), - Validators.pattern(/^[A-Za-z]+(?:[._+-]?[A-Za-z0-9]+)*(?:\/[A-Za-z0-9._+-]+)+$/) - ]], - displayName: ['', [Validators.required, Validators.maxLength(128), Validators.pattern(/.*\S.*/)]], - utcOffsetMinutes: [0, [Validators.required, Validators.min(-720), Validators.max(840)]] - }); - - readonly isViewMode = computed(() => this.modalMode() === 'view'); - readonly modalTitle = computed(() => { - switch (this.modalMode()) { - case 'create': return 'Add Timezone'; - case 'edit': return 'Edit Timezone'; - case 'view': return 'View Timezone'; - } - }); - readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save' : 'Update'); - readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving...' : 'Updating...'); - readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update'); - readonly columns = signal[]>([ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'ianaId', label: 'IANA ID', header: 'IANA ID', sortable: true, headerAlign: 'center', align: 'left' }, @@ -115,234 +84,111 @@ export class TimezoneList implements OnInit { className: 'text-primary' }, { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', + type: 'deactivate', + label: 'Deactivate', + icon: 'ti ti-toggle-right', + className: 'text-warning', visible: row => row.isActive, - disabled: row => this.statusChangingId() === row.id + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id }, { type: 'activate', label: 'Activate', - icon: 'ti ti-check', + icon: 'ti ti-toggle-left', className: 'text-success', visible: row => !row.isActive, - disabled: row => this.statusChangingId() === row.id + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id + }, + { + type: 'delete', + label: 'Delete', + icon: 'ti ti-trash', + className: 'text-danger', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); - constructor() { - this.queryRequests$.pipe( - switchMap(query => this.timezoneApi.getDataTable(query).pipe( - catchError(() => { - this.timezones.set([]); - this.totalRecords.set(0); - return of(null); - }) - )), - takeUntilDestroyed(this.destroyRef) - ).subscribe(response => { - if (!response || response.draw !== this.queryState.getQuery().draw) return; - const query = this.queryState.getQuery(); - this.timezones.set(response.rows.map((timezone, index) => ({ - ...timezone, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - }))); - this.totalRecords.set(response.total); + ngOnInit(): void { + this.tableStore.initialize({ + fetcher: query => this.timezoneApi.getDataTable(query) }); } - ngOnInit(): void { this.loadTimezones(this.queryState.getQuery()); } - loadTimezones(query: DataTableQuery): void { this.queryRequests$.next(query); } - onSearch(value: string): void { this.loadTimezones(this.queryState.setSearch(value.trim())); } - onPageChange(event: DataTablePageEvent): void { this.loadTimezones(this.queryState.setPage(event)); } - onSortChange(event: DataTableSortEvent): void { this.loadTimezones(this.queryState.setSort(event)); } - - onDeleteConfirmed(): void { - const timezoneId = this.pendingDeleteTimezoneId(); - - if (!timezoneId) { - return; - } - - this.pendingDeleteTimezoneId.set(null); - this.changeTimezoneStatus(timezoneId, false); - } - - onDeleteCancelled(): void { - this.pendingDeleteTimezoneId.set(null); + onAddTimezone(): void { + this.tableStore.openCreateModal(); } onActionClick(event: DataTableActionEvent): void { - if (event.action.type === 'view') this.openTimezone(event.row.id, 'view'); - if (event.action.type === 'edit') this.openTimezone(event.row.id, 'edit'); - if (event.action.type === 'delete') this.requestDeleteTimezone(event.row.id); - if (event.action.type === 'activate') this.changeTimezoneStatus(event.row.id, true); + if (event.action.type === 'view') this.tableStore.openViewModal(event.row); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row); + if (event.action.type === 'delete') this.requestDeleteTimezone(event.row); + if (event.action.type === 'activate') this.changeTimezoneStatus(event.row, true); + if (event.action.type === 'deactivate') this.changeTimezoneStatus(event.row, false); } - private requestDeleteTimezone(id: string): void { - this.pendingDeleteTimezoneId.set(id); - this.deleteConfirmDialog()?.open(); - } + onDeleteConfirmed(): void { + const timezone = this.pendingDeleteTimezone(); + if (!timezone) return; + this.pendingDeleteTimezone.set(null); + this.deletingId.set(timezone.id); - onAddTimezone(): void { - this.modalMode.set('create'); - this.prepareModal(null); - this.showModal.set(true); - } - - openTimezone(id: string, mode: 'edit' | 'view'): void { - this.modalMode.set(mode); - this.prepareModal(id); - this.modalLoading.set(true); - this.showModal.set(true); - this.timezoneApi.getById(id).pipe( - finalize(() => this.modalLoading.set(false)), + this.timezoneApi.delete(timezone.id).pipe( + finalize(() => this.deletingId.set(null)), takeUntilDestroyed(this.destroyRef) ).subscribe({ - next: timezone => { - if (this.selectedTimezoneId() !== timezone.id || !this.showModal()) return; - this.selectedTimezone.set(timezone); - this.timezoneForm.reset({ - ianaId: timezone.ianaId, - displayName: timezone.displayName, - utcOffsetMinutes: timezone.utcOffsetMinutes - }); - if (mode === 'view') this.timezoneForm.disable(); - this.resetFormState(); + next: () => { + this.toastr.success('Timezone deleted successfully.'); + this.tableStore.refresh(); }, - error: (error: HttpErrorResponse) => { - this.showModal.set(false); - if (error.status === 404) this.toastr.error('The timezone is no longer available.'); + error: (err) => { + let errorMsg = 'Unable to delete timezone.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete timezone because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Timezone not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.toastr.error(errorMsg); } }); } - closeModal(): void { - if (this.saving()) return; - this.showModal.set(false); - this.selectedTimezoneId.set(null); - this.selectedTimezone.set(null); - this.submitAttempted.set(false); - this.timezoneForm.enable(); + onDeleteCancelled(): void { + this.pendingDeleteTimezone.set(null); } - saveTimezone(): void { - if (this.isViewMode() || this.saving() || this.modalLoading()) return; - if (this.timezoneForm.invalid) { - this.submitAttempted.set(true); - this.timezoneForm.markAllAsTouched(); - this.focusFirstInvalidControl(); - return; - } - const selected = this.selectedTimezone(); - const id = this.selectedTimezoneId(); - if (this.modalMode() === 'edit' && (!selected || !id)) return; - - this.saving.set(true); - const createRequest = this.buildCreateRequest(); - const operation = this.modalMode() === 'create' - ? this.timezoneApi.create(createRequest) - : this.timezoneApi.update(id!, { ...createRequest, isActive: selected!.isActive }); - operation.pipe( - finalize(() => this.saving.set(false)), - takeUntilDestroyed(this.destroyRef) - ).subscribe({ - next: () => { - this.toastr.success(this.modalMode() === 'create' - ? 'Timezone saved successfully.' - : 'Timezone updated successfully.'); - this.finishSave(); - }, - error: (error: HttpErrorResponse) => this.handleSaveError(error) - }); + private requestDeleteTimezone(timezone: TimezoneTableRow): void { + this.pendingDeleteTimezone.set(timezone); + this.deleteConfirmDialog()?.open(); } - formatUtcOffset(minutes: number): string { - const sign = minutes >= 0 ? '+' : '-'; - const absolute = Math.abs(minutes); - return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`; - } + private changeTimezoneStatus(timezone: TimezoneTableRow, activate: boolean): void { + this.statusChangingId.set(timezone.id); - private prepareModal(id: string | null): void { - this.selectedTimezoneId.set(id); - this.selectedTimezone.set(null); - this.submitAttempted.set(false); - this.timezoneForm.enable(); - this.timezoneForm.reset({ ianaId: '', displayName: '', utcOffsetMinutes: 0 }); - this.resetFormState(); - } - - private buildCreateRequest(): CreateTimezoneRequest { - const value = this.timezoneForm.getRawValue(); - return { - ianaId: value.ianaId.trim(), - displayName: value.displayName.trim(), - utcOffsetMinutes: value.utcOffsetMinutes - }; - } - - private changeTimezoneStatus(id: string, isActive: boolean): void { - if (this.statusChangingId()) return; - this.statusChangingId.set(id); - this.timezoneApi.getById(id).pipe( - switchMap(timezone => this.timezoneApi.update(id, { - ianaId: timezone.ianaId, - displayName: timezone.displayName, - utcOffsetMinutes: timezone.utcOffsetMinutes, - isActive - })), + this.timezoneApi.updateStatus(timezone.id, { isActive: activate }).pipe( finalize(() => this.statusChangingId.set(null)), takeUntilDestroyed(this.destroyRef) ).subscribe({ next: () => { - this.toastr.success(isActive - ? 'Timezone activated successfully.' - : 'Timezone deleted successfully.'); - this.loadTimezones(this.queryState.getQuery()); + this.toastr.success(`Timezone ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); }, - error: (error: HttpErrorResponse) => { - if (error.status === 404) this.toastr.error('The timezone is no longer available.'); + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} timezone.`; + this.toastr.error(msg); } }); } - private handleSaveError(error: HttpErrorResponse): void { - if (error.status === 409) { - const message = this.apiErrorMessage(error) ?? 'A timezone with this IANA ID already exists.'; - this.toastr.error(message, 'Duplicate IANA timezone ID'); - } else if (error.status === 404) { - this.toastr.error('The timezone is no longer available.'); - this.closeModal(); - } - } - - private apiErrorMessage(error: HttpErrorResponse): string | null { - const body: unknown = error.error; - if (!body || typeof body !== 'object') return null; - if ('detail' in body && typeof body.detail === 'string') return body.detail; - if ('message' in body && typeof body.message === 'string') return body.message; - return null; - } - - private finishSave(): void { - this.showModal.set(false); - this.selectedTimezoneId.set(null); - this.selectedTimezone.set(null); - this.submitAttempted.set(false); - this.loadTimezones(this.queryState.getQuery()); - } - - private resetFormState(): void { - this.timezoneForm.markAsPristine(); - this.timezoneForm.markAsUntouched(); - this.timezoneForm.updateValueAndValidity(); - } - - private focusFirstInvalidControl(): void { - queueMicrotask(() => this.elementRef.nativeElement - .querySelector('modal [data-form-control][aria-invalid="true"]') - ?.focus()); + formatUtcOffset(totalMinutes: number): string { + const isNegative = totalMinutes < 0; + const absMinutes = Math.abs(totalMinutes); + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + const prefix = isNegative ? '-' : '+'; + return `UTC${prefix}${formattedHours}:${formattedMinutes}`; } } diff --git a/src/app/features/industries/data-access/industery.service.ts b/src/app/features/industries/data-access/industery.service.ts new file mode 100644 index 00000000..2cf78517 --- /dev/null +++ b/src/app/features/industries/data-access/industery.service.ts @@ -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 { + return this.http.get(INDUSTRY_ENDPOINTS.getById(id)); + } + + delete(id: string): Observable { + return this.http.delete(INDUSTRY_ENDPOINTS.delete(id)); + } + + autocomplete(term = '', limit = 50): Observable { + let params = new HttpParams().set('limit', limit); + const normalizedTerm = term?.trim(); + + if (normalizedTerm) { + params = params.set('term', normalizedTerm); + } + + return this.http.get(INDUSTRY_ENDPOINTS.autocomplete, { params }); + } +} \ No newline at end of file diff --git a/src/app/features/industries/data-access/industry.endpoints.ts b/src/app/features/industries/data-access/industry.endpoints.ts new file mode 100644 index 00000000..b7bfaced --- /dev/null +++ b/src/app/features/industries/data-access/industry.endpoints.ts @@ -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; diff --git a/src/app/features/industries/models/industry.model.ts b/src/app/features/industries/models/industry.model.ts new file mode 100644 index 00000000..f52c68f6 --- /dev/null +++ b/src/app/features/industries/models/industry.model.ts @@ -0,0 +1,7 @@ +export interface IndustryLookupDto { + id: string; + tenantId?: string | null; + industryCode?: string; + industryName: string; + parentIndustryId?: string | null; +} \ No newline at end of file diff --git a/src/app/features/industries/public-api.ts b/src/app/features/industries/public-api.ts new file mode 100644 index 00000000..d8a92bbc --- /dev/null +++ b/src/app/features/industries/public-api.ts @@ -0,0 +1,2 @@ +export { IndustryApiService } from './data-access/industery.service'; +export type { IndustryLookupDto } from './models/industry.model'; diff --git a/src/app/features/localization/localization.routes.ts b/src/app/features/localization/localization.routes.ts deleted file mode 100644 index 48b08968..00000000 --- a/src/app/features/localization/localization.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Routes } from '@angular/router'; - -export const localizationRoutes: Routes = [ - { - path: '', - loadComponent: () => import('./pages/localization-list/localization-list').then((m) => m.LocalizationList), - data: { childTitle: 'Localization', parentTitle: 'Platform', subParentTitle: 'Translations' }, - }, -]; diff --git a/src/app/features/localization/pages/localization-list/localization-list.html b/src/app/features/localization/pages/localization-list/localization-list.html deleted file mode 100644 index 0f245ea0..00000000 --- a/src/app/features/localization/pages/localization-list/localization-list.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

Localization

-

Translation browser, editor, import/export, and missing translation reports will be added here.

-
diff --git a/src/app/features/localization/pages/localization-list/localization-list.ts b/src/app/features/localization/pages/localization-list/localization-list.ts deleted file mode 100644 index 15e5cf8d..00000000 --- a/src/app/features/localization/pages/localization-list/localization-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-localization-list', - standalone: true, - imports: [CommonModule], - templateUrl: './localization-list.html', - styleUrl: './localization-list.scss', -}) -export class LocalizationList {} diff --git a/src/app/features/monitoring/monitoring.routes.ts b/src/app/features/monitoring/monitoring.routes.ts deleted file mode 100644 index a5b652a7..00000000 --- a/src/app/features/monitoring/monitoring.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Routes } from '@angular/router'; - -export const monitoringRoutes: Routes = [ - { - path: '', - loadComponent: () => import('./pages/monitoring-dashboard/monitoring-dashboard').then((m) => m.MonitoringDashboard), - data: { childTitle: 'Monitoring', parentTitle: 'Platform', subParentTitle: 'Observability' }, - }, -]; diff --git a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.html b/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.html deleted file mode 100644 index e0d9b527..00000000 --- a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

Monitoring

-

Tenant counts, subscription status, login audits, and recent failed logins will be added here.

-
diff --git a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.scss b/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.ts b/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.ts deleted file mode 100644 index e3b78496..00000000 --- a/src/app/features/monitoring/pages/monitoring-dashboard/monitoring-dashboard.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-monitoring-dashboard', - standalone: true, - imports: [CommonModule], - templateUrl: './monitoring-dashboard.html', - styleUrl: './monitoring-dashboard.scss', -}) -export class MonitoringDashboard {} diff --git a/src/app/features/organizations/dashboard/dashboard.html b/src/app/features/organizations/dashboard/dashboard.html new file mode 100644 index 00000000..692de853 --- /dev/null +++ b/src/app/features/organizations/dashboard/dashboard.html @@ -0,0 +1,78 @@ +
+
+

Welcome + back, + Json Taylor !

+

Track your sales activity, leads and + deals + here.

+
+
+ + +
+
+ +
+ + @for (card of statCards(); track card.title) { +
+
+
+
+
+

{{ card.title }}

+

+ {{ card.value }} +

+ + {{ card.helper }} + +
+ + + + +
+
+
+
+ } + +
+ + + + + +
+
\ No newline at end of file diff --git a/src/app/features/billing/pages/billing-list/billing-list.scss b/src/app/features/organizations/dashboard/dashboard.scss similarity index 100% rename from src/app/features/billing/pages/billing-list/billing-list.scss rename to src/app/features/organizations/dashboard/dashboard.scss diff --git a/src/app/features/organizations/dashboard/dashboard.ts b/src/app/features/organizations/dashboard/dashboard.ts new file mode 100644 index 00000000..2e40a8a0 --- /dev/null +++ b/src/app/features/organizations/dashboard/dashboard.ts @@ -0,0 +1,308 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { of } from 'rxjs'; + +import { DataTable } from '../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../shared/components/data-table/data-table.store'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTableRecord, + DataTableResult, +} from '../../../shared/components/data-table/data-table.types'; + +type OrganizationPlan = 'Enterprise' | 'Standard' | 'Trial'; +type OrganizationStatus = 'Active' | 'Trial' | 'Suspended'; + +interface DashboardStatCard { + title: string; + value: string; + helper: string; + accentClass: string; + iconClass: string; + iconBackgroundClass: string; + helperClass: string; +} + +interface OrganizationListRow extends DataTableRecord { + id: string; + code: string; + organizationName: string; + countryId: string; + country: string; + plan: string; + status: OrganizationStatus; + expiry: string; +} + +@Component({ + selector: 'dashboard', + standalone: true, + imports: [DataTable], + providers: [DataTableStore], + templateUrl: './dashboard.html', + styleUrl: './dashboard.scss', +}) +export class Dashboard implements OnInit { + private readonly router = inject(Router); + private readonly toastr = inject(ToastrService); + readonly tableStore = inject(DataTableStore); + + readonly statCards = signal([ + { + title: 'Total Organizations', + value: '24', + helper: 'All registered', + accentClass: 'border-primary', + iconClass: 'ti ti-building-community', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Active', + value: '19', + helper: 'Currently operational', + accentClass: 'border-primary', + iconClass: 'ti ti-circle-check', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Trial', + value: '3', + helper: 'Trial subscriptions', + accentClass: 'border-primary', + iconClass: 'ri-wallet-2-line', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Suspended', + value: '1', + helper: 'Temporarily disabled', + accentClass: 'border-primary', + iconClass: 'ti ti-player-pause', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Expired License', + value: '1', + helper: 'Needs renewal action', + accentClass: 'border-primary', + iconClass: 'ri ri-pass-expired-line', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + { + title: 'Active Users', + value: '482', + helper: 'Users with access', + accentClass: 'border-primary', + iconClass: 'ri ri-group-line', + iconBackgroundClass: 'bg-primary', + helperClass: 'bg-primary/10 text-primary', + }, + ]); + + readonly recentOrganizations = signal([ + { + id: '1', + code: 'ORG-0024', + organizationName: 'Syscom Group', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Enterprise', + status: 'Active', + expiry: '31-12-2026' + }, + { + id: '2', + code: 'ORG-0023', + organizationName: 'Acme Trading', + countryId: 'c2', + country: 'India', + plan: 'Standard', + status: 'Active', + expiry: '31-03-2027' + }, + { + id: '3', + code: 'ORG-0022', + organizationName: 'Falcon Retail LLC', + countryId: 'c3', + country: 'UAE', + plan: 'Trial', + status: 'Trial', + expiry: '28-07-2026' + }, + { + id: '4', + code: 'ORG-0021', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '5', + code: 'ORG-0022', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '6', + code: 'ORG-0023', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '7', + code: 'ORG-0024', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '8', + code: 'ORG-0025', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '9', + code: 'ORG-0026', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + ]); + + readonly columns = signal[]>([ + { key: 'code', label: 'Code', header: 'Code', sortable: true }, + { + key: 'organizationName', + label: 'Name', + header: 'Name', + sortable: true, + align: 'left', + }, + { key: 'country', label: 'Country', header: 'Country', sortable: true }, + { + key: 'plan', + label: 'Plan', + header: 'Plan', + sortable: true, + badge: true, + badgeClass: value => + value === 'Trial' + ? 'badge bg-warning/10 text-warning' + : 'badge bg-light text-defaulttextcolor', + }, + { + key: 'status', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + badgeClass: value => + value === 'Active' + ? 'badge bg-success/10 text-success' + : value === 'Trial' + ? 'badge bg-warning/10 text-warning' + : 'badge bg-danger/10 text-danger', + }, + { key: 'expiry', label: 'Expiry', header: 'Expiry', sortable: true }, + ]); + + readonly emptyMessage = signal('No Organizations'); + readonly emptyDescription = signal('Start by adding your first organization'); + + readonly actions = signal[]>([ + { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' }, + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, + { + type: 'suspend', + label: 'Suspend', + icon: 'ti ti-player-pause', + className: 'text-warning', + visible: row => row.status !== 'Suspended', + }, + ]); + + ngOnInit(): void { + this.tableStore.initialize({ + fetcher: query => { + const search = (query.search || '').trim().toLowerCase(); + const sortBy = query.sortBy; + const sortDir = query.sortDir; + + let rows = this.recentOrganizations().filter(row => { + const matchesSearch = !search + || row.code.toLowerCase().includes(search) + || row.organizationName.toLowerCase().includes(search) + || row.country.toLowerCase().includes(search) + || row.plan.toLowerCase().includes(search) + || row.status.toLowerCase().includes(search) + || (row.expiry && row.expiry.toLowerCase().includes(search)); + return matchesSearch; + }); + + if (sortBy) { + rows = [...rows].sort((left, right) => { + const leftVal = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const rightVal = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const compared = leftVal.localeCompare(rightVal); + return sortDir === 'asc' ? compared : -compared; + }); + } + + const total = rows.length; + const start = (query.page - 1) * query.pageSize; + const end = start + query.pageSize; + const result: DataTableResult = { + draw: query.draw, + total, + filtered: total, + rows: rows.slice(start, end) + }; + return of(result); + } + }); + } + + onAddOrganization(): void { + void this.router.navigate(['/organizations/onboarding']); + } + + onActionClick(event: DataTableActionEvent): 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}`); + } +} + diff --git a/src/app/features/organizations/organization-list/organization-list.html b/src/app/features/organizations/organization-list/organization-list.html index 4c6f643d..e32a2627 100644 --- a/src/app/features/organizations/organization-list/organization-list.html +++ b/src/app/features/organizations/organization-list/organization-list.html @@ -1 +1,63 @@ -

organization-list works!

+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + diff --git a/src/app/features/organizations/organization-list/organization-list.scss b/src/app/features/organizations/organization-list/organization-list.scss index e69de29b..8b137891 100644 --- a/src/app/features/organizations/organization-list/organization-list.scss +++ b/src/app/features/organizations/organization-list/organization-list.scss @@ -0,0 +1 @@ + diff --git a/src/app/features/organizations/organization-list/organization-list.ts b/src/app/features/organizations/organization-list/organization-list.ts index 0aa9bef6..6c685454 100644 --- a/src/app/features/organizations/organization-list/organization-list.ts +++ b/src/app/features/organizations/organization-list/organization-list.ts @@ -1,11 +1,278 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { of } from 'rxjs'; + +import { DataTable } from '../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../shared/components/data-table/data-table.store'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn, + DataTableRecord, + DataTableResult +} from '../../../shared/components/data-table/data-table.types'; +import { Autocomplete } from '../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteResolveValueFn, + AutocompleteSearchFn, + AutocompleteValueFn, +} from '../../../shared/components/form/autocomplete/autocomplete.types'; +import { FilterCard } from '../../../shared/components/filter-card/filter-card'; +import { Button } from '../../../shared/components/button/button'; +import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api'; + +type OrganizationStatus = 'Active' | 'Trial' | 'Suspended'; + +interface OrganizationListRow extends DataTableRecord { + id: string; + code: string; + organizationName: string; + countryId: string; + country: string; + plan: string; + status: OrganizationStatus; + expiry: string; +} @Component({ selector: 'organization-list', - imports: [], + standalone: true, + imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable, Button], + providers: [DataTableStore], templateUrl: './organization-list.html', styleUrl: './organization-list.scss', }) -export class OrganizationList { +export class OrganizationList implements OnInit { + private readonly formBuilder = inject(FormBuilder); + private readonly router = inject(Router); + private readonly toastr = inject(ToastrService); + private readonly countryApi = inject(CountryService); + readonly tableStore = inject(DataTableStore); + readonly selectedCountryLookup = signal(null); + readonly appliedCountryId = signal(null); + + readonly countryFilterForm = this.formBuilder.nonNullable.group({ + countryId: [''], + }); + + readonly allOrganizations = signal([ + { + id: '1', + code: 'ORG-0024', + organizationName: 'Syscom Group', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Enterprise', + status: 'Active', + expiry: '31-12-2026' + }, + { + id: '2', + code: 'ORG-0023', + organizationName: 'Acme Trading', + countryId: 'c2', + country: 'India', + plan: 'Standard', + status: 'Active', + expiry: '31-03-2027' + }, + { + id: '3', + code: 'ORG-0022', + organizationName: 'Falcon Retail LLC', + countryId: 'c3', + country: 'UAE', + plan: 'Trial', + status: 'Trial', + expiry: '28-07-2026' + }, + { + id: '4', + code: 'ORG-0021', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '5', + code: 'ORG-0022', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '6', + code: 'ORG-0023', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '7', + code: 'ORG-0024', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '8', + code: 'ORG-0025', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + { + id: '9', + code: 'ORG-0026', + organizationName: 'Oasis Foods', + countryId: 'c1', + country: 'Saudi Arabia', + plan: 'Standard', + status: 'Suspended', + expiry: '15-06-2026' + }, + ]); + + readonly searchCountries: AutocompleteSearchFn = (term, limit) => this.countryApi.autocomplete(term, limit); + readonly displayCountry: AutocompleteDisplayFn = country => country.name; + readonly countryValue: AutocompleteValueFn = country => country.id; + + readonly columns = signal[]>([ + { key: 'code', label: 'Code', header: 'Code', sortable: true }, + { + key: 'organizationName', + label: 'Organization Name', + header: 'Organization Name', + sortable: true, + align: 'left', + }, + { key: 'country', label: 'Country', header: 'Country', sortable: true }, + { + key: 'plan', + label: 'Plan', + header: 'Plan', + sortable: true, + badge: true, + badgeClass: value => + value === 'Trial' + ? 'badge bg-warning/10 text-warning' + : 'badge bg-light text-defaulttextcolor', + }, + { + key: 'status', + label: 'Status', + header: 'Status', + sortable: true, + badge: true, + badgeClass: value => { + if (value === 'Active') return 'badge bg-success/10 text-success'; + if (value === 'Trial') return 'badge bg-warning/10 text-warning'; + return 'badge bg-danger/10 text-danger'; + }, + }, + { key: 'expiry', label: 'Expiry', header: 'Expiry', sortable: true }, + ]); + + readonly actions = signal[]>([ + { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' }, + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, + { + type: 'suspend', + label: 'Suspend', + icon: 'ti ti-player-pause', + className: 'text-warning', + visible: row => row.status !== 'Suspended', + }, + ]); + + ngOnInit(): void { + this.tableStore.initialize({ + fetcher: query => { + const countryId = this.appliedCountryId(); + const selectedCountryName = this.selectedCountryLookup()?.name?.toLowerCase(); + const search = (query.search || '').trim().toLowerCase(); + const sortBy = query.sortBy; + const sortDir = query.sortDir; + + let rows = this.allOrganizations().filter(row => { + const matchesCountry = !countryId + || row.countryId === countryId + || (!!selectedCountryName && row.country.toLowerCase() === selectedCountryName); + const matchesSearch = !search + || row.code.toLowerCase().includes(search) + || row.organizationName.toLowerCase().includes(search) + || row.country.toLowerCase().includes(search) + || row.plan.toLowerCase().includes(search) + || row.status.toLowerCase().includes(search) + || row.expiry.toLowerCase().includes(search); + return matchesCountry && matchesSearch; + }); + + if (sortBy) { + rows = [...rows].sort((left, right) => { + const leftVal = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const rightVal = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase(); + const compared = leftVal.localeCompare(rightVal); + return sortDir === 'asc' ? compared : -compared; + }); + } + + const total = rows.length; + const start = (query.page - 1) * query.pageSize; + const end = start + query.pageSize; + const result: DataTableResult = { + draw: query.draw, + total, + filtered: total, + rows: rows.slice(start, end) + }; + return of(result); + } + }); + } + + onCountryLookupSelected(country: CountryLookupDto | null): void { + this.selectedCountryLookup.set(country); + } + + applyCountryFilter(): void { + const rawValue = this.countryFilterForm.controls.countryId.value; + const selectedCountryId = (rawValue || '').trim(); + if (!selectedCountryId) { + this.selectedCountryLookup.set(null); + } + this.appliedCountryId.set(selectedCountryId || null); + this.tableStore.refresh(); + } + + onAddOrganization(): void { + void this.router.navigate(['/organizations/onboarding']); + } + + onActionClick(event: DataTableActionEvent): 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}`); + } } diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html index 9def9eba..4d5ef8e4 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.html @@ -1,239 +1,254 @@
- - - + + +
+
+ - -
-
+ +
+
- -
- -
- @if (isFirstStep()) { - - } @else { - - } + +
+
+ +
+ +
+ @if (!isLastStep()) { + + } @else { + + } +
+
- -
- +
- @if (!isLastStep()) { - - } @else { - - } + @if (!isLastStep()) { + + } @else { + + } +
- +
- +
\ No newline at end of file diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss index e69de29b..642f4c5d 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.scss @@ -0,0 +1,129 @@ +.stepper-header-card { + background-color: transparent; + border: none; + box-shadow: none; + padding: 0.25rem 0 0.75rem; + + @media (min-width: 640px) { + padding: 0.5rem 0 1.25rem; + } +} + +.stepper-nav-scroll-wrapper { + -webkit-overflow-scrolling: touch; +} + +.stepper-progress-line { + background: var(--primary, #7c3deb); + background: linear-gradient(90deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + border-radius: 2px; +} + +.step-node-btn { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + outline: none; + + &:disabled { + cursor: default; + } +} + +.step-circle { + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); + + @media (min-width: 640px) { + width: 4rem; + height: 4rem; + } + + // Pending Step + &.pending { + background-color: #f0f2f5; + border: 2px solid #cbd5e1; + color: #94a3b8; + + :host-context(.dark) & { + background-color: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: #64748b; + } + } + + // Completed Step + &.completed { + background: var(--primary, #7c3deb); + background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + color: #ffffff; + border: none; + box-shadow: 0 4px 12px rgba(124, 61, 235, 0.25); + } + + // Active Step with Glowing Aura in Theme Primary + &.active { + background: var(--primary, #7c3deb); + background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + color: #ffffff; + border: none; + box-shadow: + 0 0 20px 5px rgba(124, 61, 235, 0.45), + 0 0 10px 2px rgba(168, 85, 247, 0.35); + transform: scale(1.05); + + @media (max-width: 639px) { + box-shadow: + 0 0 10px 2px rgba(124, 61, 235, 0.35), + 0 0 5px 1px rgba(168, 85, 247, 0.25); + } + } +} + +.step-label { + margin-top: 0.5rem; + font-size: 0.75rem; + line-height: 1rem; + white-space: nowrap; + transition: all 0.25s ease; + + @media (min-width: 640px) { + margin-top: 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + } + + &.pending { + color: #94a3b8; + font-weight: 400; + + :host-context(.dark) & { + color: #64748b; + } + } + + &.completed { + color: #334155; + font-weight: 500; + + :host-context(.dark) & { + color: #cbd5e1; + } + } + + &.active { + color: var(--primary, #7c3deb); + font-weight: 700; + + :host-context(.dark) & { + color: #ffffff; + } + } +} diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts index c5de3d6f..595ed949 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts @@ -4,6 +4,7 @@ import { input, output } from '@angular/core'; +import { Button } from '../../../../../shared/components/button/button'; export interface OnboardingStep { readonly key: string; @@ -13,7 +14,7 @@ export interface OnboardingStep { @Component({ selector: 'onboarding-stepper', standalone: true, - imports: [], + imports: [Button], templateUrl: './onboarding-stepper.html', styleUrl: './onboarding-stepper.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -34,6 +35,33 @@ export class OnboardingStepper { readonly finishClicked = output(); readonly cancelClicked = output(); + currentStepLabel(): string { + return this.steps()[this.currentStepIndex()]?.label ?? ''; + } + + getProgressPercent(): number { + const total = this.steps().length; + if (total === 0) return 0; + return Math.round(((this.currentStepIndex() + 1) / total) * 100); + } + + getTrackLineLeft(): string { + const total = this.steps().length; + if (total <= 0) return '0%'; + return `${100 / (total * 2)}%`; + } + + getProgressLineWidth(): string { + const total = this.steps().length; + if (total <= 1) return '0%'; + const maxSpan = (100 * (total - 1)) / total; + const progressRatio = Math.min( + 1, + Math.max(0, this.currentStepIndex() / (total - 1)) + ); + return `${maxSpan * progressRatio}%`; + } + isFirstStep(): boolean { return this.currentStepIndex() === 0; } diff --git a/src/app/features/organizations/organization-onboarding/constants/organization-onboarding.constants.ts b/src/app/features/organizations/organization-onboarding/constants/organization-onboarding.constants.ts new file mode 100644 index 00000000..54d02ae6 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/constants/organization-onboarding.constants.ts @@ -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' }, +]; \ No newline at end of file diff --git a/src/app/features/organizations/organization-onboarding/data-access/organization-onboarding.endpoints.ts b/src/app/features/organizations/organization-onboarding/data-access/organization-onboarding.endpoints.ts new file mode 100644 index 00000000..aeaaa533 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/data-access/organization-onboarding.endpoints.ts @@ -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; diff --git a/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts similarity index 71% rename from src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts rename to src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts index 7ae2e8a6..8a6357fd 100644 --- a/src/app/features/organizations/organization-onboarding/services/organization-onboarding-state.service.ts +++ b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts @@ -1,6 +1,6 @@ import { Injectable, computed, signal } from '@angular/core'; -import { OrganizationOnboardingDraft } from '../models/organization-draft.model'; +import { OrganizationOnboardingDraft } from '../../models/organization-draft.model'; import { OnboardingStepDefinition, OrganizationAdminValue, @@ -8,7 +8,9 @@ import { OrganizationLocalizationValue, OrganizationOnboardingData, OrganizationPlanLimitsValue, -} from '../models/organization-onboarding.model'; +} from '../../models/organization-onboarding.model'; + +import { OrganizationServerDraftResponse } from '../../models/organization-onboarding.model'; const INITIAL_DATA: OrganizationOnboardingData = { basics: null, @@ -33,6 +35,7 @@ const INITIAL_DRAFT_STATE: OnboardingDraftState = { @Injectable() export class OrganizationOnboardingStateService { + private readonly organizationIdState = signal(null); private readonly onboardingDataState = signal(INITIAL_DATA); private readonly onboardingDraftState = signal(INITIAL_DRAFT_STATE); private readonly currentStepIndexState = signal(0); @@ -40,6 +43,7 @@ export class OrganizationOnboardingStateService { private readonly savedDraftIdState = signal(null); private readonly savedAtState = signal(null); + readonly organizationId = this.organizationIdState.asReadonly(); readonly onboardingData = this.onboardingDataState.asReadonly(); readonly onboardingDraft = this.onboardingDraftState.asReadonly(); readonly currentStepIndex = this.currentStepIndexState.asReadonly(); @@ -56,8 +60,12 @@ export class OrganizationOnboardingStateService { return !!(data.basics || data.localization || data.planLimits || data.admin); }); + setOrganizationId(id: string | null): void { + this.organizationIdState.set(id); + } + setCurrentStepIndex(index: number): void { - this.currentStepIndexState.set(Math.max(0, index)); + this.currentStepIndexState.set(Math.min(3, Math.max(0, index))); } updateBasics(value: OrganizationBasicsValue | null): void { @@ -108,8 +116,27 @@ export class OrganizationOnboardingStateService { this.completedStepIndexesState.update(current => current.filter(item => item !== index)); } + invalidateStep(index: number): void { + this.unmarkStepCompleted(index); + this.onboardingDataState.update(current => { + switch (index) { + case 0: + return { ...current, basics: null }; + case 1: + return { ...current, localization: null }; + case 2: + return { ...current, planLimits: null }; + case 3: + return { ...current, admin: null }; + default: + return current; + } + }); + } + setCompletedStepIndexes(indexes: readonly number[]): void { - const uniqueIndexes = [...new Set(indexes.filter(index => index >= 0))].sort((left, right) => left - right); + const uniqueIndexes = [...new Set(indexes.filter(index => index >= 0 && index <= 3))] + .sort((left, right) => left - right); this.completedStepIndexesState.set(uniqueIndexes); } @@ -137,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 { this.onboardingDraftState.set({ basics: draft.basics ? { ...draft.basics } : null, @@ -158,7 +215,7 @@ export class OrganizationOnboardingStateService { ? { ...draft.admin } as OrganizationAdminValue : null, }); - this.currentStepIndexState.set(Math.max(0, draft.currentStepIndex)); + this.currentStepIndexState.set(Math.min(3, Math.max(0, draft.currentStepIndex))); this.setCompletedStepIndexes(draft.completedStepIndexes); this.savedDraftIdState.set(draft.draftId); this.savedAtState.set(draft.savedAt); @@ -170,6 +227,7 @@ export class OrganizationOnboardingStateService { } clear(): void { + this.organizationIdState.set(null); this.onboardingDataState.set(INITIAL_DATA); this.onboardingDraftState.set(INITIAL_DRAFT_STATE); this.currentStepIndexState.set(0); @@ -180,10 +238,10 @@ export class OrganizationOnboardingStateService { createStepDefinitions(): readonly OnboardingStepDefinition[] { return [ - { key: 'basics', label: 'Basics' }, + { key: 'basics', label: 'Basic' }, { key: 'localization', label: 'Localization' }, - { key: 'plan-limits', label: 'Plan & Limits' }, - { key: 'admin-user', label: 'Admin & User' }, + { key: 'plan-limits', label: 'Plans and Limits' }, + { key: 'admin-user', label: 'Admin & Users' }, ]; } -} \ No newline at end of file +} diff --git a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding.service.ts b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding.service.ts new file mode 100644 index 00000000..1e5e5613 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding.service.ts @@ -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[] = [ + { 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[] = [ + { value: 0, label: '12 hour' }, + { value: 1, label: '24 hour' }, +]; + +const NUMBER_FORMAT_OPTIONS: readonly FormSelectOption[] = [ + { value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' }, + { value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' }, +]; + +const FISCAL_YEAR_OPTIONS: readonly FormSelectOption[] = [ + { value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' }, + { value: 'AprilToMarch', label: 'April - March' }, + { value: 'JulyToJune', label: 'July - June' }, +]; + +const LICENSE_TYPE_OPTIONS: readonly FormSelectOption[] = [ + { 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 { + return this.http.post(ORGANIZATION_ONBOARDING_ENDPOINTS.createDraft, request); + } + + getOrganizationById(id: string): Observable { + return this.http.get(ORGANIZATION_ONBOARDING_ENDPOINTS.getById(id)); + } + + updateBasics(id: string, request: UpdateOrganizationBasicsApiRequest): Observable { + return this.http.patch(ORGANIZATION_ONBOARDING_ENDPOINTS.updateBasics(id), request); + } + + updateLocalization(id: string, request: UpdateOrganizationLocalizationApiRequest): Observable { + return this.http.patch(ORGANIZATION_ONBOARDING_ENDPOINTS.updateLocalization(id), request); + } + + updatePlan(id: string, request: UpdateOrganizationPlanApiRequest): Observable { + return this.http.patch(ORGANIZATION_ONBOARDING_ENDPOINTS.updatePlan(id), request); + } + + updateAdminContact(id: string, request: UpdateOrganizationAdminContactApiRequest): Observable { + return this.http.patch(ORGANIZATION_ONBOARDING_ENDPOINTS.updateAdminContact(id), request); + } + + finishOnboarding(id: string): Observable { + return this.http.post(ORGANIZATION_ONBOARDING_ENDPOINTS.finish(id), {}); + } + + // --- Lookups and Defaults --- + + searchOrganizationTypes(term: string | null, limit = 10): Observable { + return of(this.filterLookupValues(ORGANIZATION_TYPE_OPTIONS, term, limit)); + } + + resolveOrganizationType(id: string): Observable { + 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 { + const params = new HttpParams().set('term', term).set('limit', limit); + return this.http.get(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 { + 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 { + if (!id) { + return of(null); + } + return this.http.get(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 { + 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[] { + return DATE_FORMAT_OPTIONS; + } + + getTimeFormatOptions(): readonly FormSelectOption[] { + return TIME_FORMAT_OPTIONS; + } + + getNumberFormatOptions(): readonly FormSelectOption[] { + return NUMBER_FORMAT_OPTIONS; + } + + getFiscalYearOptions(): readonly FormSelectOption[] { + return FISCAL_YEAR_OPTIONS; + } + + getLicenseTypeOptions(): readonly FormSelectOption[] { + return LICENSE_TYPE_OPTIONS; + } + + getCountryLocalizationDefaults(countryIso2: string | null): Observable { + if (!countryIso2?.trim()) { + return of(null); + } + + return of(LOCALIZATION_DEFAULTS.find(item => item.countryId === countryIso2.trim().toUpperCase()) ?? null); + } + + private filterLookupValues( + 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)); + } +} diff --git a/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts b/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts index 7e591a24..2f907ae8 100644 --- a/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts +++ b/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts @@ -29,7 +29,7 @@ export interface OrganizationBasicsValue { readonly registrationCountry: CountryLookupValue; } -export type TimeFormatValue = 'TwelveHour' | 'TwentyFourHour'; +export type TimeFormatValue = 0 | 1 | 'TwelveHour' | 'TwentyFourHour'; export type DateFormatValue = | 'DD/MM/YYYY' @@ -45,6 +45,42 @@ export type FiscalYearConventionValue = | 'AprilToMarch' | '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 { readonly timeZone: OnboardingLookupValue; readonly currency: OnboardingLookupValue; @@ -52,16 +88,45 @@ export interface OrganizationLocalizationValue { readonly additionalLanguageIds: readonly string[]; readonly additionalLanguageSelections: readonly OnboardingLookupValue[]; readonly dateFormat: DateFormatValue; - readonly timeFormat: TimeFormatValue; + readonly timeFormat: TimeFormatValue | number | null; // 0 = TwelveHour, 1 = TwentyFourHour readonly numberFormat: NumberFormatValue; readonly fiscalYearConvention: FiscalYearConventionValue; 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 { readonly code?: string | null; + readonly maxCompanies?: number; + readonly maxUsers?: number; + readonly maxStorageGb?: number; + readonly defaultTrialDays?: number | null; } export interface OrganizationPlanLimitsValue { @@ -108,6 +173,7 @@ export interface OrganizationPlanDefaults { readonly maximumCompanies: number; readonly maximumUsers: number; readonly maximumStorageGb: number; + readonly defaultTrialDays?: number | null; readonly limitsEditable: boolean; } @@ -116,4 +182,165 @@ export interface OnboardingStepForm { getValue(): TValue; getDraftValue(): Partial; patchValue(value: Partial): void; -} \ No newline at end of file + 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, + 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, + 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, + 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, + 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 | null; + localization?: OrganizationLocalizationValue | Partial | null; + planLimits?: OrganizationPlanLimitsValue | Partial | null; + admin?: OrganizationAdminValue | Partial | null; +} diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.html b/src/app/features/organizations/organization-onboarding/organization-onboarding.html index 4c21af5f..563d4284 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.html +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.html @@ -1,66 +1,58 @@ -
-
-
-

- Create Organization -

+
+ +
+
-
- - @switch (currentStep().key) { - @case ('basics') { - - } +
+ +
- @case ('localization') { -
- Localization content -
- } +
+ +
- @case ('plan-limits') { -
- Plan and Limits content -
- } +
+ +
+
+
- @case ('admin-user') { -
- Admin and User content -
- } - } -
-
-
-
\ No newline at end of file + + + + + + + diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts index 5d18da07..a8b48d8e 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts @@ -4,24 +4,46 @@ import { computed, DestroyRef, inject, - signal + signal, + viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { catchError, finalize, of } from 'rxjs'; +import { catchError, finalize, Observable, of, switchMap } from 'rxjs'; import { OnboardingStep, OnboardingStepper } from './components/onboarding-stepper/onboarding-stepper'; import { OrganizationBasicsStepComponent } from './steps/organization-basics/organization-basics'; -import { OrganizationOnboardingStateService } from './services/organization-onboarding-state.service'; -import { OrganizationOnboardingService } from './services/organization-onboarding.service'; +import { OrganizationLocalizationStepComponent } from './steps/organization-localization/organization-localization'; +import { OrganizationPlanLimitsStepComponent } from './steps/organization-plan-limits/organization-plan-limits'; +import { OrganizationAdminStepComponent } from './steps/organization-admin/organization-admin'; +import { OrganizationOnboardingStateService } from './data-access/services/organization-onboarding-state.service'; +import { OrganizationOnboardingService } from './data-access/services/organization-onboarding.service'; +import { ConfirmDialog } from '../../../shared/components/confirm-dialog/confirm-dialog'; +import { + OnboardingStepForm, + OrganizationAdminValue, + OrganizationServerDraftResponse, + mapAdminContactStepToApiRequest, + mapBasicsStepToApiRequest, + mapLocalizationStepToApiRequest, + mapPlanStepToApiRequest, +} from './models/organization-onboarding.model'; +import { OrganizationProvisioningRequest } from './models/organization-provisioning.model'; @Component({ selector: 'app-organization-onboarding', standalone: true, - imports: [OnboardingStepper, OrganizationBasicsStepComponent], + imports: [ + OnboardingStepper, + OrganizationBasicsStepComponent, + OrganizationLocalizationStepComponent, + OrganizationPlanLimitsStepComponent, + OrganizationAdminStepComponent, + ConfirmDialog, + ], templateUrl: './organization-onboarding.html', styleUrl: './organization-onboarding.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -30,8 +52,9 @@ import { OrganizationOnboardingService } from './services/organization-onboardin export class OrganizationOnboarding { private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); private readonly toastr = inject(ToastrService); - private readonly stateService = inject(OrganizationOnboardingStateService); + readonly stateService = inject(OrganizationOnboardingStateService); private readonly onboardingService = inject(OrganizationOnboardingService); readonly steps: readonly OnboardingStep[] = this.stateService.createStepDefinitions(); @@ -39,7 +62,15 @@ export class OrganizationOnboarding { readonly savingDraft = signal(false); readonly finishing = signal(false); - private basicsStep?: OrganizationBasicsStepComponent; + private readonly basicsStep = viewChild(OrganizationBasicsStepComponent); + private readonly localizationStep = viewChild(OrganizationLocalizationStepComponent); + private readonly planLimitsStep = viewChild(OrganizationPlanLimitsStepComponent); + private readonly adminStep = viewChild(OrganizationAdminStepComponent); + private readonly cancelConfirmDialog = viewChild('cancelConfirmDialog'); + private readonly finishConfirmDialog = viewChild('finishConfirmDialog'); + private savedDraftSnapshot = ''; + + readonly provisioningRequest = signal(null); readonly currentStepIndex = this.stateService.currentStepIndex; readonly completedStepIndexes = this.stateService.completedStepIndexes; @@ -53,29 +84,25 @@ export class OrganizationOnboarding { ); constructor() { - this.onboardingService.loadDraft() - .pipe( - catchError(() => { - this.toastr.error('Unable to restore the onboarding draft.', 'Draft restore failed'); - return of(null); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(draft => { - if (!draft) { - return; - } + const draftId = this.route.snapshot.queryParamMap.get('id'); - this.stateService.restoreDraft(draft); - }); - } - - registerBasicsStep(component: OrganizationBasicsStepComponent): void { - this.basicsStep = component; - const basicsDraft = this.stateService.onboardingDraft().basics; - - if (basicsDraft) { - component.patchValue(basicsDraft); + if (draftId) { + this.onboardingService.getOrganizationById(draftId) + .pipe( + catchError(err => { + const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to restore draft from server.'; + this.toastr.error(errorMsg, 'Draft Restore Failed'); + return of(null); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(draft => { + if (draft) { + this.stateService.restoreServerDraft(draft); + this.savedDraftSnapshot = this.serializeDraftState(); + this.hydrateActiveStep(); + } + }); } } @@ -84,7 +111,9 @@ export class OrganizationOnboarding { return; } + this.captureActiveStepDraft(); this.stateService.setCurrentStepIndex(index); + this.hydrateActiveStep(); } onBack(): void { @@ -94,7 +123,9 @@ export class OrganizationOnboarding { return; } + this.captureActiveStepDraft(); this.stateService.setCurrentStepIndex(currentIndex - 1); + this.hydrateActiveStep(); } onNext(): void { @@ -104,17 +135,29 @@ export class OrganizationOnboarding { return; } - if (currentIndex === 0) { - if (!this.basicsStep?.validate()) { - this.stateService.unmarkStepCompleted(currentIndex); - return; - } - - this.stateService.updateBasics(this.basicsStep.getValue()); + const activeStep = this.getActiveStep(); + if (!activeStep?.validate()) { + this.stateService.unmarkStepCompleted(currentIndex); + return; } + this.storeValidatedActiveStep(activeStep); this.stateService.markStepCompleted(currentIndex); - this.stateService.setCurrentStepIndex(currentIndex + 1); + + this.persistStepToServer(currentIndex, true) + .pipe( + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.stateService.setCurrentStepIndex(currentIndex + 1); + 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 { @@ -122,13 +165,17 @@ export class OrganizationOnboarding { return; } - if (this.currentStepIndex() === 0 && this.basicsStep) { - this.stateService.updateBasicsDraft(this.basicsStep.getDraftValue()); + const activeStep = this.getActiveStep(); + if (!activeStep?.validate()) { + return; } + this.storeValidatedActiveStep(activeStep); + this.savingDraft.set(true); - this.onboardingService.saveDraft(this.stateService.buildDraft()) + const currentIndex = this.currentStepIndex(); + this.persistStepToServer(currentIndex, false) .pipe( finalize(() => { this.savingDraft.set(false); @@ -137,10 +184,12 @@ export class OrganizationOnboarding { ) .subscribe({ next: () => { - this.toastr.success('Onboarding draft saved successfully.', 'Draft saved'); + this.savedDraftSnapshot = this.serializeDraftState(); + this.toastr.success('Onboarding draft saved successfully on server.', 'Draft Saved'); }, - error: () => { - this.toastr.error('Unable to save the onboarding draft.', 'Draft save failed'); + error: (err) => { + const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to save onboarding draft to server.'; + this.toastr.error(errorMsg, 'Draft Save Failed'); } }); } @@ -150,15 +199,262 @@ export class OrganizationOnboarding { return; } - this.finishing.set(true); + const activeStep = this.adminStep(); + if (!activeStep?.validate()) { + this.stateService.unmarkStepCompleted(3); + return; + } - queueMicrotask(() => { - this.finishing.set(false); + const adminValue = activeStep.getValue(); + this.stateService.updateAdmin(adminValue); + this.stateService.markStepCompleted(3); + + const data = this.stateService.onboardingData(); + if (!data.basics || !data.localization || !data.planLimits || !data.admin) { + this.toastr.error('Complete and validate every onboarding step before provisioning.'); + return; + } + + this.provisioningRequest.set({ + basics: data.basics, + localization: data.localization, + planLimits: data.planLimits, + admin: data.admin, }); + + const confirmModal = this.finishConfirmDialog(); + if (confirmModal) { + void confirmModal.open(); + } else { + this.onProvisioningConfirmed(); + } } onCancel(): void { - this.stateService.clear(); - void this.router.navigate(['/configuration/organizations']); + this.captureActiveStepDraft(); + if (this.serializeDraftState() !== this.savedDraftSnapshot) { + void this.cancelConfirmDialog()?.open(); + return; + } + + void this.leaveOnboarding(); } -} \ No newline at end of file + + onDiscardAndLeave(): void { + void this.leaveOnboarding(); + } + + onProvisioningConfirmed(): void { + const orgId = this.stateService.organizationId(); + const adminData = this.stateService.admin(); + + 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 { + 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 { + 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 | undefined { + switch (this.currentStepIndex()) { + case 0: + return this.basicsStep(); + case 1: + return this.localizationStep(); + case 2: + return this.planLimitsStep(); + case 3: + return this.adminStep(); + default: + return undefined; + } + } + + private storeValidatedActiveStep(step: OnboardingStepForm): void { + switch (this.currentStepIndex()) { + case 0: + this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue()); + break; + case 1: + this.stateService.updateLocalization((step as OrganizationLocalizationStepComponent).getValue()); + break; + case 2: + this.stateService.updatePlanLimits((step as OrganizationPlanLimitsStepComponent).getValue()); + break; + case 3: + this.stateService.updateAdmin((step as OrganizationAdminStepComponent).getValue()); + break; + } + } + + private captureActiveStepDraft(): void { + const currentIndex = this.currentStepIndex(); + const validatedValue = this.getValidatedStepValue(currentIndex); + + switch (currentIndex) { + case 0: + this.stateService.updateBasicsDraft(this.basicsStep()?.getDraftValue() ?? null); + break; + case 1: + this.stateService.updateLocalizationDraft(this.localizationStep()?.getDraftValue() ?? null); + break; + case 2: + this.stateService.updatePlanLimitsDraft(this.planLimitsStep()?.getDraftValue() ?? null); + break; + case 3: + this.stateService.updateAdminDraft(this.adminStep()?.getDraftValue() ?? null); + break; + } + + const draftValue = this.getDraftStepValue(currentIndex); + if ( + this.stateService.isStepCompleted(currentIndex) && + JSON.stringify(draftValue) !== JSON.stringify(validatedValue) + ) { + this.stateService.invalidateStep(currentIndex); + } + } + + private getValidatedStepValue(index: number): unknown { + const data = this.stateService.onboardingData(); + return [data.basics, data.localization, data.planLimits, data.admin][index] ?? null; + } + + private getDraftStepValue(index: number): unknown { + const draft = this.stateService.onboardingDraft(); + return [draft.basics, draft.localization, draft.planLimits, draft.admin][index] ?? null; + } + + private hydrateActiveStep(): void { + queueMicrotask(() => { + const draft = this.stateService.onboardingDraft(); + switch (this.currentStepIndex()) { + case 0: + if (draft.basics) this.basicsStep()?.patchValue(draft.basics); + break; + case 1: + if (draft.localization) { + this.localizationStep()?.patchValue(draft.localization); + } + this.applyCountryDefaults(); + break; + case 2: + if (draft.planLimits) this.planLimitsStep()?.patchValue(draft.planLimits); + break; + case 3: + if (draft.admin) this.adminStep()?.patchValue(draft.admin); + break; + } + }); + } + + private applyCountryDefaults(): void { + const countryIso2 = this.stateService.basics()?.registrationCountry.iso2 ?? null; + this.onboardingService.getCountryLocalizationDefaults(countryIso2) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(defaults => { + if (defaults) { + this.localizationStep()?.applyCountryDefaults(defaults, false); + } + }); + } + + private serializeDraftState(): string { + const draft = this.stateService.buildDraft(); + return JSON.stringify({ + currentStepIndex: draft.currentStepIndex, + completedStepIndexes: draft.completedStepIndexes, + basics: draft.basics, + localization: draft.localization, + planLimits: draft.planLimits, + admin: draft.admin, + }); + } + + private async leaveOnboarding(): Promise { + this.stateService.clear(); + await this.router.navigate(['/organizations/list']); + } +} diff --git a/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts b/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts deleted file mode 100644 index 2f92c05a..00000000 --- a/src/app/features/organizations/organization-onboarding/services/organization-onboarding.service.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; -import { Observable, catchError, map, of, throwError } from 'rxjs'; -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'; -import { - OrganizationProvisioningRequest, - OrganizationProvisioningResult, -} from '../models/organization-provisioning.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 FormSelect[] = [ - // { 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 FormSelect[] = [ - // { value: 'TwelveHour', label: '12 hour' }, - // { value: 'TwentyFourHour', label: '24 hour' }, -]; - -const NUMBER_FORMAT_OPTIONS: readonly FormSelect[] = [ - // { value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' }, - // { value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' }, -]; - -const FISCAL_YEAR_OPTIONS: readonly FormSelect[] = [ - // { value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' }, - // { value: 'AprilToMarch', label: 'April - March' }, - // { value: 'JulyToJune', label: 'July - June' }, -]; - -const LICENSE_TYPE_OPTIONS: readonly FormSelect[] = [ - // { 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 { - return of(this.filterLookupValues(ORGANIZATION_TYPE_OPTIONS, term, limit)); - } - - resolveOrganizationType(id: string): Observable { - return of(ORGANIZATION_TYPE_OPTIONS.find(option => option.id === id) ?? null); - } - - searchIndustries(term: string | null, limit = 10): Observable { - return of(this.filterLookupValues(INDUSTRY_OPTIONS, term, limit)); - } - - resolveIndustry(id: string): Observable { - return of(INDUSTRY_OPTIONS.find(option => option.id === id) ?? null); - } - - searchRegistrationCountries(term: string | null, limit = 10): Observable { - return this.countryService.autocomplete(term ?? '', limit); - } - - resolveCountry(id: string): Observable { - return this.countryService.getCountryById(id).pipe( - map(country => ({ id: country.id, iso2: country.iso2, name: country.name })) - ); - } - - searchTimezones(term: string | null, limit = 10): Observable { - return this.timezoneService.autocomplete(term, limit); - } - - resolveTimezone(id: string): Observable { - return this.timezoneService.getById(id).pipe( - map(timezone => ({ id: timezone.id, ianaId: timezone.ianaId, displayName: timezone.displayName })) - ); - } - - searchCurrencies(term: string | null, limit = 10): Observable { - return this.currencyService.autocomplete(term, limit); - } - - resolveCurrency(id: string): Observable { - 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 { - return this.languageService.autocomplete(term, limit); - } - - resolveLanguage(id: string): Observable { - 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):readonly FormSelect[] { - return DATE_FORMAT_OPTIONS; - } - // loadLanguageOptions(limit = 50): Observable[]> { - // // return this.languageService.autocomplete('', limit).pipe( - // // map(items => items.map(item => ({ - // // value: item.id, - // // label: `${item.code} - ${item.name}`, - // // }))) - // // ); - // } - - getDateFormatOptions(): readonly FormSelect[] { - return DATE_FORMAT_OPTIONS; - } - - getTimeFormatOptions(): readonly FormSelect[] { - return TIME_FORMAT_OPTIONS; - } - - getNumberFormatOptions(): readonly FormSelect[] { - return NUMBER_FORMAT_OPTIONS; - } - - getFiscalYearOptions(): readonly FormSelect[] { - return FISCAL_YEAR_OPTIONS; - } - - getLicenseTypeOptions(): readonly FormSelect[] { - return LICENSE_TYPE_OPTIONS; - } - - searchSubscriptionPlans(term: string | null, limit = 10): Observable { - return of(this.filterLookupValues(SUBSCRIPTION_PLAN_OPTIONS, term, limit)); - } - - resolveSubscriptionPlan(id: string): Observable { - return of(SUBSCRIPTION_PLAN_OPTIONS.find(option => option.id === id) ?? null); - } - - getPlanDefaults(planId: string): Observable { - return of(PLAN_DEFAULTS.find(item => item.plan.id === planId) ?? null); - } - - getCountryLocalizationDefaults(countryIso2: string | null): Observable { - if (!countryIso2?.trim()) { - return of(null); - } - - return of(LOCALIZATION_DEFAULTS.find(item => item.countryId === countryIso2.trim().toUpperCase()) ?? null); - } - - loadDraft(): Observable { - try { - const rawDraft = sessionStorage.getItem(ORGANIZATION_ONBOARDING_DRAFT_KEY); - - if (!rawDraft) { - return of(null); - } - - const parsedDraft = JSON.parse(rawDraft) as Partial; - - if (!this.isValidDraft(parsedDraft)) { - sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY); - return of(null); - } - - return of(parsedDraft); - } catch (error) { - sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY); - return throwError(() => error); - } - } - - saveDraft(draft: OrganizationOnboardingDraft): Observable { - try { - sessionStorage.setItem(ORGANIZATION_ONBOARDING_DRAFT_KEY, JSON.stringify(draft)); - return of(void 0); - } catch (error) { - return throwError(() => error); - } - } - - clearDraft(): Observable { - try { - sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY); - return of(void 0); - } catch (error) { - return throwError(() => error); - } - } - - provisionOrganization(_request: OrganizationProvisioningRequest): Observable { - return of({ - status: 'not-configured', - message: 'Provisioning is not configured yet for organization onboarding.', - }); - } - - private filterLookupValues( - 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): value is OrganizationOnboardingDraft { - return ( - value.schemaVersion === 1 && - typeof value.currentStepIndex === 'number' && - Array.isArray(value.completedStepIndexes) && - typeof value.savedAt === 'string' - ); - } -} \ No newline at end of file diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.html b/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.html new file mode 100644 index 00000000..9f0ca37b --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.html @@ -0,0 +1,156 @@ +
+
+
+

+ Organization Contact +

+
+ +
+ +
+ +
+ +
+ +
+

+ First Administrator +

+

+ A set-password link will be sent to this administrator. +

+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+

+ Final Review +

+ +
+
+
Organization
+
+ {{ reviewData().basics?.organizationName || 'Not completed' }} +
+
+
+
Country
+
+ {{ reviewData().basics?.registrationCountry?.label || 'Not completed' }} +
+
+
+
Localization
+
+ {{ reviewData().localization?.timeZone?.label || 'Not completed' }} · + {{ reviewData().localization?.currency?.secondaryLabel || reviewData().localization?.currency?.label || '—' }} +
+
+
+
Plan and limits
+
+ {{ reviewData().planLimits?.subscriptionPlan?.label || 'Not completed' }} + @if (reviewData().planLimits) { + · {{ reviewData().planLimits?.maximumCompanies }} companies · + {{ reviewData().planLimits?.maximumUsers }} users · + {{ reviewData().planLimits?.maximumStorageGb }} GB + } +
+
+
+
Access dates
+
+ {{ reviewData().planLimits?.systemAccessStartDate || 'Not completed' }} – + {{ reviewData().planLimits?.systemAccessEndDate || 'No end date' }} +
+
+
+
Administrator
+
+ {{ form.controls.administratorFullName.value || 'Not entered' }} + @if (form.controls.administratorEmail.value) { + · {{ form.controls.administratorEmail.value }} + } +
+
+
+
diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.ts b/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.ts new file mode 100644 index 00000000..941a2f50 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/steps/organization-admin/organization-admin.ts @@ -0,0 +1,101 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + input, + signal, +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { + OnboardingStepForm, + OrganizationAdminValue, + OrganizationOnboardingData, +} from '../../models/organization-onboarding.model'; + +interface OrganizationAdminFormModel { + readonly organizationEmail: string; + readonly organizationPhone: string; + readonly administratorFullName: string; + readonly administratorEmail: string; + readonly administratorMobile: string; +} + +@Component({ + selector: 'app-organization-admin-step', + standalone: true, + imports: [ReactiveFormsModule, FormInput], + templateUrl: './organization-admin.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OrganizationAdminStepComponent implements OnboardingStepForm { + private readonly formBuilder = inject(FormBuilder); + private readonly elementRef = inject>(ElementRef); + + readonly reviewData = input.required(); + readonly submitAttempted = signal(false); + + readonly form = this.formBuilder.nonNullable.group({ + organizationEmail: ['', [Validators.required, Validators.email]], + organizationPhone: ['', [Validators.required]], + administratorFullName: ['', [Validators.required, Validators.maxLength(150)]], + administratorEmail: ['', [Validators.required, Validators.email]], + administratorMobile: ['', [Validators.required]], + }); + + validate(): boolean { + this.submitAttempted.set(true); + if (this.form.valid) { + return true; + } + + this.form.markAllAsTouched(); + this.focusFirstInvalidControl(); + return false; + } + + markAsUntouched(): void { + this.form.markAsUntouched(); + this.submitAttempted.set(false); + } + + getValue(): OrganizationAdminValue { + const value = this.form.getRawValue() as OrganizationAdminFormModel; + return this.normalize(value); + } + + getDraftValue(): Partial { + return this.normalize(this.form.getRawValue() as OrganizationAdminFormModel); + } + + patchValue(value: Partial & Record): void { + this.form.patchValue({ + organizationEmail: value.organizationEmail ?? value['OrgEmail'] ?? value['orgEmail'] ?? '', + organizationPhone: value.organizationPhone ?? value['OrgPhone'] ?? value['orgPhone'] ?? '', + administratorFullName: value.administratorFullName ?? value['AdminFullName'] ?? value['adminFullName'] ?? '', + administratorEmail: value.administratorEmail ?? value['AdminEmail'] ?? value['adminEmail'] ?? '', + administratorMobile: value.administratorMobile ?? value['AdminMobile'] ?? value['adminMobile'] ?? '', + }, { emitEvent: false }); + this.submitAttempted.set(false); + } + + private normalize(value: OrganizationAdminFormModel): OrganizationAdminValue { + return { + organizationEmail: value.organizationEmail.trim(), + organizationPhone: value.organizationPhone.trim(), + administratorFullName: value.administratorFullName.trim(), + administratorEmail: value.administratorEmail.trim(), + administratorMobile: value.administratorMobile.trim(), + }; + } + + private focusFirstInvalidControl(): void { + queueMicrotask(() => { + this.elementRef.nativeElement + .querySelector('[data-form-control].ng-invalid, .ng-invalid [data-form-control]') + ?.focus(); + }); + } +} diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.html b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.html index 61a039c5..dbae1c84 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.html +++ b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.html @@ -71,6 +71,8 @@ variant="floating" label="Organization Type" placeholder="Search" + [minSearchLength]="0" + [showDropdownOnFocus]="true" [searchFn]="searchOrganizationTypes" [displayWith]="displayLookup" [valueWith]="lookupValue" @@ -93,6 +95,8 @@ variant="floating" label="Industry" placeholder="Search" + [minSearchLength]="0" + [showDropdownOnFocus]="true" [searchFn]="searchIndustries" [displayWith]="displayLookup" [valueWith]="lookupValue" @@ -115,6 +119,8 @@ variant="floating" label="Country of Registration" placeholder="Search" + [minSearchLength]="0" + [showDropdownOnFocus]="true" [searchFn]="searchCountries" [displayWith]="displayCountry" [valueWith]="countryValue" diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts index 7d37ead3..cfd246c0 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts +++ b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts @@ -5,7 +5,11 @@ import { catchError, map, of } from 'rxjs'; import { CountryLookupDto, + CountryService, } from '../../../../global-masters/countries/public-api'; +import { + IndustryApiService, +} from '../../../../industries/public-api'; import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { AutocompleteDisplayFn, @@ -19,7 +23,7 @@ import { OnboardingStepForm, OrganizationBasicsValue, } from '../../models/organization-onboarding.model'; -import { OrganizationOnboardingService } from '../../services/organization-onboarding.service'; +import { OrganizationOnboardingService } from '../../data-access/services/organization-onboarding.service'; interface OrganizationBasicsFormModel { readonly organizationName: string; @@ -40,6 +44,8 @@ interface OrganizationBasicsFormModel { export class OrganizationBasicsStepComponent implements OnboardingStepForm { private readonly formBuilder = inject(FormBuilder); private readonly onboardingService = inject(OrganizationOnboardingService); + private readonly countryService = inject(CountryService); + private readonly industryApiService = inject(IndustryApiService); private readonly toastr = inject(ToastrService); private readonly elementRef = inject>(ElementRef); @@ -67,7 +73,12 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm = (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(() => { this.toastr.error('Unable to load industries.'); return of([]); @@ -75,7 +86,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm = (term, limit) => - this.onboardingService.searchRegistrationCountries(term, limit).pipe( + this.countryService.autocomplete(term ?? '', limit).pipe( catchError(() => { this.toastr.error('Unable to load countries.'); return of([]); @@ -90,15 +101,29 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm = value => this.onboardingService.resolveOrganizationType(value); - readonly resolveIndustry: AutocompleteResolveValueFn = value => - this.onboardingService.resolveIndustry(value); + readonly resolveIndustry: AutocompleteResolveValueFn = 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 = country => country.name; readonly countryValue: AutocompleteValueFn = country => country.id; - readonly resolveCountry: AutocompleteResolveValueFn = value => - this.onboardingService.resolveCountry(value); + readonly resolveCountry: AutocompleteResolveValueFn = 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 { this.submitAttempted.set(true); @@ -112,6 +137,11 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm
+
+ +
- +
+ +
+ +
+ +
+ +
+ +
- \ No newline at end of file + diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts index cf356394..c39e58b9 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts +++ b/src/app/features/organizations/organization-onboarding/steps/organization-localization/organization-localization.ts @@ -1,26 +1,29 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { catchError, of } from 'rxjs'; +import { catchError, map, of } from 'rxjs'; 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 { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { FormSelect } from '../../../../../shared/components/form/form-select/form-select'; import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn, } from '../../../../../shared/components/form/autocomplete/autocomplete.types'; -import { FormSelect } from '../../../../../shared/components/form/form-select/form-select'; import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models'; import { CountryLocalizationDefaults, @@ -31,8 +34,10 @@ import { OnboardingStepForm, OrganizationLocalizationValue, TimeFormatValue, + mapApiToNumberFormat, + mapMmDdToFiscalYear, } from '../../models/organization-onboarding.model'; -import { OrganizationOnboardingService } from '../../services/organization-onboarding.service'; +import { OrganizationOnboardingService } from '../../data-access/services/organization-onboarding.service'; interface OrganizationLocalizationFormModel { readonly timeZoneId: string | null; @@ -45,6 +50,15 @@ interface OrganizationLocalizationFormModel { readonly fiscalYearConvention: FiscalYearConventionValue | null; } +type ThemeSelectValue = + | string + | number + | readonly string[] + | readonly number[] + | string[] + | number[] + | null; + @Component({ selector: 'app-organization-localization-step', standalone: true, @@ -56,6 +70,9 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm private readonly destroyRef = inject(DestroyRef); private readonly formBuilder = inject(FormBuilder); 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 elementRef = inject>(ElementRef); @@ -64,11 +81,18 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm readonly currencySelection = signal(null); readonly defaultLanguageSelection = signal(null); readonly additionalLanguageOptions = signal[]>([]); + readonly additionalLanguageThemeOptions = computed(() => + this.additionalLanguageOptions().map(option => ({ label: option.label, value: option.value })) + ); readonly dateFormatOptions = this.onboardingService.getDateFormatOptions(); readonly timeFormatOptions = this.onboardingService.getTimeFormatOptions(); readonly numberFormatOptions = this.onboardingService.getNumberFormatOptions(); readonly fiscalYearOptions = this.onboardingService.getFiscalYearOptions(); + readonly dateFormatThemeOptions = this.toThemeOptions(this.dateFormatOptions); + readonly timeFormatThemeOptions = this.toThemeOptions(this.timeFormatOptions); + readonly numberFormatThemeOptions = this.toThemeOptions(this.numberFormatOptions); + readonly fiscalYearThemeOptions = this.toThemeOptions(this.fiscalYearOptions); readonly form = this.formBuilder.group({ timeZoneId: this.formBuilder.control(null, [Validators.required]), @@ -82,7 +106,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm }); readonly searchTimezones: AutocompleteSearchFn = (term, limit) => - this.onboardingService.searchTimezones(term, limit).pipe( + this.timezoneService.autocomplete(term ?? '', limit).pipe( catchError(() => { this.toastr.error('Unable to load timezones.'); return of([]); @@ -90,7 +114,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm ); readonly searchCurrencies: AutocompleteSearchFn = (term, limit) => - this.onboardingService.searchCurrencies(term, limit).pipe( + this.currencyService.autocomplete(term ?? '', limit).pipe( catchError(() => { this.toastr.error('Unable to load currencies.'); return of([]); @@ -98,7 +122,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm ); readonly searchLanguages: AutocompleteSearchFn = (term, limit) => - this.onboardingService.searchLanguages(term, limit).pipe( + this.languageService.autocomplete(term ?? '', limit).pipe( catchError(() => { this.toastr.error('Unable to load languages.'); return of([]); @@ -110,37 +134,62 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm readonly timeZoneValue: AutocompleteValueFn = item => item.id; - readonly resolveTimezone: AutocompleteResolveValueFn = value => - this.onboardingService.resolveTimezone(value); + readonly resolveTimezone: AutocompleteResolveValueFn = 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 = item => [item.code, item.name, item.symbol ? `(${item.symbol})` : null].filter(Boolean).join(' '); readonly currencyValue: AutocompleteValueFn = item => item.id; - readonly resolveCurrency: AutocompleteResolveValueFn = value => - this.onboardingService.resolveCurrency(value); + readonly resolveCurrency: AutocompleteResolveValueFn = 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 = item => [item.code, item.name].filter(Boolean).join(' - '); readonly languageValue: AutocompleteValueFn = item => item.id; - readonly resolveLanguage: AutocompleteResolveValueFn = value => - this.onboardingService.resolveLanguage(value); + readonly resolveLanguage: AutocompleteResolveValueFn = 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() { - // this.onboardingService.loadLanguageOptions(100) - // .pipe( - // catchError(() => { - // this.toastr.error('Unable to load language options.'); - // return of[]>([]); - // }), - // takeUntilDestroyed(this.destroyRef) - // ) - // .subscribe(options => { - // this.additionalLanguageOptions.update(current => this.mergeLanguageOptions(current, options)); - // }); + this.languageService.autocomplete('', 100) + .pipe( + map(items => items.map(item => ({ + value: item.id, + label: `${item.code} - ${item.name}`, + }))), + catchError(() => { + this.toastr.error('Unable to load language options.'); + return of[]>([]); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(options => { + this.additionalLanguageOptions.update(current => this.mergeLanguageOptions(current, options)); + }); this.form.controls.defaultLanguageId.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) @@ -169,23 +218,30 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm return false; } + markAsUntouched(): void { + this.form.markAsUntouched(); + this.submitAttempted.set(false); + } + getValue(): OrganizationLocalizationValue { const value = this.form.getRawValue() as OrganizationLocalizationFormModel; const timeZone = this.timeZoneSelection(); const currency = this.currencySelection(); 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.'); } - 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 { timeZone: { id: timeZone.id, label: timeZone.displayName, secondaryLabel: timeZone.ianaId }, currency: { id: currency.id, label: currency.name, secondaryLabel: currency.code }, defaultLanguage: { id: defaultLanguage.id, label: defaultLanguage.name, secondaryLabel: defaultLanguage.code }, - additionalLanguageIds: value.additionalLanguageIds.filter(id => id !== defaultLanguage.id), + additionalLanguageIds: filteredAdditionalLanguageIds, additionalLanguageSelections, dateFormat: value.dateFormat, timeFormat: value.timeFormat, @@ -197,31 +253,33 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm getDraftValue(): Partial { const value = this.form.getRawValue() as OrganizationLocalizationFormModel; + const defaultLanguageId = this.defaultLanguageSelection()?.id; + const filteredAdditionalLanguageIds = (value.additionalLanguageIds ?? []).filter(id => id && id !== defaultLanguageId); return { timeZone: this.timeZoneSelection() ? { - id: this.timeZoneSelection()!.id, - label: this.timeZoneSelection()!.displayName, - secondaryLabel: this.timeZoneSelection()!.ianaId, - } + id: this.timeZoneSelection()!.id, + label: this.timeZoneSelection()!.displayName, + secondaryLabel: this.timeZoneSelection()!.ianaId, + } : undefined, currency: this.currencySelection() ? { - id: this.currencySelection()!.id, - label: this.currencySelection()!.name, - secondaryLabel: this.currencySelection()!.code, - } + id: this.currencySelection()!.id, + label: this.currencySelection()!.name, + secondaryLabel: this.currencySelection()!.code, + } : undefined, defaultLanguage: this.defaultLanguageSelection() ? { - id: this.defaultLanguageSelection()!.id, - label: this.defaultLanguageSelection()!.name, - secondaryLabel: this.defaultLanguageSelection()!.code, - } + id: this.defaultLanguageSelection()!.id, + label: this.defaultLanguageSelection()!.name, + secondaryLabel: this.defaultLanguageSelection()!.code, + } : undefined, - additionalLanguageIds: value.additionalLanguageIds, - additionalLanguageSelections: this.resolveAdditionalLanguageSelections(value.additionalLanguageIds), + additionalLanguageIds: filteredAdditionalLanguageIds, + additionalLanguageSelections: this.resolveAdditionalLanguageSelections(filteredAdditionalLanguageIds), dateFormat: value.dateFormat ?? undefined, timeFormat: value.timeFormat ?? undefined, numberFormat: value.numberFormat ?? undefined, @@ -230,46 +288,60 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm }; } - patchValue(value: Partial): void { + patchValue(value: Partial & Record): 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({ - timeZoneId: value.timeZone?.id ?? null, - currencyId: value.currency?.id ?? null, - defaultLanguageId: value.defaultLanguage?.id ?? null, - additionalLanguageIds: value.additionalLanguageIds ?? [], - dateFormat: value.dateFormat ?? null, - timeFormat: value.timeFormat ?? null, - numberFormat: value.numberFormat ?? null, - fiscalYearConvention: value.fiscalYearConvention ?? null, + timeZoneId: value.timeZone?.id ?? value['DefaultTimezoneId'] ?? value['defaultTimezoneId'] ?? value['timeZoneId'] ?? null, + currencyId: value.currency?.id ?? value['DefaultCurrencyId'] ?? value['defaultCurrencyId'] ?? value['currencyId'] ?? null, + defaultLanguageId: value.defaultLanguage?.id ?? value['DefaultLanguageId'] ?? value['defaultLanguageId'] ?? value['defaultLanguageId'] ?? null, + additionalLanguageIds: value.additionalLanguageIds ?? value['AdditionalLanguageIds'] ?? value['additionalLanguageIds'] ?? [], + dateFormat: value.dateFormat ?? value['DateFormat'] ?? value['dateFormat'] ?? null, + timeFormat: timeFormatNormalized, + numberFormat: numberFormatNormalized, + fiscalYearConvention: fiscalYearNormalized, }, { emitEvent: false }); this.timeZoneSelection.set( value.timeZone ? { - id: value.timeZone.id, - ianaId: value.timeZone.secondaryLabel ?? value.timeZone.label, - displayName: value.timeZone.label, - } + id: value.timeZone.id, + ianaId: value.timeZone.secondaryLabel ?? value.timeZone.label, + displayName: value.timeZone.label, + } : null ); this.currencySelection.set( value.currency ? { - id: value.currency.id, - code: value.currency.secondaryLabel ?? value.currency.label, - name: value.currency.label, - symbol: '', - } + id: value.currency.id, + code: value.currency.secondaryLabel ?? value.currency.label, + name: value.currency.label, + symbol: '', + } : null ); this.defaultLanguageSelection.set( value.defaultLanguage ? { - id: value.defaultLanguage.id, - code: value.defaultLanguage.secondaryLabel ?? value.defaultLanguage.label, - name: value.defaultLanguage.label, - nativeName: value.defaultLanguage.label, - isRightToLeft: false, - } + id: value.defaultLanguage.id, + code: value.defaultLanguage.secondaryLabel ?? value.defaultLanguage.label, + name: value.defaultLanguage.label, + nativeName: value.defaultLanguage.label, + isRightToLeft: false, + } : null ); this.additionalLanguageOptions.update(current => this.mergeSelectionOptions(current, value.additionalLanguageSelections ?? [])); @@ -277,12 +349,13 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm } applyCountryDefaults(defaults: CountryLocalizationDefaults, replaceExisting: boolean): void { + const currentTf = this.form.controls.timeFormat.value; const nextValue = { 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, 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, - 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, fiscalYearConvention: replaceExisting || !this.form.controls.fiscalYearConvention.value ? defaults.fiscalYearConvention : this.form.controls.fiscalYearConvention.value, }; @@ -291,31 +364,31 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm this.timeZoneSelection.set( replaceExisting || !this.timeZoneSelection() ? { - id: defaults.timeZone.id, - ianaId: defaults.timeZone.secondaryLabel ?? defaults.timeZone.label, - displayName: defaults.timeZone.label, - } + id: defaults.timeZone.id, + ianaId: defaults.timeZone.secondaryLabel ?? defaults.timeZone.label, + displayName: defaults.timeZone.label, + } : this.timeZoneSelection() ); this.currencySelection.set( replaceExisting || !this.currencySelection() ? { - id: defaults.currency.id, - code: defaults.currency.secondaryLabel ?? defaults.currency.label, - name: defaults.currency.label, - symbol: '', - } + id: defaults.currency.id, + code: defaults.currency.secondaryLabel ?? defaults.currency.label, + name: defaults.currency.label, + symbol: '', + } : this.currencySelection() ); this.defaultLanguageSelection.set( replaceExisting || !this.defaultLanguageSelection() ? { - id: defaults.defaultLanguage.id, - code: defaults.defaultLanguage.secondaryLabel ?? defaults.defaultLanguage.label, - name: defaults.defaultLanguage.label, - nativeName: defaults.defaultLanguage.label, - isRightToLeft: false, - } + id: defaults.defaultLanguage.id, + code: defaults.defaultLanguage.secondaryLabel ?? defaults.defaultLanguage.label, + name: defaults.defaultLanguage.label, + nativeName: defaults.defaultLanguage.label, + isRightToLeft: false, + } : this.defaultLanguageSelection() ); @@ -334,7 +407,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm value.defaultLanguageId || value.additionalLanguageIds.length || value.dateFormat || - value.timeFormat || + (value.timeFormat !== null && value.timeFormat !== undefined) || value.numberFormat || value.fiscalYearConvention ); @@ -358,12 +431,58 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm onDefaultLanguageSelected(item: LanguageLookupDto): void { 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 { this.defaultLanguageSelection.set(null); } + onAdditionalLanguagesChanged(value: ThemeSelectValue): void { + const selectedIds = Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + const defaultLanguageId = this.form.controls.defaultLanguageId.value; + + this.form.controls.additionalLanguageIds.setValue( + defaultLanguageId ? selectedIds.filter(id => id !== defaultLanguageId) : selectedIds + ); + } + + onDateFormatChanged(value: ThemeSelectValue): void { + this.form.controls.dateFormat.setValue(typeof value === 'string' ? value as DateFormatValue : null); + } + + onTimeFormatChanged(value: ThemeSelectValue): void { + 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 { + this.form.controls.numberFormat.setValue(typeof value === 'string' ? value as NumberFormatValue : null); + } + + onFiscalYearConventionChanged(value: ThemeSelectValue): void { + this.form.controls.fiscalYearConvention.setValue( + typeof value === 'string' ? value as FiscalYearConventionValue : null + ); + } + private resolveAdditionalLanguageSelections(languageIds: readonly string[]): readonly OnboardingLookupValue[] { const uniqueIds = [...new Set(languageIds)]; const optionMap = new Map(this.additionalLanguageOptions().map(option => [option.value, option.label])); @@ -406,4 +525,8 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm invalidElement?.focus(); }); } -} \ No newline at end of file + + private toThemeOptions(options: readonly FormSelectOption[]) { + return options.map(option => ({ label: option.label, value: option.value })); + } +} diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.html b/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.html index 76205c91..04722f34 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.html +++ b/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.html @@ -7,6 +7,8 @@ variant="floating" label="Subscription Plan" placeholder="Search" + [minSearchLength]="0" + [showDropdownOnFocus]="true" [searchFn]="searchPlans" [displayWith]="displayPlan" [valueWith]="planValue" @@ -19,8 +21,23 @@ (cleared)="onPlanCleared()" /> - - +
+ +
@@ -99,8 +117,9 @@ formControlName="systemAccessStartDate" inputId="organization-system-access-start-date" variant="floating" - label="System Access Start Date" + label="System Access From Date" [required]="true" + [variant]="'floating'" [submitAttempted]="submitAttempted()" [validationMessages]="{ required: 'System Access Start Date is required.' }" /> @@ -111,7 +130,7 @@ formControlName="systemAccessEndDate" inputId="organization-system-access-end-date" variant="floating" - label="System Access End Date" + label="System Access To Date" [required]="requiresAccessEndDate()" [submitAttempted]="submitAttempted()" [validationMessages]="{ @@ -135,4 +154,4 @@
} - \ No newline at end of file + diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.ts b/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.ts index 0fb4688f..4084a032 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.ts +++ b/src/app/features/organizations/organization-onboarding/steps/organization-plan-limits/organization-plan-limits.ts @@ -27,8 +27,9 @@ import { OrganizationPlanDefaults, OrganizationPlanLimitsValue, OrganizationPlanLookupValue, + mapApiToLicenseType, } from '../../models/organization-onboarding.model'; -import { OrganizationOnboardingService } from '../../services/organization-onboarding.service'; +import { OrganizationOnboardingService } from '../../data-access/services/organization-onboarding.service'; interface OrganizationPlanLimitsFormModel { readonly subscriptionPlanId: string | null; @@ -41,6 +42,15 @@ interface OrganizationPlanLimitsFormModel { readonly systemAccessEndDate: string | null; } +type ThemeSelectValue = + | string + | number + | readonly string[] + | readonly number[] + | string[] + | number[] + | null; + const accessDateValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { const value = control.value as OrganizationPlanLimitsFormModel; @@ -84,6 +94,10 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm(null); readonly limitsEditable = signal(true); readonly licenseTypeOptions = this.onboardingService.getLicenseTypeOptions(); + readonly licenseTypeThemeOptions = this.licenseTypeOptions.map(option => ({ + label: option.label, + value: option.value, + })); readonly form = this.formBuilder.group({ subscriptionPlanId: this.formBuilder.control(null, [Validators.required]), @@ -164,6 +178,11 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm): void { + patchValue(value: Partial & Record): void { + const rawLicenseType = value.licenseType ?? value['LicenseType']; + const normalizedLicenseType = mapApiToLicenseType(rawLicenseType); + + const planId = value.subscriptionPlan?.id ?? value['PlanId'] ?? value['subscriptionPlanId'] ?? null; + + const subPlan = value.subscriptionPlan ?? (value['SubscriptionPlan'] ? { + id: value['SubscriptionPlan']?.id ?? planId, + label: value['SubscriptionPlan']?.name ?? value['SubscriptionPlan']?.label ?? '', + code: value['SubscriptionPlan']?.code ?? '', + } : planId ? { id: planId, label: '', code: '' } : null); + this.form.patchValue({ - subscriptionPlanId: value.subscriptionPlan?.id ?? null, - licenseType: value.licenseType ?? null, - maximumCompanies: value.maximumCompanies ?? null, - maximumUsers: value.maximumUsers ?? null, - maximumStorageGb: value.maximumStorageGb ?? null, - goLiveDate: value.goLiveDate ?? null, - systemAccessStartDate: value.systemAccessStartDate ?? null, - systemAccessEndDate: value.systemAccessEndDate ?? null, + subscriptionPlanId: planId, + licenseType: normalizedLicenseType, + maximumCompanies: value.maximumCompanies ?? value['MaxCompanies'] ?? null, + maximumUsers: value.maximumUsers ?? value['MaxUsers'] ?? null, + maximumStorageGb: value.maximumStorageGb ?? value['MaxStorageGb'] ?? null, + goLiveDate: value.goLiveDate ?? value['GoLiveDate'] ?? null, + systemAccessStartDate: value.systemAccessStartDate ?? value['SystemAccessFrom'] ?? null, + systemAccessEndDate: value.systemAccessEndDate ?? value['SystemAccessTo'] ?? null, }, { emitEvent: false }); - this.planSelection.set(value.subscriptionPlan ?? null); + this.planSelection.set(subPlan ?? value.subscriptionPlan ?? null); this.limitsEditable.set(value.limitsEditable ?? true); this.applyLimitsEditableState(); this.submitAttempted.set(false); @@ -227,15 +257,29 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm = { licenseType: defaults.licenseType, maximumCompanies: defaults.maximumCompanies, maximumUsers: defaults.maximumUsers, maximumStorageGb: defaults.maximumStorageGb, - }, { emitEvent: false }); + }; + + if (defaults.licenseType === 'Trial' && defaults.defaultTrialDays && !this.form.controls.systemAccessEndDate.value) { + const startDateStr = this.form.controls.systemAccessStartDate.value || new Date().toISOString().split('T')[0]; + const startDate = new Date(startDateStr); + startDate.setDate(startDate.getDate() + defaults.defaultTrialDays); + patch['systemAccessEndDate'] = startDate.toISOString().split('T')[0]; + } + + this.form.patchValue(patch, { emitEvent: false }); this.applyLimitsEditableState(); } @@ -263,4 +307,4 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm import('./dashboard/dashboard').then((m) => m.Dashboard), + data: { childTitle: 'Dashboard', parentTitle: 'Organizations', subParentTitle: 'Global Organization Management' }, + }, + { + path: 'list', loadComponent: () => import('./organization-list/organization-list').then((m) => m.OrganizationList), data: { childTitle: 'Organization Management', parentTitle: 'Organizations', subParentTitle: 'Configuration' }, }, diff --git a/src/app/features/platform/pages/platform-list/platform-list.html b/src/app/features/platform/pages/platform-list/platform-list.html deleted file mode 100644 index 930f8e71..00000000 --- a/src/app/features/platform/pages/platform-list/platform-list.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

Platform

-

Database connections, testing, and tenant assignments will be added here.

-
diff --git a/src/app/features/platform/pages/platform-list/platform-list.scss b/src/app/features/platform/pages/platform-list/platform-list.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/features/platform/pages/platform-list/platform-list.ts b/src/app/features/platform/pages/platform-list/platform-list.ts deleted file mode 100644 index 6c2fb374..00000000 --- a/src/app/features/platform/pages/platform-list/platform-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'app-platform-list', - standalone: true, - imports: [CommonModule], - templateUrl: './platform-list.html', - styleUrl: './platform-list.scss', -}) -export class PlatformList {} diff --git a/src/app/features/platform/platform.routes.ts b/src/app/features/platform/platform.routes.ts deleted file mode 100644 index 28878dc0..00000000 --- a/src/app/features/platform/platform.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Routes } from '@angular/router'; - -export const platformRoutes: Routes = [ - { - path: '', - loadComponent: () => import('./pages/platform-list/platform-list').then((m) => m.PlatformList), - data: { childTitle: 'Platform', parentTitle: 'Platform', subParentTitle: 'Infrastructure' }, - }, -]; diff --git a/src/app/features/settings/pages/branding/branding.html b/src/app/features/settings/pages/branding/branding.html new file mode 100644 index 00000000..69e56b7a --- /dev/null +++ b/src/app/features/settings/pages/branding/branding.html @@ -0,0 +1,226 @@ +
+
+
+ Branding +
+
+ +
+
+ +
+ + +
+ +
+ @if (uploads().orgLogo.file) { +
+ @if (uploads().orgLogo.previewUrl) { + Organization Logo + } @else { + + } +
+ {{ uploads().orgLogo.fileName }} +
+
+ {{ uploads().orgLogo.fileSize }} +
+ +
+ } @else { + + + + + Organization Logo + + (click or drag file here) + } +
+
+ + +
+ +
+ @if (uploads().whiteLogo.file) { +
+ @if (uploads().whiteLogo.previewUrl) { +
+ White Logo +
+ } @else { + + } +
+ {{ uploads().whiteLogo.fileName }} +
+
+ {{ uploads().whiteLogo.fileSize }} +
+ +
+ } @else { + + + + + White Logo (dark bg) + + (click or drag file here) + } +
+
+ + +
+ +
+ @if (uploads().favicon.file) { +
+ @if (uploads().favicon.previewUrl) { + Favicon + } @else { + + } +
+ {{ uploads().favicon.fileName }} +
+
+ {{ uploads().favicon.fileSize }} +
+ +
+ } @else { + + + + + Favicon / Icon + + (click or drag file here) + } +
+
+ + +
+ +
+ @if (uploads().digitalSeal.file) { +
+ @if (uploads().digitalSeal.previewUrl) { + Digital Seal + } @else { + + } +
+ {{ uploads().digitalSeal.fileName }} +
+
+ {{ uploads().digitalSeal.fileSize }} +
+ +
+ } @else { + + + + + Digital Seal & Signature + + (click or drag file here) + } +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ All settings have working defaults — nothing here blocks onboarding. Security tab holds MFA / password policy + / session / IP restriction with platform defaults. +
+ +
+
+
+ + +
\ No newline at end of file diff --git a/src/app/features/localization/pages/localization-list/localization-list.scss b/src/app/features/settings/pages/branding/branding.scss similarity index 100% rename from src/app/features/localization/pages/localization-list/localization-list.scss rename to src/app/features/settings/pages/branding/branding.scss diff --git a/src/app/features/settings/pages/branding/branding.ts b/src/app/features/settings/pages/branding/branding.ts new file mode 100644 index 00000000..6805a70a --- /dev/null +++ b/src/app/features/settings/pages/branding/branding.ts @@ -0,0 +1,147 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Button } from '../../../../shared/components/button/button'; +import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; +import { FormInput } from '../../../../shared/components/form/form-input/form-input'; +import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models'; + +export type BrandingUploadKey = 'orgLogo' | 'whiteLogo' | 'favicon' | 'digitalSeal'; + +export interface UploadFileItem { + file: File | null; + previewUrl: string | null; + fileName: string | null; + fileSize: string | null; + isDragging: boolean; +} + +@Component({ + selector: 'branding', + imports: [ReactiveFormsModule, Button, FormSelect, FormInput], + templateUrl: './branding.html', + styleUrl: './branding.scss', +}) +export class Branding { + private fb = inject(FormBuilder); + private toastr = inject(ToastrService); + + brandingForm: FormGroup = this.fb.group({ + theme: ['default-light'], + brandColor: ['#1F3A93'] + }); + + themeOptions: FormSelectOption[] = [ + { label: 'Default Light', value: 'default-light' }, + { label: 'Dark Mode', value: 'dark' } + ]; + + readonly uploads = signal>({ + orgLogo: { file: null, previewUrl: null, fileName: null, fileSize: null, isDragging: false }, + whiteLogo: { file: null, previewUrl: null, fileName: null, fileSize: null, isDragging: false }, + favicon: { file: null, previewUrl: null, fileName: null, fileSize: null, isDragging: false }, + digitalSeal: { file: null, previewUrl: null, fileName: null, fileSize: null, isDragging: false } + }); + + triggerFileInput(inputElement: HTMLInputElement): void { + inputElement.click(); + } + + onFileSelected(event: Event, key: BrandingUploadKey): void { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.processFile(input.files[0], key); + } + } + + onDragOver(event: DragEvent, key: BrandingUploadKey): void { + event.preventDefault(); + event.stopPropagation(); + this.updateUploadState(key, { isDragging: true }); + } + + onDragLeave(event: DragEvent, key: BrandingUploadKey): void { + event.preventDefault(); + event.stopPropagation(); + this.updateUploadState(key, { isDragging: false }); + } + + onDrop(event: DragEvent, key: BrandingUploadKey): void { + event.preventDefault(); + event.stopPropagation(); + this.updateUploadState(key, { isDragging: false }); + + if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { + this.processFile(event.dataTransfer.files[0], key); + } + } + + removeFile(key: BrandingUploadKey, event?: Event): void { + event?.stopPropagation(); + const current = this.uploads()[key]; + if (current.previewUrl) { + URL.revokeObjectURL(current.previewUrl); + } + this.updateUploadState(key, { + file: null, + previewUrl: null, + fileName: null, + fileSize: null, + isDragging: false + }); + } + + onDiscard(): void { + this.brandingForm.reset({ + theme: 'default-light', + brandColor: '#1F3A93' + }); + (['orgLogo', 'whiteLogo', 'favicon', 'digitalSeal'] as BrandingUploadKey[]).forEach(key => { + this.removeFile(key); + }); + this.toastr.info('Branding changes discarded.'); + } + + onSave(): void { + this.toastr.success('Branding settings saved successfully.'); + } + + private processFile(file: File, key: BrandingUploadKey): void { + const current = this.uploads()[key]; + if (current.previewUrl) { + URL.revokeObjectURL(current.previewUrl); + } + + const fileSizeFormatted = this.formatBytes(file.size); + let previewUrl: string | null = null; + + if (file.type.startsWith('image/')) { + previewUrl = URL.createObjectURL(file); + } + + this.updateUploadState(key, { + file, + previewUrl, + fileName: file.name, + fileSize: fileSizeFormatted, + isDragging: false + }); + } + + private updateUploadState(key: BrandingUploadKey, partial: Partial): void { + this.uploads.update(state => ({ + ...state, + [key]: { ...state[key], ...partial } + })); + } + + private formatBytes(bytes: number, decimals = 1): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } +} + diff --git a/src/app/features/settings/settings.routes.ts b/src/app/features/settings/settings.routes.ts new file mode 100644 index 00000000..8e80f1b4 --- /dev/null +++ b/src/app/features/settings/settings.routes.ts @@ -0,0 +1,9 @@ +import { Routes } from '@angular/router'; + +export const settingsRoutes: Routes = [ + { + path: 'branding', + loadComponent: () => import('./pages/branding/branding').then((m) => m.Branding), + data: { childTitle: 'Branding Management', parentTitle: 'Settings', subParentTitle: 'Configuration' }, + }, +]; diff --git a/src/app/features/tenants/components/tenant-form-modal/tenant-form-modal.html b/src/app/features/tenants/components/tenant-form-modal/tenant-form-modal.html new file mode 100644 index 00000000..f0686494 --- /dev/null +++ b/src/app/features/tenants/components/tenant-form-modal/tenant-form-modal.html @@ -0,0 +1,152 @@ + + @if (modalLoading()) { +
+ + Loading tenant... +
+ } @else { +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ @if (mode() === 'edit') { +
+ +
+ } +
+
+ } +
diff --git a/src/app/features/tenants/components/tenant-form-modal/tenant-form-modal.ts b/src/app/features/tenants/components/tenant-form-modal/tenant-form-modal.ts new file mode 100644 index 00000000..a3e81bf2 --- /dev/null +++ b/src/app/features/tenants/components/tenant-form-modal/tenant-form-modal.ts @@ -0,0 +1,246 @@ +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 } from 'rxjs/operators'; + +import { + CreateTenantRequest, + TenantDto, + TenantModalMode, + TenantStatus, + UpdateTenantRequest +} from '../../models/tenant.model'; +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 { TenantService } from '../../data-access/tenant.service'; +import { FormInput } from '../../../../shared/components/form/form-input/form-input'; +import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; +import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models'; +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-tenant-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete], + templateUrl: './tenant-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TenantFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly tenantApi = inject(TenantService); + private readonly languageApi = inject(LanguageService); + private readonly currencyApi = inject(CurrencyService); + private readonly timezoneApi = inject(TimezoneService); + private readonly toastr = inject(ToastrService); + + readonly open = input(false); + readonly mode = input('create'); + readonly tenantId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly tenantSubmitAttempted = signal(false); + readonly selectedTenant = signal(null); + readonly selectedLanguage = signal(null); + readonly selectedCurrency = signal(null); + readonly selectedTimezone = signal(null); + + readonly tenantForm = this.formBuilder.group({ + code: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(50)]), + name: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]), + status: this.formBuilder.control(TenantStatus.Trial, [Validators.required]), + defaultLanguageId: this.formBuilder.control(null, [Validators.required]), + defaultCurrencyId: this.formBuilder.control(null, [Validators.required]), + defaultTimezoneId: this.formBuilder.control(null, [Validators.required]), + dataRegion: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(100)]), + isActive: this.formBuilder.control(1, [Validators.required]) + }); + + readonly tenantStatusOptions = signal[]>([ + { value: TenantStatus.Trial, label: 'Trial' }, + { value: TenantStatus.Active, label: 'Active' }, + { value: TenantStatus.Suspended, label: 'Suspended' }, + { value: TenantStatus.Cancelled, label: 'Cancelled' } + ]); + + readonly activeStatusOptions = signal[]>([ + { value: 1, label: 'Active' }, + { value: 0, label: 'Inactive' } + ]); + + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add Tenant'; + case 'edit': return 'Edit Tenant'; + case 'view': return 'View Tenant'; + } + }); + + readonly searchLanguages: AutocompleteSearchFn = (term, limit) => + this.languageApi.autocomplete(term, limit).pipe(catchError(() => of([]))); + readonly languageDisplay: AutocompleteDisplayFn = lang => lang.name; + readonly languageValue: AutocompleteValueFn = lang => lang.id; + + readonly searchCurrencies: AutocompleteSearchFn = (term, limit) => + this.currencyApi.autocomplete(term, limit).pipe(catchError(() => of([]))); + readonly currencyDisplay: AutocompleteDisplayFn = curr => `${curr.code} - ${curr.name}`; + readonly currencyValue: AutocompleteValueFn = curr => curr.id; + + readonly searchTimezones: AutocompleteSearchFn = (term, limit) => + this.timezoneApi.autocomplete(term, limit).pipe(catchError(() => of([]))); + readonly timezoneDisplay: AutocompleteDisplayFn = tz => tz.displayName; + readonly timezoneValue: AutocompleteValueFn = tz => tz.id; + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.tenantId()); + } + }); + } + + prepareModal(id: string | null): void { + this.tenantSubmitAttempted.set(false); + this.tenantForm.reset({ + code: '', name: '', status: TenantStatus.Trial, defaultLanguageId: null, + defaultCurrencyId: null, defaultTimezoneId: null, dataRegion: '', isActive: 1 + }); + this.selectedLanguage.set(null); + this.selectedCurrency.set(null); + this.selectedTimezone.set(null); + + if (!id || this.mode() === 'create') { + this.selectedTenant.set(null); + this.modalLoading.set(false); + return; + } + + this.modalLoading.set(true); + this.tenantApi.getTenantById(id).pipe( + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: tenant => { + this.selectedTenant.set(tenant); + this.tenantForm.patchValue({ + code: tenant.code, + name: tenant.name, + status: tenant.status, + defaultLanguageId: tenant.defaultLanguageId, + defaultCurrencyId: tenant.defaultCurrencyId, + defaultTimezoneId: tenant.defaultTimezoneId, + dataRegion: tenant.dataRegion, + isActive: tenant.isActive ? 1 : 0 + }); + }, + error: () => { + this.toastr.error('Unable to load tenant details.'); + this.closeModal(); + } + }); + } + + saveTenant(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.tenantSubmitAttempted.set(true); + if (this.tenantForm.invalid || this.saving()) return; + + const val = this.tenantForm.getRawValue(); + this.saving.set(true); + + if (this.mode() === 'create') { + const request: CreateTenantRequest = { + code: val.code.trim(), + name: val.name.trim(), + status: val.status ?? TenantStatus.Trial, + defaultLanguageId: val.defaultLanguageId!, + defaultCurrencyId: val.defaultCurrencyId!, + defaultTimezoneId: val.defaultTimezoneId!, + dataRegion: val.dataRegion.trim() + }; + + this.tenantApi.createTenant(request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Tenant created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.tenantId(); + if (!id) return; + + const request: UpdateTenantRequest = { + code: val.code.trim(), + name: val.name.trim(), + status: val.status ?? TenantStatus.Trial, + defaultLanguageId: val.defaultLanguageId!, + defaultCurrencyId: val.defaultCurrencyId!, + defaultTimezoneId: val.defaultTimezoneId!, + defaultDbConnectionId: this.selectedTenant()?.defaultDbConnectionId ?? null, + dataRegion: val.dataRegion.trim(), + isActive: val.isActive === 1 + }; + + this.tenantApi.updateTenant(id, request).pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('Tenant 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 tenant with this code already exists.'); + return; + } + this.toastr.error(`Unable to ${action} tenant. Please try again.`); + } +} diff --git a/src/app/features/tenants/models/tenant.model.ts b/src/app/features/tenants/models/tenant.model.ts index b0071ef5..ec2f506c 100644 --- a/src/app/features/tenants/models/tenant.model.ts +++ b/src/app/features/tenants/models/tenant.model.ts @@ -49,7 +49,6 @@ export interface UpdateTenantRequest { isActive: boolean; } - export interface TenantTableRow extends DataTableRecord { readonly id: string; readonly code: string; @@ -58,6 +57,10 @@ export interface TenantTableRow extends DataTableRecord { readonly dataRegion: string; readonly isActive: boolean; readonly serialNumber: number; + readonly defaultLanguageId: string; + readonly defaultCurrencyId: string; + readonly defaultTimezoneId: string; + readonly defaultDbConnectionId: string | null; readonly createdOn?: string; readonly modifiedOn?: string | null; readonly defaultLanguageName: string | null; @@ -65,4 +68,4 @@ export interface TenantTableRow extends DataTableRecord { readonly defaultTimezoneName: string | null; } -export type TenantModalMode = 'create' | 'edit'; +export type TenantModalMode = 'create' | 'edit' | 'view'; diff --git a/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts b/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts index 632985a6..1205b463 100644 --- a/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts +++ b/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts @@ -1,31 +1,26 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs'; +import { Subject, catchError, map, of, switchMap } from 'rxjs'; import { TenantCurrenciesService } from '../../data-access/tenant-currencies.service'; -import { LanguageLookupDto, LanguageService } from '../../../global-masters/languages/public-api'; +import { LanguageService } from '../../../global-masters/languages/public-api'; import { CurrencyLookupDto, CurrencyService } from '../../../global-masters/currencies/public-api'; import { TimezoneService } from '../../../global-masters/timezones/public-api'; import { DataTable } from '../../../../shared/components/data-table/data-table'; import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; -import { CreateTenantCurrencyRequest, TenantCurrencyDto, TenantCurrencyModalMode, TenantCurrencyTableRow, UpdateTenantCurrencyRequest } from '../../models/tenant-currencies.model'; +import { TenantCurrencyModalMode, TenantCurrencyTableRow } from '../../models/tenant-currencies.model'; import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; import { TenantLookupDto } from '../../models/tenant.model'; import { TenantService } from '../../data-access/tenant.service'; import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete'; -import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; -import { FormInput } from '../../../../shared/components/form/form-input/form-input'; -import { Modal } from '../../../../shared/components/modal/modal'; -import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; import { FilterCard } from '../../../../shared/components/filter-card/filter-card'; @Component({ selector: 'tenant-currencies', - imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete, ConfirmDialog, FilterCard], + imports: [DataTable, ReactiveFormsModule, Autocomplete, FilterCard], templateUrl: './tenant-currencies.html', styleUrl: './tenant-currencies.scss', }) diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.html b/src/app/features/tenants/pages/tenant-list/tenant-list.html index 1bcebce6..4232f9d5 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.html +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.html @@ -1,192 +1,28 @@ - -
-
-
- -
- -
- -
- -
- -
- - -
- -
- -
- -
- -
- -
- -
- -
-
-
-
+ diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.ts b/src/app/features/tenants/pages/tenant-list/tenant-list.ts index dd39dfca..2d372607 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.ts +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.ts @@ -1,576 +1,161 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs'; +import { finalize } from 'rxjs/operators'; -import { CreateTenantRequest, TenantDto, TenantModalMode, TenantStatus, TenantTableRow, UpdateTenantRequest } from '../../models/tenant.model'; -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 { TenantDto, TenantStatus, TenantTableRow, UpdateTenantRequest } from '../../models/tenant.model'; import { TenantService } from '../../data-access/tenant.service'; import { DataTable } from '../../../../shared/components/data-table/data-table'; -import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; -import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableRecord, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; -import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete'; -import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; -import { FormInput } from '../../../../shared/components/form/form-input/form-input'; -import { FormSelect } from '../../../../shared/components/form/form-select/form-select'; -import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models'; -import { Modal } from '../../../../shared/components/modal/modal'; - - +import { DataTableStore } from '../../../../shared/components/data-table/data-table.store'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn +} from '../../../../shared/components/data-table/data-table.types'; +import { TenantFormModalComponent } from '../../components/tenant-form-modal/tenant-form-modal'; @Component({ - selector: 'tenant-list', - standalone: true, - imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete], - templateUrl: './tenant-list.html', - styleUrl: './tenant-list.scss' + selector: 'tenant-list', + standalone: true, + imports: [DataTable, TenantFormModalComponent], + providers: [DataTableStore], + templateUrl: './tenant-list.html', + styleUrl: './tenant-list.scss' }) -export class TenantList { - private readonly destroyRef = inject(DestroyRef); - private readonly tenantApi = inject(TenantService); - private readonly languageApi = inject(LanguageService); - private readonly currencyApi = inject(CurrencyService); - private readonly timezoneApi = inject(TimezoneService); - private readonly formBuilder = inject(FormBuilder); - private readonly elementRef = inject>(ElementRef); - private readonly toastr = inject(ToastrService); - private readonly queryRequests$ = new Subject(); +export class TenantList implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly tenantApi = inject(TenantService); + private readonly toastr = inject(ToastrService); + readonly tableStore = inject(DataTableStore); - readonly queryState = new DataTableQueryState(); - readonly tenants = signal([]); - readonly totalRecords = signal(0); - readonly filteredRecords = signal(0); - readonly saving = signal(false); - readonly showTenantModal = signal(false); - readonly tenantModalMode = signal('create'); - readonly selectedTenantId = signal(null); - readonly selectedTenant = signal(null); - readonly tenantSubmitAttempted = signal(false); + readonly statusChangingId = signal(null); - readonly tenantForm = this.formBuilder.group({ - code: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(50)]), - name: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]), - status: this.formBuilder.control(TenantStatus.Trial, [Validators.required]), - defaultLanguageId: this.formBuilder.control(null, [Validators.required]), - defaultCurrencyId: this.formBuilder.control(null, [Validators.required]), - defaultTimezoneId: this.formBuilder.control(null, [Validators.required]), - dataRegion: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(100)]), - isActive: this.formBuilder.control(1, [Validators.required]) + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, + { key: 'code', label: 'Tenant Code', header: 'Tenant Code', sortable: true }, + { key: 'name', label: 'Tenant Name', header: 'Tenant Name', sortable: true, align: 'left' }, + { + key: 'status', label: 'Status', header: 'Status', sortable: true, badge: true, + badgeClass: value => this.getTenantStatusBadgeClass(value as TenantStatus), + formatter: value => this.formatTenantStatus(value as TenantStatus) + }, + { key: 'dataRegion', label: 'Data Region', header: 'Data Region', sortable: true }, + { + key: 'isActive', label: 'Active', header: 'Active', sortable: true, badge: true, + badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', + formatter: value => value ? 'Active' : 'Inactive' + } + ]); + + readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, + { + type: 'deactivate', label: 'Deactivate', icon: 'ti ti-power', className: 'text-danger', + visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id + }, + { + type: 'activate', label: 'Activate', icon: 'ti ti-check', className: 'text-success', + visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id + } + ]); + + ngOnInit(): void { + this.tableStore.initialize({ + fetcher: query => this.tenantApi.getTenantDataTable(query), + mapRow: (tenant, serialNumber) => ({ + id: tenant.id, + code: tenant.code, + name: tenant.name, + status: tenant.status, + dataRegion: tenant.dataRegion, + isActive: tenant.isActive, + serialNumber, + defaultLanguageId: tenant.defaultLanguageId, + defaultCurrencyId: tenant.defaultCurrencyId, + defaultTimezoneId: tenant.defaultTimezoneId, + defaultDbConnectionId: tenant.defaultDbConnectionId, + createdOn: tenant.createdOn, + modifiedOn: tenant.modifiedOn, + defaultLanguageName: tenant.defaultLanguageName, + defaultCurrencyName: tenant.defaultCurrencyName, + defaultTimezoneName: tenant.defaultTimezoneName + }) }); + } - readonly tenantStatusOptions = signal[]>([ - { value: TenantStatus.Trial, label: 'Trial' }, - { value: TenantStatus.Active, label: 'Active' }, - { value: TenantStatus.Suspended, label: 'Suspended' }, - { value: TenantStatus.Cancelled, label: 'Cancelled' } - ]); + onAddTenant(): void { + this.tableStore.openCreateModal(); + } - readonly activeStatusOptions = signal[]>([ - { value: 1, label: 'Active' }, - { value: 0, label: 'Inactive' } - ]); + onActionClick(event: DataTableActionEvent): void { + const tenantDto: TenantDto = { + id: event.row.id, + code: event.row.code, + name: event.row.name, + status: event.row.status, + defaultLanguageId: event.row.defaultLanguageId, + defaultLanguageName: event.row.defaultLanguageName, + defaultDbConnectionId: event.row.defaultDbConnectionId, + defaultDbConnectionName: null, + defaultCurrencyId: event.row.defaultCurrencyId, + defaultCurrencyName: event.row.defaultCurrencyName, + defaultTimezoneId: event.row.defaultTimezoneId, + defaultTimezoneName: event.row.defaultTimezoneName, + dataRegion: event.row.dataRegion, + isActive: event.row.isActive, + createdOn: event.row.createdOn, + modifiedOn: event.row.modifiedOn + }; - readonly searchLanguages: AutocompleteSearchFn = (term, limit) => - this.languageApi.autocomplete(term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load languages.'); - return of([]); - }) - ); + if (event.action.type === 'view') this.tableStore.openViewModal(tenantDto); + if (event.action.type === 'edit') this.tableStore.openEditModal(tenantDto); + if (event.action.type === 'deactivate') this.changeTenantStatus(event.row, false); + if (event.action.type === 'activate') this.changeTenantStatus(event.row, true); + } - readonly searchCurrencies: AutocompleteSearchFn = (term, limit) => - this.currencyApi.autocomplete(term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load currencies.'); - return of([]); - }) - ); + private changeTenantStatus(tenant: TenantTableRow, activate: boolean): void { + this.statusChangingId.set(tenant.id); + const request: UpdateTenantRequest = { + code: tenant.code, + name: tenant.name, + status: tenant.status, + defaultLanguageId: tenant.defaultLanguageId, + defaultCurrencyId: tenant.defaultCurrencyId, + defaultTimezoneId: tenant.defaultTimezoneId, + defaultDbConnectionId: tenant.defaultDbConnectionId, + dataRegion: tenant.dataRegion, + isActive: activate + }; - readonly searchTimezones: AutocompleteSearchFn = (term, limit) => - this.timezoneApi.autocomplete(term, limit).pipe( - catchError(() => { - this.toastr.error('Unable to load timezones.'); - return of([]); - }) - ); + this.tenantApi.updateTenant(tenant.id, request).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.toastr.success(`Tenant ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: () => this.toastr.error(`Unable to ${activate ? 'activate' : 'deactivate'} tenant.`) + }); + } - readonly displayLanguage: AutocompleteDisplayFn = language => [language.code, language.name].filter(Boolean).join(' - '); - - readonly languageValue: AutocompleteValueFn = language => language.id; - - readonly resolveLanguage: AutocompleteResolveValueFn = value => - this.languageApi.getById(value).pipe( - map(language => ({ - id: language.id, - code: language.code, - name: language.name, - nativeName: language.nativeName, - isRightToLeft: language.isRightToLeft - })) - ); - - readonly displayCurrency: AutocompleteDisplayFn = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : ''] - .filter(Boolean) - .join(' '); - - readonly currencyValue: AutocompleteValueFn = currency => currency.id; - - readonly resolveCurrency: AutocompleteResolveValueFn = value => - this.currencyApi.getCurrencyById(value).pipe( - map(currency => ({ - id: currency.id, - code: currency.code, - name: currency.name, - symbol: currency.symbol - })) - ); - - readonly displayTimezone: AutocompleteDisplayFn = timezone => [timezone.ianaId, timezone.displayName].filter(Boolean).join(' — '); - - readonly timezoneValue: AutocompleteValueFn = timezone => timezone.id; - - readonly resolveTimezone: AutocompleteResolveValueFn = value => - this.timezoneApi.getById(value).pipe( - map(timezone => ({ - id: timezone.id, - ianaId: timezone.ianaId, - displayName: timezone.displayName - })) - ); - - readonly modalTitle = computed(() => - this.tenantModalMode() === 'create' ? 'Add Tenant' : 'Edit Tenant' - ); - - readonly submitLabel = computed(() => - this.tenantModalMode() === 'create' ? 'Save' : 'Update' - ); - - readonly loadingLabel = computed(() => - this.tenantModalMode() === 'create' ? 'Saving...' : 'Updating...' - ); - - readonly submitAction = computed<'save' | 'update'>(() => - this.tenantModalMode() === 'create' ? 'save' : 'update' - ); - - readonly columns = signal[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, - { key: 'code', label: 'Code', header: 'Code', sortable: true, align: 'left' }, - { key: 'name', label: 'Tenant Name', header: 'Tenant Name', sortable: true, align: 'left' }, - { - key: 'status', - label: 'Status', - header: 'Status', - sortable: true, - badge: true, - badgeClass: value => this.getTenantStatusBadgeClass(value as TenantStatus), - formatter: value => this.formatTenantStatus(value as TenantStatus) - }, - { key: 'dataRegion', label: 'Data Region', header: 'Data Region', sortable: true, align: 'left' }, - { - key: 'isActive', - label: 'Active', - header: 'Active', - sortable: true, - badge: true, - badgeClass: value => value === true - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger', - formatter: value => value === true ? 'Active' : 'Inactive' - }, - { - key: 'createdOn', - label: 'Created On', - header: 'Created On', - sortable: true, - formatter: value => this.formatDateTime(value) - } - ]); - - readonly actions = signal[]>([ - { - type: 'edit', - label: 'Edit', - icon: 'ti ti-edit', - className: 'text-primary' - }, - { - type: 'delete', - label: 'Delete', - icon: 'ti ti-trash', - className: 'text-danger', - visible: row => row.isActive - }, - { - type: 'activate', - label: 'Activate', - icon: 'ti ti-check', - className: 'text-success', - visible: row => !row.isActive - } - ]); - - constructor() { - this.queryRequests$ - .pipe( - switchMap(query => - this.tenantApi.getTenantDataTable(this.buildTenantQuery(query)).pipe( - catchError(() => { - this.toastr.error('Unable to load tenants.'); - this.clearTenantGrid(); - return of(null); - }) - ) - ), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(response => { - if (!response) { - return; - } - - const query = this.queryState.getQuery(); - - if (response.draw !== query.draw) { - return; - } - - const tenantsWithSerialNumbers: TenantTableRow[] = response.rows.map((tenant, index) => ({ - ...tenant, - serialNumber: (query.page - 1) * query.pageSize + index + 1 - })); - - this.tenants.set(tenantsWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }); + private formatTenantStatus(status: TenantStatus): string { + switch (status) { + case TenantStatus.Trial: return 'Trial'; + case TenantStatus.Active: return 'Active'; + case TenantStatus.Suspended: return 'Suspended'; + case TenantStatus.Cancelled: return 'Cancelled'; + default: return 'Unknown'; } + } - ngOnInit(): void { - this.loadTenants(this.queryState.getQuery()); - } - - loadTenants(query: DataTableQuery): void { - this.queryRequests$.next(query); - } - - onSearch(value: string): void { - this.loadTenants(this.queryState.setSearch(value.trim())); - } - - onPageChange(event: DataTablePageEvent): void { - this.loadTenants(this.queryState.setPage(event)); - } - - onSortChange(event: DataTableSortEvent): void { - this.loadTenants(this.queryState.setSort(event)); - } - - onActionClick(event: DataTableActionEvent): void { - if (event.action.type === 'edit') { - this.openEditTenant(event.row.id); - } - } - - onAddTenant(): void { - this.tenantModalMode.set('create'); - this.selectedTenantId.set(null); - this.selectedTenant.set(null); - this.tenantSubmitAttempted.set(false); - this.resetTenantForm({ - code: '', - name: '', - status: TenantStatus.Trial, - defaultLanguageId: null, - defaultCurrencyId: null, - defaultTimezoneId: null, - dataRegion: '', - isActive: 1 - }); - this.showTenantModal.set(true); - } - - closeTenantModal(): void { - if (this.saving()) { - return; - } - - this.showTenantModal.set(false); - this.selectedTenantId.set(null); - this.selectedTenant.set(null); - this.tenantSubmitAttempted.set(false); - this.resetTenantForm({ - code: '', - name: '', - status: TenantStatus.Trial, - defaultLanguageId: null, - defaultCurrencyId: null, - defaultTimezoneId: null, - dataRegion: '', - isActive: 1 - }); - } - - saveTenant(): void { - if (this.tenantForm.invalid) { - this.tenantSubmitAttempted.set(true); - this.tenantForm.markAllAsTouched(); - this.focusFirstInvalidControl(); - return; - } - - if (this.saving()) { - return; - } - - this.saving.set(true); - - if (this.tenantModalMode() === 'create') { - this.tenantApi - .createTenant(this.buildCreateTenantRequest()) - .pipe( - finalize(() => this.saving.set(false)), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: () => { - this.toastr.success('Tenant saved successfully.'); - this.finishTenantSave(); - }, - error: (error: HttpErrorResponse) => this.handleSaveError(error) - }); - - return; - } - - const tenantId = this.selectedTenantId(); - - if (!tenantId) { - this.saving.set(false); - return; - } - - this.tenantApi - .updateTenant(tenantId, this.buildUpdateTenantRequest()) - .pipe( - finalize(() => this.saving.set(false)), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: () => { - this.toastr.success('Tenant updated successfully.'); - this.finishTenantSave(); - }, - error: (error: HttpErrorResponse) => this.handleSaveError(error) - }); - } - - openEditTenant(id: string): void { - this.tenantModalMode.set('edit'); - this.selectedTenantId.set(id); - this.selectedTenant.set(null); - this.tenantSubmitAttempted.set(false); - this.resetTenantForm({ - code: '', - name: '', - status: TenantStatus.Trial, - defaultLanguageId: null, - defaultCurrencyId: null, - defaultTimezoneId: null, - dataRegion: '', - isActive: 1 - }); - - this.tenantApi - .getTenantById(id) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: tenant => { - if (this.selectedTenantId() !== tenant.id) { - return; - } - - this.selectedTenant.set(tenant); - this.resetTenantForm({ - code: tenant.code ?? '', - name: tenant.name ?? '', - status: tenant.status ?? TenantStatus.Trial, - defaultLanguageId: tenant.defaultLanguageId ?? null, - defaultCurrencyId: tenant.defaultCurrencyId ?? null, - defaultTimezoneId: tenant.defaultTimezoneId ?? null, - dataRegion: tenant.dataRegion ?? '', - isActive: tenant.isActive ? 1 : 0 - }); - this.showTenantModal.set(true); - }, - error: (error: HttpErrorResponse) => { - if (error.status === 404) { - this.toastr.error('The tenant is no longer available.'); - } - - this.selectedTenantId.set(null); - } - }); - } - - private buildTenantQuery(query: DataTableQuery): DataTableQuery { - return { - ...query, - sortBy: this.resolveSortField(query.sortBy ?? null) - }; - } - - private resolveSortField(sortBy: string | null | undefined): string | null { - if (!sortBy) { - return null; - } - - switch (sortBy) { - case 'code': - case 'name': - case 'status': - case 'dataRegion': - case 'isActive': - case 'createdOn': - return sortBy; - default: - return null; - } - } - - private buildCreateTenantRequest(): CreateTenantRequest { - const value = this.tenantForm.getRawValue(); - - return { - code: value.code.trim(), - name: value.name.trim(), - status: value.status ?? TenantStatus.Trial, - defaultLanguageId: value.defaultLanguageId ?? '', - defaultCurrencyId: value.defaultCurrencyId ?? '', - defaultTimezoneId: value.defaultTimezoneId ?? '', - dataRegion: value.dataRegion.trim() - }; - } - - private buildUpdateTenantRequest(): UpdateTenantRequest { - const value = this.tenantForm.getRawValue(); - - return { - code: value.code.trim(), - name: value.name.trim(), - status: value.status ?? TenantStatus.Trial, - defaultLanguageId: value.defaultLanguageId ?? '', - defaultCurrencyId: value.defaultCurrencyId ?? '', - defaultTimezoneId: value.defaultTimezoneId ?? '', - defaultDbConnectionId: this.selectedTenant()?.defaultDbConnectionId ?? null, - dataRegion: value.dataRegion.trim(), - isActive: value.isActive === 1 - }; - } - - private finishTenantSave(): void { - this.showTenantModal.set(false); - this.selectedTenantId.set(null); - this.selectedTenant.set(null); - this.tenantSubmitAttempted.set(false); - this.resetTenantForm({ - code: '', - name: '', - status: TenantStatus.Trial, - defaultLanguageId: null, - defaultCurrencyId: null, - defaultTimezoneId: null, - dataRegion: '', - isActive: 1 - }); - this.loadTenants(this.queryState.getQuery()); - } - - private resetTenantForm(value: { - code: string; - name: string; - status: TenantStatus; - defaultLanguageId: string | null; - defaultCurrencyId: string | null; - defaultTimezoneId: string | null; - dataRegion: string; - isActive: number | null; - }): void { - this.tenantForm.reset(value); - this.tenantForm.markAsPristine(); - this.tenantForm.markAsUntouched(); - this.tenantForm.updateValueAndValidity(); - } - - private formatTenantStatus(status: TenantStatus): string { - switch (status) { - case TenantStatus.Trial: - return 'Trial'; - case TenantStatus.Active: - return 'Active'; - case TenantStatus.Suspended: - return 'Suspended'; - case TenantStatus.Cancelled: - return 'Cancelled'; - default: - return 'Unknown'; - } - } - - private getTenantStatusBadgeClass(status: TenantStatus): string { - switch (status) { - case TenantStatus.Trial: - return 'badge bg-warning/10 text-warning'; - case TenantStatus.Active: - return 'badge bg-success/10 text-success'; - case TenantStatus.Suspended: - return 'badge bg-info/10 text-info'; - case TenantStatus.Cancelled: - return 'badge bg-danger/10 text-danger'; - default: - return 'badge bg-secondary/10 text-secondary'; - } - } - - private formatDateTime(value: unknown): string { - if (typeof value !== 'string' || value.trim().length === 0) { - return ''; - } - - const date = new Date(value); - - if (Number.isNaN(date.getTime())) { - return value; - } - - return date.toLocaleString(); - } - - private handleSaveError(error: HttpErrorResponse): void { - if (error.status === 409) { - this.toastr.error('A tenant with this code already exists.', 'Duplicate tenant code'); - } - } - - private focusFirstInvalidControl(): void { - queueMicrotask(() => { - const control = this.elementRef.nativeElement.querySelector( - 'modal [data-form-control][aria-invalid="true"]' - ); - - control?.focus(); - control?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - } - - private clearTenantGrid(): void { - this.tenants.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); + private getTenantStatusBadgeClass(status: TenantStatus): string { + switch (status) { + case TenantStatus.Trial: return 'badge bg-warning/10 text-warning'; + case TenantStatus.Active: return 'badge bg-success/10 text-success'; + case TenantStatus.Suspended: return 'badge bg-danger/10 text-danger'; + case TenantStatus.Cancelled: return 'badge bg-light text-defaulttextcolor'; + default: return 'badge bg-light text-defaulttextcolor'; } + } } diff --git a/src/app/features/users/components/user-form-modal/user-form-modal.html b/src/app/features/users/components/user-form-modal/user-form-modal.html new file mode 100644 index 00000000..a2bc8722 --- /dev/null +++ b/src/app/features/users/components/user-form-modal/user-form-modal.html @@ -0,0 +1,55 @@ + +
+
+
+ + + @if (hasControlError('email', 'required')) { +

Email address is required.

+ } @else if (hasControlError('email', 'email')) { +

Enter a valid email address.

+ } +
+ +
+ + + @if (hasControlError('password', 'required')) { +

Password is required.

+ } @else if (hasControlError('password', 'minlength')) { +

Password must be at least 8 characters long.

+ } +
+
+
+
diff --git a/src/app/features/users/components/user-form-modal/user-form-modal.ts b/src/app/features/users/components/user-form-modal/user-form-modal.ts new file mode 100644 index 00000000..8abe6868 --- /dev/null +++ b/src/app/features/users/components/user-form-modal/user-form-modal.ts @@ -0,0 +1,182 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + 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 { UserService } from '../../data-access/user.service'; +import { CreateUserRequest, UserModalMode } from '../../models/user.model'; +import { Modal } from '../../../../shared/components/modal/modal'; + +@Component({ + selector: 'app-user-form-modal', + standalone: true, + imports: [ReactiveFormsModule, Modal], + templateUrl: './user-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UserFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly usersApi = inject(UserService); + private readonly toastr = inject(ToastrService); + + readonly open = input(false); + readonly mode = input('create'); + + readonly saved = output(); + readonly closed = output(); + + readonly saving = signal(false); + readonly userSubmitAttempted = signal(false); + + readonly userForm = this.formBuilder.nonNullable.group({ + email: [ + '', + [Validators.required, Validators.email, Validators.maxLength(256)] + ], + password: [ + '', + [Validators.required, Validators.minLength(8), Validators.maxLength(128)] + ], + roleCodes: this.formBuilder.nonNullable.control( + [], + [ + Validators.required, + control => (control.value.length > 0 ? null : { required: true }) + ] + ) + }); + + constructor() { + effect(() => { + if (this.open()) { + this.resetUserForm(); + } + }); + } + + closeModal(): void { + if (this.saving()) return; + this.resetUserForm(); + this.closed.emit(); + } + + saveUser(): void { + this.userSubmitAttempted.set(true); + + if (this.userForm.invalid || this.saving()) { + return; + } + + const request = this.buildCreateUserRequest(); + if (request.roleCodes.length === 0) { + this.userForm.controls.roleCodes.setErrors({ required: true }); + return; + } + + this.saving.set(true); + this.usersApi + .createUser(request) + .pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.saving.set(false); + this.toastr.success('User created successfully.'); + this.resetUserForm(); + this.saved.emit(); + this.closed.emit(); + }, + error: (error: HttpErrorResponse) => { + this.handleCreateUserError(error); + } + }); + } + + isControlInvalid(controlName: keyof typeof this.userForm.controls): boolean { + const control = this.userForm.controls[controlName]; + return this.userSubmitAttempted() && control.invalid; + } + + hasControlError( + controlName: keyof typeof this.userForm.controls, + errorName: string + ): boolean { + const control = this.userForm.controls[controlName]; + return this.userSubmitAttempted() && control.hasError(errorName); + } + + private buildCreateUserRequest(): CreateUserRequest { + const value = this.userForm.getRawValue(); + const normalizedRoleCodes = Array.from( + new Set( + value.roleCodes + .map(roleCode => roleCode.trim()) + .filter(roleCode => roleCode.length > 0) + ) + ); + + return { + email: value.email.trim(), + password: value.password, + roleCodes: normalizedRoleCodes + }; + } + + private resetUserForm(): void { + this.userForm.reset({ + email: '', + password: '', + roleCodes: [] + }); + this.userSubmitAttempted.set(false); + this.userForm.markAsPristine(); + this.userForm.markAsUntouched(); + } + + private handleCreateUserError(error: HttpErrorResponse): void { + if (error.status === 409) { + this.toastr.error('A user with this email address already exists.'); + return; + } + if (error.status === 400) { + this.toastr.error( + this.extractApiErrorMessage(error) ?? 'The user information is invalid.' + ); + return; + } + if (error.status === 401 || error.status === 403) { + return; + } + this.toastr.error('Unable to create the user. Please try again.'); + } + + private extractApiErrorMessage(error: HttpErrorResponse): string | null { + const responseBody: unknown = error.error; + if (typeof responseBody !== 'object' || responseBody === null) { + return null; + } + const apiError = responseBody as { detail?: unknown; message?: unknown; title?: unknown }; + if (typeof apiError.detail === 'string') return apiError.detail; + if (typeof apiError.message === 'string') return apiError.message; + if (typeof apiError.title === 'string') return apiError.title; + return null; + } +} diff --git a/src/app/features/users/models/user.model.ts b/src/app/features/users/models/user.model.ts index b1d5a240..71602721 100644 --- a/src/app/features/users/models/user.model.ts +++ b/src/app/features/users/models/user.model.ts @@ -26,4 +26,4 @@ export interface UpdateUserRequest extends CreateUserRequest { readonly isActive: boolean; } -export type UserModalMode = 'create' | 'edit'; +export type UserModalMode = 'create' | 'edit' | 'view'; diff --git a/src/app/features/users/pages/users-list/users-list.html b/src/app/features/users/pages/users-list/users-list.html index e373e763..64bd877d 100644 --- a/src/app/features/users/pages/users-list/users-list.html +++ b/src/app/features/users/pages/users-list/users-list.html @@ -1,19 +1,27 @@ - - - + diff --git a/src/app/features/users/pages/users-list/users-list.ts b/src/app/features/users/pages/users-list/users-list.ts index 9dcf49ab..d1618745 100644 --- a/src/app/features/users/pages/users-list/users-list.ts +++ b/src/app/features/users/pages/users-list/users-list.ts @@ -1,47 +1,24 @@ import { ChangeDetectionStrategy, Component, - DestroyRef, OnInit, inject, signal } from '@angular/core'; -import { - FormBuilder, - ReactiveFormsModule, - Validators -} from '@angular/forms'; import { RouterLink } from '@angular/router'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - catchError, - finalize, - of, - Subject, - switchMap -} from 'rxjs'; -import { HttpErrorResponse } from '@angular/common/http'; -import { ToastrService } from 'ngx-toastr'; - -import { - CreateUserRequest, - UserModalMode, - UserStatus -} from '../../models/user.model'; +import { UserDto, UserStatus } from '../../models/user.model'; import { UserService } from '../../data-access/user.service'; - import { DataTable } from '../../../../shared/components/data-table/data-table'; -import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; +import { DataTableStore } from '../../../../shared/components/data-table/data-table.store'; import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, - DataTableQuery, DataTableRecord, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; -import { Modal } from '../../../../shared/components/modal/modal'; +import { UserFormModalComponent } from '../../components/user-form-modal/user-form-modal'; interface UserTableRow extends DataTableRecord { id: string; @@ -58,64 +35,17 @@ interface UserTableRow extends DataTableRecord { selector: 'app-users-list', standalone: true, imports: [ - ReactiveFormsModule, - RouterLink, DataTable, - Modal + UserFormModalComponent ], + providers: [DataTableStore], templateUrl: './users-list.html', styleUrl: './users-list.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class UsersList implements OnInit { - private readonly destroyRef = inject(DestroyRef); - private readonly formBuilder = inject(FormBuilder); private readonly usersApi = inject(UserService); - private readonly toastr = inject(ToastrService); - - private readonly usersQueryRequests$ = - new Subject(); - - readonly users = signal([]); - readonly queryState = new DataTableQueryState(); - - readonly totalRecords = signal(0); - readonly filteredRecords = signal(0); - - readonly userModalMode = signal('create'); - readonly showUserModal = signal(false); - - readonly saving = signal(false); - readonly userSubmitAttempted = signal(false); - - readonly userForm = this.formBuilder.nonNullable.group({ - email: [ - '', - [ - Validators.required, - Validators.email, - Validators.maxLength(256) - ] - ], - password: [ - '', - [ - Validators.required, - Validators.minLength(8), - Validators.maxLength(128) - ] - ], - roleCodes: this.formBuilder.nonNullable.control( - [], - [ - Validators.required, - control => - control.value.length > 0 - ? null - : { required: true } - ] - ) - }); + readonly tableStore = inject(DataTableStore); readonly columns = signal[]>([ { @@ -137,8 +67,7 @@ export class UsersList implements OnInit { label: 'User Status', header: 'User Status', sortable: true, - formatter: value => - this.formatUserStatus(value as UserStatus) + formatter: value => this.formatUserStatus(value as UserStatus) }, { key: 'roles', @@ -149,10 +78,7 @@ export class UsersList implements OnInit { formatter: value => this.formatRoles( Array.isArray(value) - ? value.filter( - (item): item is string => - typeof item === 'string' - ) + ? value.filter((item): item is string => typeof item === 'string') : [] ) }, @@ -168,9 +94,7 @@ export class UsersList implements OnInit { header: 'Last Login On', sortable: true, formatter: value => - typeof value === 'string' && value.trim().length > 0 - ? value - : 'Never' + typeof value === 'string' && value.trim().length > 0 ? value : 'Never' }, { key: 'isActive', @@ -182,340 +106,59 @@ export class UsersList implements OnInit { value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', - formatter: value => - value === true ? 'Active' : 'Inactive' + formatter: value => (value === true ? 'Active' : 'Inactive') } ]); - onActionClick(event: DataTableActionEvent): void { - // const user = this.toUserDto(event.row); - - // switch (event.action.type) { - // case 'view': - // this.viewUser(user); - // break; - // case 'edit': - // this.openEditUser(user); - // break; - // case 'delete': - // this.requestDeleteUser(user); - // break; - // case 'activate': - // this.activateUser(user); - // break; - // } - } readonly actions = signal[]>([]); - constructor() { - this.initializeUserQueryStream(); - } - ngOnInit(): void { - this.loadUsers(this.queryState.getQuery()); - } - - loadUsers(query: DataTableQuery): void { - this.usersQueryRequests$.next(query); - } - - onSearch(value: string): void { - const query = this.queryState.setSearch(value.trim()); - this.loadUsers(query); - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.queryState.setPage(event); - this.loadUsers(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.queryState.setSort(event); - this.loadUsers(query); - } - - onRefresh(): void { - const query = this.queryState.reset(); - this.loadUsers(query); + this.tableStore.initialize({ + fetcher: query => this.usersApi.getDataTable(query), + mapRow: (user, serialNumber) => ({ + id: user.id, + email: user.email, + status: user.status, + roles: [...user.roles], + isActive: user.isActive, + createdOn: user.createdOn, + lastLoginOn: user.lastLoginOn, + serialNumber + }) + }); } onAddUser(): void { - this.userModalMode.set('create'); - this.resetUserForm(); - this.showUserModal.set(true); + this.tableStore.openCreateModal(); } - closeUserModal(): void { - if (this.saving()) { - return; - } - - this.showUserModal.set(false); - this.resetUserForm(); + onUserSaved(): void { + this.tableStore.refresh(); } - saveUser(): void { - this.userSubmitAttempted.set(true); - - if (this.userForm.invalid || this.saving()) { - return; - } - - const request = this.buildCreateUserRequest(); - - if (request.roleCodes.length === 0) { - this.userForm.controls.roleCodes.setErrors({ - required: true - }); - - return; - } - - this.saving.set(true); - - this.usersApi - .createUser(request) - .pipe( - finalize(() => { - this.saving.set(false); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: () => { - this.toastr.success('User created successfully.'); - - this.showUserModal.set(false); - this.resetUserForm(); - this.refreshUsersAfterSave(); - }, - error: (error: HttpErrorResponse) => { - this.handleCreateUserError(error); - } - }); - } - - isControlInvalid( - controlName: keyof typeof this.userForm.controls - ): boolean { - const control = this.userForm.controls[controlName]; - - return this.userSubmitAttempted() && control.invalid; - } - - hasControlError( - controlName: keyof typeof this.userForm.controls, - errorName: string - ): boolean { - const control = this.userForm.controls[controlName]; - - return ( - this.userSubmitAttempted() && - control.hasError(errorName) - ); - } - - private initializeUserQueryStream(): void { - this.usersQueryRequests$ - .pipe( - switchMap(requestedQuery => - this.usersApi.getDataTable(requestedQuery).pipe( - catchError(() => { - this.clearUsersGrid(); - this.toastr.error('Unable to load users.'); - - return of(null); - }) - ) - ), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(response => { - if (response === null) { - return; - } - - const currentQuery = this.queryState.getQuery(); - - if (response.draw !== currentQuery.draw) { - return; - } - - const usersWithSerialNumbers: UserTableRow[] = - response.rows.map((user, index) => ({ - id: user.id, - email: user.email, - status: user.status, - roles: [...user.roles], - isActive: user.isActive, - createdOn: user.createdOn, - lastLoginOn: user.lastLoginOn, - serialNumber: - (currentQuery.page - 1) * - currentQuery.pageSize + - index + - 1 - })); - - this.users.set(usersWithSerialNumbers); - this.totalRecords.set(response.total); - this.filteredRecords.set(response.filtered); - }); - } - - private buildCreateUserRequest(): CreateUserRequest { - const value = this.userForm.getRawValue(); - - const normalizedRoleCodes = Array.from( - new Set( - value.roleCodes - .map(roleCode => roleCode.trim()) - .filter(roleCode => roleCode.length > 0) - ) - ); - - return { - email: value.email.trim(), - password: value.password, - roleCodes: normalizedRoleCodes - }; - } - - private refreshUsersAfterSave(): void { - const query = this.queryState.reset(); - this.loadUsers(query); - } - - private resetUserForm(): void { - this.userForm.reset({ - email: '', - password: '', - roleCodes: [] - }); - - this.userSubmitAttempted.set(false); - - this.userForm.markAsPristine(); - this.userForm.markAsUntouched(); - this.userForm.updateValueAndValidity({ - emitEvent: false - }); - } - - private clearUsersGrid(): void { - this.users.set([]); - this.totalRecords.set(0); - this.filteredRecords.set(0); - } - - private handleCreateUserError( - error: HttpErrorResponse - ): void { - if (error.status === 409) { - this.toastr.error( - 'A user with this email address already exists.' - ); - - return; - } - - if (error.status === 400) { - this.toastr.error( - this.extractApiErrorMessage(error) ?? - 'The user information is invalid.' - ); - - return; - } - - /* - * Authentication/session errors should normally be handled - * by the global authentication interceptor. - */ - if (error.status === 401 || error.status === 403) { - return; - } - - this.toastr.error( - 'Unable to create the user. Please try again.' - ); - } - - private extractApiErrorMessage( - error: HttpErrorResponse - ): string | null { - const responseBody: unknown = error.error; - - if ( - typeof responseBody !== 'object' || - responseBody === null - ) { - return null; - } - - const apiError = responseBody as { - detail?: unknown; - message?: unknown; - title?: unknown; - }; - - if (typeof apiError.detail === 'string') { - return apiError.detail; - } - - if (typeof apiError.message === 'string') { - return apiError.message; - } - - if (typeof apiError.title === 'string') { - return apiError.title; - } - - return null; - } + onActionClick(_event: DataTableActionEvent): void {} private formatRoles(roles: readonly string[]): string { - if (roles.length === 0) { - return '—'; - } - - return roles - .map(role => this.formatRoleCode(role)) - .join(', '); + if (roles.length === 0) return '—'; + return roles.map(role => this.formatRoleCode(role)).join(', '); } private formatRoleCode(roleCode: string): string { return roleCode .split('_') .filter(part => part.length > 0) - .map( - part => - `${part.charAt(0).toUpperCase()}${part - .slice(1) - .toLowerCase()}` - ) + .map(part => `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}`) .join(' '); } private formatUserStatus(status: UserStatus): string { switch (status) { - case UserStatus.Pending: - return 'Pending'; - - case UserStatus.Active: - return 'Active'; - - case UserStatus.Suspended: - return 'Suspended'; - - case UserStatus.Locked: - return 'Locked'; - - case UserStatus.Disabled: - return 'Disabled'; - - default: - return 'Unknown'; + case UserStatus.Pending: return 'Pending'; + case UserStatus.Active: return 'Active'; + case UserStatus.Suspended: return 'Suspended'; + case UserStatus.Locked: return 'Locked'; + case UserStatus.Disabled: return 'Disabled'; + default: return 'Unknown'; } } } diff --git a/src/app/shared/components/app-loader/app-loader.html b/src/app/shared/components/app-loader/app-loader.html deleted file mode 100644 index b5de70f9..00000000 --- a/src/app/shared/components/app-loader/app-loader.html +++ /dev/null @@ -1,8 +0,0 @@ -@if (loadingService.isLoading()) { -
- -
-} \ No newline at end of file diff --git a/src/app/shared/components/app-loader/app-loader.scss b/src/app/shared/components/app-loader/app-loader.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/components/app-loader/app-loader.ts b/src/app/shared/components/app-loader/app-loader.ts deleted file mode 100644 index d3e57044..00000000 --- a/src/app/shared/components/app-loader/app-loader.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Component,inject } from '@angular/core'; -import { LoadingService } from '../../../core/services/loading.service'; - -@Component({ - selector: 'app-loader', - imports: [], - standalone: true, - templateUrl: './app-loader.html', - styleUrl: './app-loader.scss', -}) -export class AppLoader { -readonly loadingService = inject(LoadingService); -} diff --git a/src/app/shared/components/button/button.html b/src/app/shared/components/button/button.html index 2adfdee1..ec65c79f 100644 --- a/src/app/shared/components/button/button.html +++ b/src/app/shared/components/button/button.html @@ -8,6 +8,8 @@ title() || 'Button' " + [attr.aria-expanded]="ariaExpanded()" + [attr.aria-controls]="ariaControls()" [attr.aria-busy]="loading() ? true : null" (click)="onClick($event)" > diff --git a/src/app/shared/components/button/button.scss b/src/app/shared/components/button/button.scss index e69de29b..80d714be 100644 --- a/src/app/shared/components/button/button.scss +++ b/src/app/shared/components/button/button.scss @@ -0,0 +1,85 @@ +:host-context(.modern-modal-footer) button, +:host-context(.onboarding-action-footer) button { + height: 36px; + padding: 0 16px; + border-radius: 10px; + font-size: 13px; + font-weight: 600; + transition: .3s ease; +} + +:host-context(.modern-modal-footer) button.ti-btn-light, +:host-context(.onboarding-action-footer) button.ti-btn-light { + border: 1px solid #dbe3ef; + background: #ffffff; + color: #475569; +} + +:host-context(.modern-modal-footer) button.ti-btn-light:hover:not(:disabled), +:host-context(.onboarding-action-footer) button.ti-btn-light:hover:not(:disabled) { + background: #f8fafc; + border-color: #cbd5e1; + transform: translateY(-2px); +} + +:host-context(.modern-modal-footer) button.ti-btn-primary-full, +:host-context(.modern-modal-footer) button.ti-btn-success-full { + position: relative; + overflow: hidden; + border: none; + color: #ffffff; + background: #7c3deb; + box-shadow: 0 8px 20px rgba(125, 61, 235, 0.468); + transition: .35s ease; +} + +:host-context(.modern-modal-footer) button.ti-btn-primary-full:hover:not(:disabled), +:host-context(.modern-modal-footer) button.ti-btn-success-full:hover:not(:disabled) { + transform: translateY(-3px); + box-shadow: 0 12px 28px rgba(37, 99, 235, .45); +} + +:host-context(.modern-modal-footer) button.ti-btn-primary-full::before, +:host-context(.modern-modal-footer) button.ti-btn-success-full::before{ + content: ""; + position: absolute; + top: 0; + left: -120%; + width: 70%; + height: 100%; + background: linear-gradient(120deg, transparent, rgba(255, 255, 255, .55), transparent); + transform: skewX(-25deg); + animation: saveButtonShine 2s infinite; +} + +:host-context(.modern-modal-footer) button:disabled { + opacity: .55; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +:host-context(.modern-modal-footer) button.onboarding-nav-btn { + height: 34px; + padding: 0 12px; + font-size: 12px; +} + +:host-context(.modern-modal-footer) button.onboarding-save-btn:hover:not(:disabled) { + transform: translateY(-3px); + box-shadow: 0 12px 28px rgba(37, 99, 235, .45); +} + +@keyframes saveButtonShine { + 0% { + left: -120%; + } + + 40% { + left: 130%; + } + + 100% { + left: 130%; + } +} \ No newline at end of file diff --git a/src/app/shared/components/button/button.ts b/src/app/shared/components/button/button.ts index bdea5aa5..e13598c8 100644 --- a/src/app/shared/components/button/button.ts +++ b/src/app/shared/components/button/button.ts @@ -200,6 +200,7 @@ const BUTTON_ACTION_CONFIG: Record = { selector: 'app-button', standalone: true, templateUrl: './button.html', + styleUrl: './button.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class Button { @@ -231,6 +232,8 @@ export class Button { readonly iconClass = input(''); readonly ariaLabel = input(null); + readonly ariaExpanded = input(null); + readonly ariaControls = input(null); readonly title = input(null); readonly buttonClicked = output(); diff --git a/src/app/shared/components/data-table/data-table.html b/src/app/shared/components/data-table/data-table.html index 1a65ea66..68ab0427 100644 --- a/src/app/shared/components/data-table/data-table.html +++ b/src/app/shared/components/data-table/data-table.html @@ -145,7 +145,7 @@ dark:hover:bg-white/10 " [disabled]="isActionDisabled(action, row)" [class.opacity-50]="isActionDisabled(action, row)" [class.cursor-not-allowed]="isActionDisabled(action, row)" - [class.!text-danger]="action.type === 'delete'" [attr.aria-label]="getActionLabel(action)" + [class.!text-danger]="action.type === 'delete'" (click)="onDropdownActionClick($event, action, row)"> @if (action.icon) { diff --git a/src/app/shared/components/data-table/data-table.store.ts b/src/app/shared/components/data-table/data-table.store.ts new file mode 100644 index 00000000..e881eec4 --- /dev/null +++ b/src/app/shared/components/data-table/data-table.store.ts @@ -0,0 +1,134 @@ +import { DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Subject, Observable, of } from 'rxjs'; +import { switchMap, catchError, tap, debounceTime } from 'rxjs/operators'; +import { DataTableQueryState } from './data-table-query.state'; +import { + DataTablePageEvent, + DataTableQuery, + DataTableRecord, + DataTableResult, + DataTableSortEvent +} from './data-table.types'; + +export interface DataTableStoreOptions { + fetcher: (query: DataTableQuery) => Observable>; + mapRow?: (item: TItem, serialNumber: number, query: DataTableQuery) => TRow; + onError?: (error: unknown) => void; +} + +@Injectable() +export class DataTableStore { + private readonly destroyRef = inject(DestroyRef); + private readonly querySubject$ = new Subject(); + + readonly queryState = new DataTableQueryState(); + readonly rows = signal([]); + readonly totalRecords = signal(0); + readonly filteredRecords = signal(0); + readonly loading = signal(false); + + readonly showModal = signal(false); + readonly modalMode = signal<'create' | 'edit' | 'view'>('create'); + readonly selectedItem = signal(null); + readonly saving = signal(false); + + initialize(options: DataTableStoreOptions): void { + this.querySubject$ + .pipe( + debounceTime(50), + tap(() => this.loading.set(true)), + switchMap(query => + options.fetcher(query).pipe( + catchError(err => { + this.rows.set([]); + this.totalRecords.set(0); + this.filteredRecords.set(0); + if (options.onError) { + options.onError(err); + } + return of(null); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(response => { + this.loading.set(false); + if (!response) return; + + const currentQuery = this.queryState.getQuery(); + if (response.draw !== currentQuery.draw) return; + + const mappedRows: TRow[] = response.rows.map((item, index) => { + const serialNumber = (currentQuery.page - 1) * currentQuery.pageSize + index + 1; + if (options.mapRow) { + return options.mapRow(item, serialNumber, currentQuery); + } + return { + ...(item as unknown as TRow), + serialNumber + }; + }); + + this.rows.set(mappedRows); + this.totalRecords.set(response.total); + this.filteredRecords.set(response.filtered); + }); + + this.load(this.queryState.getQuery()); + } + + load(query: DataTableQuery): void { + this.querySubject$.next(query); + } + + onSearch(value: string): void { + const query = this.queryState.setSearch(value.trim()); + this.load(query); + } + + onPageChange(event: DataTablePageEvent): void { + const query = this.queryState.setPage(event); + this.load(query); + } + + onSortChange(event: DataTableSortEvent): void { + const query = this.queryState.setSort(event); + this.load(query); + } + + refresh(): void { + const query = this.queryState.getQuery(); + this.load(query); + } + + reset(): void { + const query = this.queryState.reset(); + this.load(query); + } + + openCreateModal(): void { + this.modalMode.set('create'); + this.selectedItem.set(null); + this.showModal.set(true); + } + + openEditModal(item: TItem): void { + this.modalMode.set('edit'); + this.selectedItem.set(item); + this.showModal.set(true); + } + + openViewModal(item: TItem): void { + this.modalMode.set('view'); + this.selectedItem.set(item); + this.showModal.set(true); + } + + closeModal(): void { + if (this.saving()) return; + this.showModal.set(false); + this.selectedItem.set(null); + } +} diff --git a/src/app/shared/components/data-table/data-table.ts b/src/app/shared/components/data-table/data-table.ts index dd96df3b..994552f3 100644 --- a/src/app/shared/components/data-table/data-table.ts +++ b/src/app/shared/components/data-table/data-table.ts @@ -517,6 +517,11 @@ export class DataTable { } // 2. Automatic status detection + + if (this.rowColorMode() === 'none') { + return baseClass; + } + const status = row['status'] ?? row['state'] ?? diff --git a/src/app/shared/components/footer/footer.html b/src/app/shared/components/footer/footer.html deleted file mode 100644 index bf43af8d..00000000 --- a/src/app/shared/components/footer/footer.html +++ /dev/null @@ -1,15 +0,0 @@ - -
-
- Copyright © {{fullyear}} Ynex. - Designed with by - Spruko - All - rights - reserved - -
-
- diff --git a/src/app/shared/components/footer/footer.scss b/src/app/shared/components/footer/footer.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/components/footer/footer.ts b/src/app/shared/components/footer/footer.ts deleted file mode 100644 index fbc97626..00000000 --- a/src/app/shared/components/footer/footer.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Component } from '@angular/core'; - - -@Component({ - selector: 'app-footer', - templateUrl: './footer.html', - styleUrl: './footer.scss' -}) -export class Footer { - fullyear=new Date().getFullYear() -} - - diff --git a/src/app/shared/components/form/autocomplete/autocomplete.scss b/src/app/shared/components/form/autocomplete/autocomplete.scss index 7c8b0606..435a931b 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.scss +++ b/src/app/shared/components/form/autocomplete/autocomplete.scss @@ -77,6 +77,15 @@ padding-block: 0.625rem 0.375rem; } +:host-context(app-filter-card) .autocomplete-control--floating { + min-height: 2rem; +} + +:host-context(app-filter-card) .autocomplete-control--floating .autocomplete-input--floating { + min-height: calc(2rem - 2px); + padding-block: 0.5rem 0.25rem; +} + .autocomplete-dropdown-indicator { position: absolute; inset-inline-end: 0.75rem; diff --git a/src/app/shared/components/form/autocomplete/autocomplete.ts b/src/app/shared/components/form/autocomplete/autocomplete.ts index d5524218..9b53b5ba 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.ts +++ b/src/app/shared/components/form/autocomplete/autocomplete.ts @@ -135,6 +135,8 @@ export class Autocomplete implements ControlValueAccessor { readonly formDisabled = signal(false); readonly panelWidth = signal(0); readonly focused = signal(false); + readonly isForFilter = signal(false); + readonly resolvedInputId = computed(() => this.inputId()?.trim() || this.generatedId); readonly panelId = computed(() => `${this.resolvedInputId()}-listbox`); @@ -170,6 +172,11 @@ export class Autocomplete implements ControlValueAccessor { } return this.options().length ? '' : this.emptyText(); }); + + // readonly resolveHeight = computed(() => { + // return this.he() === 'sm' ? 'h-8' : 'h-10'; + // }); + readonly resolvedInputClass = computed(() => { this.controlStateVersion(); const control = this.control(); diff --git a/src/app/shared/components/form/form-date-picker/form-date-picker.html b/src/app/shared/components/form/form-date-picker/form-date-picker.html index 407ab591..5eee16c0 100644 --- a/src/app/shared/components/form/form-date-picker/form-date-picker.html +++ b/src/app/shared/components/form/form-date-picker/form-date-picker.html @@ -13,27 +13,29 @@ [showValidationWhenDirty]="showValidationWhenDirty()" [submitAttempted]="submitAttempted()" [validationMessages]="validationMessages()" - [wrapperClass]="wrapperClass()" + [wrapperClass]="(isFloating() ? 'date-picker-floating ' : '') + wrapperClass()" [labelClass]="labelClass()" [contentClass]="fieldContentClass()" > - +
+ +
diff --git a/src/app/shared/components/form/form-date-picker/form-date-picker.scss b/src/app/shared/components/form/form-date-picker/form-date-picker.scss index e69de29b..5896a85a 100644 --- a/src/app/shared/components/form/form-date-picker/form-date-picker.scss +++ b/src/app/shared/components/form/form-date-picker/form-date-picker.scss @@ -0,0 +1,110 @@ +:host { + display: block; + width: 100%; + min-width: 0; +} + +:host ::ng-deep .date-picker-floating .floating-label .form-control { + min-height: 2.5rem !important; + height: 2.5rem !important; + border: 1px solid var(--color-inputborder); + border-radius: 0.5rem; + background-color: var(--color-white); + color: transparent !important; + font-size: 0.8125rem; + padding-block: 0.5rem 0.15rem !important; + padding-inline: 0.75rem !important; + box-shadow: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease; +} + +/* Hide native datetime format (dd/mm/yyyy) when un-floated and not focused */ +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-fields-wrapper, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-text, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-month-field, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-day-field, +:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-year-field { + color: transparent !important; +} + +:host ::ng-deep .date-picker-floating .floating-label > label { + position: absolute !important; + inset-inline-start: 0.75rem !important; + padding-inline: 0.35rem !important; + background-color: var(--color-white) !important; + font-weight: 400 !important; + color: #8c9097 !important; + z-index: 9999 !important; + overflow: visible !important; + white-space: nowrap !important; + pointer-events: none !important; +} + +/* Float label and highlight purple on focus or when date is selected */ +:host ::ng-deep .date-picker-floating .floating-label--float > label, +:host ::ng-deep .date-picker-floating .floating-label:focus-within > label { + top: 0 !important; + transform: translateY(-50%) scale(0.85) !important; + transform-origin: left center !important; + font-weight: 400 !important; + color: var(--color-primary) !important; +} + +:host ::ng-deep .date-picker-floating .floating-label--float .form-control, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control { + color: var(--color-defaulttextcolor) !important; + border-color: var(--color-primary) !important; +} + +/* Show native datetime format / selected date value when floated or focused */ +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-fields-wrapper, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-fields-wrapper, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-text, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-text, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-month-field, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-month-field, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-day-field, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-day-field, +:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-year-field, +:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-year-field { + color: var(--color-defaulttextcolor) !important; +} + +:host-context(.dark) ::ng-deep .date-picker-floating .form-control, +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label > label { + background-color: var(--color-bodybg) !important; +} + +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label--float .form-control, +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label:focus-within .form-control { + color: var(--color-white) !important; +} + +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit, +:host-context(.dark) ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit { + color: var(--color-white) !important; +} + +/* Calendar icon picker indicator styling */ +:host ::ng-deep .form-control::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + transition: opacity 0.2s ease, filter 0.2s ease; + + &:hover { + opacity: 1; + } +} + +/* Invert calendar picker icon color to white in dark mode */ +:host-context(.dark) ::ng-deep .form-control::-webkit-calendar-picker-indicator { + filter: invert(1) !important; + opacity: 0.8; + + &:hover { + opacity: 1; + } +} diff --git a/src/app/shared/components/form/form-field/form-field.ts b/src/app/shared/components/form/form-field/form-field.ts index acf1a4c9..b95745c4 100644 --- a/src/app/shared/components/form/form-field/form-field.ts +++ b/src/app/shared/components/form/form-field/form-field.ts @@ -151,6 +151,10 @@ export class FormField { const value = control.value; + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== null && value !== undefined && value !== ''; }); diff --git a/src/app/shared/components/form/form-input/form-input.scss b/src/app/shared/components/form/form-input/form-input.scss index 25f2e9da..0b44641e 100644 --- a/src/app/shared/components/form/form-input/form-input.scss +++ b/src/app/shared/components/form/form-input/form-input.scss @@ -18,14 +18,14 @@ .shared-form-input { display: block; width: 100%; - min-height: 2.5rem; + //min-height: 1.6rem; padding: 0.75rem 0.75rem 0.5rem; border: 1px solid var(--color-inputborder); border-radius: 0.5rem; background-color: var(--color-white); color: var(--color-defaulttextcolor); font-size: 0.8125rem; - line-height: 1.25; + line-height: 10px; outline: none; box-shadow: none; transition: border-color 0.2s ease; diff --git a/src/app/shared/components/form/form-select/form-select.html b/src/app/shared/components/form/form-select/form-select.html index 7323075c..c7bfe274 100644 --- a/src/app/shared/components/form/form-select/form-select.html +++ b/src/app/shared/components/form/form-select/form-select.html @@ -1,225 +1,181 @@ - - - @if (showDropdownHeader()) { + +
+ + @if (showDropdownHeader()) { @if (searchable()) { -
- -
+
+ + +
} - @if (isMultiple() && showSelectAll()) { - - } -
- } - - -
- @if (isMultiple() && showCheckboxes()) { + @if (resolvedIsMultiple() && showSelectAll()) { + + } + + } + + +
+ @if (resolvedIsMultiple() && showCheckboxes()) { + + } + + @if (item.prefixText) { {{ item.prefixText }} - } + } - - - {{ getOptionDisplayLabel(item, item$.label) }} - + + + {{ getOptionDisplayLabel(item, item$.label) }} + - @if (item.description) { + @if (item.description) { {{ item.description }} - } - -
-
+ } + +
+
- - - @if (getSelectionPrefixText(item)) { + + + @if (getSelectionPrefixText(item)) { {{ getSelectionPrefixText(item) }} - } + } - - {{ getSelectionDisplayLabel(item, label) }} - + + {{ getSelectionDisplayLabel(item, label) }} + - @if (isMultiple() && clearable() && !resolvedReadonly()) { - - } - - + } + + - @if (isMultiple()) { + @if (resolvedIsMultiple()) { -
+
@if (items.length > 0) { - - - {{ getMultiLabelText(items) }} - - - @if (clearable() && !resolvedReadonly()) { - - } + + + {{ getFirstOptionLabel(items) }} + + @if (items.length > 1) { + + +{{ items.length - 1 }} + + } + + @if (clearable() && !resolvedReadonly()) { + + } + }
- } + } - @if (isMultiple() && showMultiSelectFooter()) { + @if (resolvedIsMultiple() && showMultiSelectFooter()) { -
+
{{ pendingValue().length }} selected - -
+ } + + + @if (!loading()) { + } - - +
+ \ No newline at end of file diff --git a/src/app/shared/components/form/form-select/form-select.scss b/src/app/shared/components/form/form-select/form-select.scss index e8532903..1ebca17d 100644 --- a/src/app/shared/components/form/form-select/form-select.scss +++ b/src/app/shared/components/form/form-select/form-select.scss @@ -5,31 +5,170 @@ } :host ::ng-deep .form-select-floating .floating-label .ng-select-container { - min-height: 2.5rem; + min-height: 2.5rem !important; + height: 2.5rem !important; border: 1px solid var(--color-inputborder); border-radius: 0.5rem; background-color: var(--color-white); color: var(--color-defaulttextcolor); font-size: 0.8125rem; box-shadow: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + display: flex; + align-items: center; + position: relative; + overflow: visible; } -:host ::ng-deep .form-select-floating .floating-label > label { - padding-inline: 0.25rem; +:host ::ng-deep .form-select-floating .floating-label { + position: relative !important; + overflow: visible !important; +} + +:host ::ng-deep .form-select-floating .floating-label>label { + position: absolute !important; + inset-inline-start: 0.75rem !important; + padding-inline: 0.35rem !important; background-color: var(--color-white) !important; - font-size: 0.75rem !important; + font-weight: 400 !important; + color: #8c9097 !important; + z-index: 9999 !important; + overflow: visible !important; + white-space: nowrap !important; + pointer-events: none !important; +} + +:host ::ng-deep .form-select-floating .floating-label--float>label, +:host ::ng-deep .form-select-floating .floating-label:focus-within>label, +:host ::ng-deep .form-select-floating:has(.ng-select-has-value)>label { + top: 0 !important; + transform: translateY(-50%) scale(0.85) !important; + transform-origin: left center !important; + font-weight: 400 !important; + color: var(--color-primary) !important; +} + +:host ::ng-deep .ng-select .ng-arrow-wrapper { + display: none !important; +} + +.form-select-dropdown-indicator { + position: absolute; + inset-inline-end: 0.75rem; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-textmuted, #8c9097); + font-size: 0.875rem; + line-height: 1; + pointer-events: none; + z-index: 9999 !important; + transition: transform 0.2s ease, color 0.2s ease; + + &.is-expanded { + transform: translateY(-50%) rotate(180deg) !important; + color: var(--color-primary) !important; + } +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select .ng-select-container .ng-value-container { + padding-inline-start: 0.75rem; + padding-inline-end: 2.25rem; + padding-block: 0; + display: flex; + align-items: center; + height: 100%; + max-width: 100%; + overflow: hidden; +} + +:host ::ng-deep .form-select-floating .floating-label--float .ng-select .ng-select-container .ng-value-container, +:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select .ng-select-container .ng-value-container { + padding-top: 0.5rem !important; + padding-bottom: 0.15rem !important; +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container { + display: flex; + flex-wrap: nowrap; + gap: 0.375rem; + align-items: center; + max-width: 100%; + overflow: hidden; +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-value { + margin: 0; + max-width: 100%; + overflow: hidden; +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-input { + margin: 0; + padding: 0; +} + +:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-placeholder { + top: auto; } :host ::ng-deep .form-select-floating .floating-label--float .ng-select-container, -:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select-container { - border-color: var(--color-primary); +:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select-container, +:host ::ng-deep .form-select-floating .ng-select.ng-select-has-value .ng-select-container, +:host ::ng-deep .form-select-floating .ng-select.ng-select-opened .ng-select-container, +:host ::ng-deep .form-select-floating .ng-select.ng-select-focused .ng-select-container { + border-color: var(--color-primary) !important; } :host ::ng-deep .form-select-floating .floating-label--invalid .ng-select-container { - border-color: var(--color-danger); + border-color: var(--color-danger) !important; } :host-context(.dark) ::ng-deep .form-select-floating .ng-select-container, -:host-context(.dark) ::ng-deep .form-select-floating .floating-label > label { +:host-context(.dark) ::ng-deep .form-select-floating .floating-label>label { background-color: var(--color-bodybg) !important; } + +:host ::ng-deep .ng-select .ng-dropdown-panel { + border-radius: 0.5rem !important; + border: 1px solid var(--color-defaultborder, #e9edf6) !important; + background-color: var(--color-white, #ffffff) !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05) !important; + overflow: hidden !important; + margin-top: 4px !important; +} + +:host-context(.dark) ::ng-deep .ng-select .ng-dropdown-panel { + border-color: rgba(255, 255, 255, 0.1) !important; + background-color: var(--color-bodybg, #1a1e25) !important; +} + +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-header { + border-bottom: none !important; + padding: 0 !important; +} + +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-header-tmp button, +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option { + padding: 0.5rem 0.75rem !important; +} + +:host ::ng-deep .ng-select.ng-select-multiple .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected:not(.ng-option-marked) { + background-color: transparent !important; + color: var(--color-defaulttextcolor) !important; +} + +:host-context(.dark) ::ng-deep .ng-select.ng-select-multiple .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected:not(.ng-option-marked) { + color: var(--color-white) !important; +} + +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked { + background-color: var(--color-primary) !important; + color: #ffffff !important; +} + +:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked * { + color: #ffffff !important; +} \ No newline at end of file diff --git a/src/app/shared/components/form/form-select/form-select.ts b/src/app/shared/components/form/form-select/form-select.ts index 641af404..365f449f 100644 --- a/src/app/shared/components/form/form-select/form-select.ts +++ b/src/app/shared/components/form/form-select/form-select.ts @@ -3,6 +3,7 @@ import { Component, Injector, computed, + effect, forwardRef, inject, input, @@ -64,6 +65,7 @@ export class FormSelect implements private readonly generatedInputId = `form-select-${FormSelect.nextId++}`; readonly inputId = input(null); + readonly id = input(null); readonly label = input(''); readonly variant = input<'default' | 'floating'>('default'); @@ -71,13 +73,19 @@ export class FormSelect implements readonly name = input(null); readonly options = input[]>([]); + readonly bindLabel = input('label'); + readonly bindValue = input('value'); readonly mode = input('single'); + readonly multiple = input(null); + readonly defaultValue = input | null>(null); readonly searchable = input(true); readonly searchMode = input('client'); + readonly searchPlaceholder = input(null); + readonly editableSearchTerm = input(null); readonly clearable = input(true); @@ -159,6 +167,7 @@ export class FormSelect implements readonly fieldContentClass = input(''); readonly selectClass = input(''); + readonly className = input(''); readonly ariaLabel = input(null); @@ -193,9 +202,9 @@ export class FormSelect implements readonly searchTerm = signal(''); readonly hasFocus = signal(false); - private onChange: (value: FormSelectValue) => void = () => {}; + private onChange: (value: FormSelectValue) => void = () => { }; - private onTouched: () => void = () => {}; + private onTouched: () => void = () => { }; readonly control = computed(() => { return this.injector.get(NgControl, null, { @@ -205,7 +214,7 @@ export class FormSelect implements }); readonly resolvedInputId = computed(() => - this.inputId()?.trim() || this.generatedInputId + this.inputId()?.trim() || this.id()?.trim() || this.generatedInputId ); readonly resolvedName = computed(() => @@ -214,6 +223,10 @@ export class FormSelect implements readonly isMultiple = computed(() => this.mode() === 'multiple'); + readonly resolvedIsMultiple = computed(() => + this.multiple() ?? this.mode() === 'multiple' + ); + readonly isDisabled = computed(() => this.disabled() || this.formDisabled() || @@ -235,7 +248,7 @@ export class FormSelect implements ); readonly resolvedCloseOnSelect = computed(() => - this.closeOnSelect() ?? !this.isMultiple() + this.closeOnSelect() ?? !this.resolvedIsMultiple() ); readonly resolvedEditableSearchTerm = computed(() => @@ -276,7 +289,7 @@ export class FormSelect implements readonly showDropdownHeader = computed(() => this.searchable() || ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showSelectAll() ) ); @@ -291,7 +304,7 @@ export class FormSelect implements readonly modelValue = computed>(() => { if ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showMultiSelectFooter() && this.dropdownOpen() ) { @@ -353,7 +366,7 @@ export class FormSelect implements ); readonly resolvedSearchPlaceholder = computed(() => - this.placeholder().trim() || + this.searchPlaceholder()?.trim() || ( this.label().trim() ? `Search ${this.label().trim()}` @@ -361,7 +374,7 @@ export class FormSelect implements ) ); - + readonly describedBy = computed(() => { const ids: string[] = []; @@ -437,12 +450,25 @@ export class FormSelect implements 'app-form-select-control', showInvalidState ? 'is-invalid' : '', this.isDisabled() ? 'opacity-60 pointer-events-none' : '', - this.selectClass() + this.selectClass(), + this.className() ] .filter(Boolean) .join(' '); }); + constructor() { + effect(() => { + const defaultValue = this.defaultValue(); + + if (this.control() || defaultValue === null) { + return; + } + + this.value.set(this.normalizeValue(defaultValue)); + }); + } + writeValue(value: FormSelectValue): void { this.value.set(this.normalizeValue(value)); } @@ -463,7 +489,7 @@ export class FormSelect implements const normalizedValue = this.normalizeValue(incomingValue); if ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showMultiSelectFooter() && this.dropdownOpen() ) { @@ -492,7 +518,7 @@ export class FormSelect implements } toggleSelectAll(): void { - if (!this.isMultiple() || this.isDisabled()) { + if (!this.resolvedIsMultiple() || this.isDisabled()) { return; } @@ -516,7 +542,7 @@ export class FormSelect implements this.searchTerm.set(''); if ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showMultiSelectFooter() ) { this.pendingValue.set([ @@ -559,7 +585,7 @@ export class FormSelect implements } confirmSelection(select: NgSelectComponent): void { - if (!this.isMultiple()) { + if (!this.resolvedIsMultiple()) { return; } @@ -594,15 +620,18 @@ export class FormSelect implements : firstLabel; } + getFirstOptionLabel(items: readonly FormSelectOption[]): string { + return items[0]?.label ?? ''; + } + trackOption(option: FormSelectOption): TValue { return option.value; } getOptionContainerClass(option: FormSelectOption): string { return [ - 'flex min-w-0 w-full items-start gap-2 text-inherit', - option.disabled ? 'text-textmuted opacity-60' : '', - this.isOptionSelected(option) && !option.disabled ? 'text-white' : '' + 'flex min-w-0 w-full items-center gap-2 text-inherit', + option.disabled ? 'text-textmuted opacity-60' : '' ] .filter(Boolean) .join(' '); @@ -660,7 +689,7 @@ export class FormSelect implements private activeSelectedValues(): readonly TValue[] { if ( - this.isMultiple() && + this.resolvedIsMultiple() && this.showMultiSelectFooter() && this.dropdownOpen() ) { @@ -679,7 +708,7 @@ export class FormSelect implements } private normalizeValue(value: FormSelectValue): FormSelectValue { - if (this.isMultiple()) { + if (this.resolvedIsMultiple()) { return this.isValueArray(value) ? value.filter(item => !this.isEmptyStringValue(item)) : []; diff --git a/src/app/shared/components/header/header.html b/src/app/shared/components/header/header.html deleted file mode 100644 index 9cb86a42..00000000 --- a/src/app/shared/components/header/header.html +++ /dev/null @@ -1,524 +0,0 @@ - -
- -
- - - - - diff --git a/src/app/shared/components/header/header.scss b/src/app/shared/components/header/header.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/components/header/header.ts b/src/app/shared/components/header/header.ts deleted file mode 100644 index 7f58ecd3..00000000 --- a/src/app/shared/components/header/header.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { Component, DOCUMENT, ElementRef, Renderer2, inject } from '@angular/core'; -import { Menu, NavService } from '../../services/nav.service'; -import * as headeData from "./headerdata" -import { AppStateService } from '../../services/app-state.service'; -import { Subscription } from 'rxjs'; -import { Router, RouterLink } from '@angular/router'; -import { FullscreenDirective } from '../../directives/fullscreen.directive'; -import { FormsModule } from '@angular/forms'; -import { SlicePipe } from '@angular/common'; -import { AuthService } from '../../../core/auth/auth.service'; -interface Item { - id: number; - name: string; - type: string; - title: string; - // Add other properties as needed -} -declare const HSStaticMethods: any; -@Component({ - selector: 'app-header', - templateUrl: './header.html', - styleUrls: ['./header.scss'], - imports: [RouterLink, FullscreenDirective, FormsModule, SlicePipe] -}) - -export class Header { - headeData = headeData; - public menuItems!: Menu[]; - public menuitemsSubscribe$!: Subscription; - public NavServices = inject(NavService) - private appStateService = inject(AppStateService) - private readonly authService = inject(AuthService); - private readonly router = inject(Router); - readonly currentUser = this.authService.currentUserSignal; - Selector = (selector: any) => document.querySelector(selector); - - SelectorAll = (selector: any) => document.querySelectorAll(selector); - - private doc = inject(DOCUMENT); - toggleSidebar() { - const html = this.doc.documentElement; - // Check the window width - if (window.innerWidth <= 992) { - let dataToggled = html.getAttribute("data-toggled"); - - if (dataToggled == "open") { - html.setAttribute("data-toggled", "close"); - } else { - html.setAttribute("data-toggled", "open"); - } - } - else { - let menuNavLayoutType = html.getAttribute("data-nav-style"); - let verticalStyleType = html.getAttribute("data-vertical-style"); - - if (menuNavLayoutType) { - let dataToggled = html.getAttribute("data-toggled"); - if (dataToggled) { - html.removeAttribute("data-toggled"); - } else { - html.setAttribute( - "data-toggled", - menuNavLayoutType + "-closed", - ); - } - } else if (verticalStyleType) { - let dataToggled = html.getAttribute("data-toggled"); - - if (verticalStyleType == "doublemenu") { - if ( - html.getAttribute("data-toggled") === "double-menu-open" && this.Selector(".double-menu-active")) { - html.setAttribute("data-toggled", "double-menu-close"); - } else { - if (this.Selector(".double-menu-active")) { html.setAttribute("data-toggled", "double-menu-open"); } - } - } else if (dataToggled) { - html.removeAttribute("data-toggled"); - } else { - switch (verticalStyleType) { - case "closed": - html.setAttribute( - "data-toggled", - "close-menu-close", - ); - break; - case "icontext": - html.setAttribute( - "data-toggled", - "icon-text-close", - ); - break; - case "overlay": - html.setAttribute( - "data-toggled", - "icon-overlay-close", - ); - break; - case "detached": - html.setAttribute("data-toggled", "detached-close"); - break; - default: - } - } - } - } - } - - - public items: Menu[] = []; // Your full menu data (source) - public text: string = ''; - public SearchResultEmpty: boolean = false; - public isDropdownVisible: boolean = false; - Search(searchText: string) { - // 2. Safety Check: If search is empty or source data hasn't loaded - if (!searchText || !this.items) { - this.menuItems = []; - this.SearchResultEmpty = false; - return; - } - - const results: Menu[] = []; - const query = searchText.toLowerCase().trim(); - - // 3. Deep search through 3 levels of menu - this.items.forEach((level1: Menu) => { - // Check Level 1 - if (level1.title?.toLowerCase().includes(query)) { - results.push(level1); - } - - // Check Level 2 (Children) - if (level1.children) { - level1.children.forEach((level2: Menu) => { - if (level2.title?.toLowerCase().includes(query)) { - results.push(level2); - } - - // Check Level 3 (Sub-children) - if (level2.children) { - level2.children.forEach((level3: Menu) => { - if (level3.title?.toLowerCase().includes(query)) { - results.push(level3); - } - }); - } - }); - } - }); - - // 4. Update UI State - this.menuItems = results; - this.SearchResultEmpty = results.length === 0; - } - - - - // Used to clear previous search result - clearSearch() { - const headerSearch = this.Selector('.header-search'); - if (headerSearch) { - headerSearch.classList.remove('searchdrop'); - } - this.text = ''; - this.menuItems = []; - this.SearchResultEmpty = false; - return this.text, this.menuItems; - - } - - updateTheme(theme: string) { - - - this.appStateService.updateState({ theme, menuColor: theme, headerColor: theme }); - if (theme == 'light') { - this.appStateService.updateState({ theme, themeBackground: '', headerColor: 'light', menuColor: 'dark' }); - let html = document.querySelector('html'); - html?.style.removeProperty('--color-bodybg'); - html?.style.removeProperty('--color-bodybg2'); - html?.style.removeProperty('--color-light'); - html?.style.removeProperty('--color-formcontrolbg'); - html?.style.removeProperty('--color-inputborder'); - html?.style.removeProperty('--color-gray3'); - if (window.innerWidth <= 992) { - html?.setAttribute('data-toggled', 'close'); - } - } - if (theme == 'dark') { - this.appStateService.updateState({ theme, themeBackground: '', headerColor: 'dark', menuColor: 'dark' }); - let html = document.querySelector('html'); - html?.style.removeProperty('--color-bodybg'); - html?.style.removeProperty('--color-bodybg2'); - html?.style.removeProperty('--color-light'); - html?.style.removeProperty('--color-formcontrolbg'); - html?.style.removeProperty('--color-inputborder'); - html?.style.removeProperty('--color-gray3'); - if (window.innerWidth <= 992) { - html?.setAttribute('data-toggled', 'close'); - } - } - } - - - cartItemCount = this.headeData.cartItems.length; - notificationItemCount = this.headeData.notifications.length; - handleCardClick(event: MouseEvent) { - // Prevent the click event from propagating to the container - event.stopPropagation(); - } - removeRow(itemId: string,event: MouseEvent) { - const index = this.headeData.cartItems.findIndex(i => i.id === itemId); - if (index !== -1) { - this.headeData.cartItems.splice(index, 1); - } - this.updateCartItemCount(); - event.stopPropagation(); - } - updateCartItemCount() { - this.cartItemCount = this.headeData.cartItems.length; - } - - removeNotification(id: number, event: Event): void { - event.preventDefault(); // Prevent link navigation - this.headeData.notifications.splice( - this.headeData.notifications.findIndex(item => item.id === id), - 1 - ); - - this.updatenotificationsItemCount(); - } - - updatenotificationsItemCount() { - this.notificationItemCount = this.headeData.notifications.length; - } - - isFullscreen: boolean = false; - toggleFullscreen() { - - this.isFullscreen = !this.isFullscreen; - } - - - - removeAlert(array: T[], id: string): void { - const index = array.findIndex(item => item.id === id); - - if (index !== -1) { - array.splice(index, 1); - } - } - - ngOnInit(): void { - this.NavServices.items.subscribe((menuItems) => { - this.items = menuItems; - }); - } - - ngAfterViewInit(): void { - HSStaticMethods.autoInit(); - } - - getDisplayName(): string { - const email = this.currentUser()?.email?.trim(); - if (email) { - const localPart = email.split('@')[0]; - const formatted = localPart - .replace(/[._-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - if (formatted) { - return formatted - .split(' ') - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(' '); - } - } - - return this.currentUser()?.displayName?.trim() || 'User'; - } - - getPrimaryRole(): string { - const roles = this.currentUser()?.roles; - if (roles && roles.length > 0) { - const role = roles[0].trim(); - if (role) { - return role - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .split(' ') - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(' '); - } - } - - return 'User'; - } - - handleProfileItemClick(item: { label?: string; link?: string | null }, event: MouseEvent): void { - event.preventDefault(); - - if (item.label === 'Log Out') { - this.logout(); - return; - } - - if (item.link) { - void this.router.navigateByUrl(item.link); - } - } - - logout(): void { - this.authService.logout(); - void this.router.navigate(['/auth/login']); - } -} - diff --git a/src/app/shared/components/header/headerdata.ts b/src/app/shared/components/header/headerdata.ts deleted file mode 100644 index 15f150a3..00000000 --- a/src/app/shared/components/header/headerdata.ts +++ /dev/null @@ -1,102 +0,0 @@ -export const languages = [ - { name: 'English', flag: './assets/images/flags/us_flag.jpg' }, - { name: 'Spanish', flag: './assets/images/flags/spain_flag.jpg' }, - { name: 'French', flag: './assets/images/flags/french_flag.jpg' }, - { name: 'German', flag: './assets/images/flags/germany_flag.jpg' }, - { name: 'Italian', flag: './assets/images/flags/italy_flag.jpg' }, - { name: 'Russian', flag: './assets/images/flags/russia_flag.jpg' } -]; - - -export const cartItems = [ - { id: 'row1', name: 'SomeThing Phone', price: '$1,299.00', img: './assets/images/ecommerce/jpg/1.jpg', tags: ['Metallic Blue', '6gb Ram'] }, - { id: 'row2', name: 'Stop Watch', price: '$179.29', img: './assets/images/ecommerce/jpg/3.jpg', tags: ['Analog'], freeShipping: true }, - { id: 'row3', name: 'Photo Frame', price: '$29.00', img: './assets/images/ecommerce/jpg/5.jpg', tags: ['Decorative'] }, - { id: 'row4', name: 'Kikon Camera', price: '$4,999.00', img: './assets/images/ecommerce/jpg/4.jpg', tags: ['Black', '50MM'] }, - { id: 'row5', name: 'Canvas Shoes', price: '$129.00', img: './assets/images/ecommerce/jpg/6.jpg', tags: ['Gray', 'Sports'] }, -]; - - - -export const notifications = [ - { - id: 1, - type: 'shipment', - icon: 'ti-gift', - colorClass: 'text-primary', - bgClass: 'bg-primary/10', - title: 'Your Order Has Been Shipped', - description: 'Order No: 123456 Has Shipped To Your Delivery Address', }, - { - id: 2, - type: 'discount', - icon: 'ti-discount-2', - colorClass: 'text-secondary', - bgClass: 'bg-secondary/10', - title: 'Discount Available', - description: 'Discount Available On Selected Products', }, - { - id: 3, - type: 'verify', - icon: 'ti-user-check', - colorClass: 'text-pink', - bgClass: 'bg-pink/10', - title: 'Account Has Been Verified', - description: 'Your Account Has Been Verified Successfully', }, - { - id: 4, - type: 'placed', - icon: 'ti-circle-check', - colorClass: 'text-warning', - bgClass: 'bg-warning/10', - title: 'Order Placed', - description: 'Order Placed Successfully', - orderId: '#1116773' - }, - { - id: 5, - type: 'delayed', - icon: 'ti-clock', - colorClass: 'text-success', - bgClass: 'bg-success/10', - title: 'Order Delayed', - description: 'Order Delayed Unfortunately', - orderId: '7731116' - } -]; - -export const relatedApps = [ - { name: 'Figma', img: './assets/images/apps/figma.png', alt: 'figma' }, - { name: 'Power Point', img: './assets/images/apps/microsoft-powerpoint.png', alt: 'microsoft' }, - { name: 'MS Word', img: './assets/images/apps/microsoft-word.png', alt: 'msword' }, - { name: 'Calendar', img: './assets/images/apps/calender.png', alt: 'calendar' }, - { name: 'Sketch', img: './assets/images/apps/sketch.png', alt: 'sketch' }, - { name: 'Docs', img: './assets/images/apps/google-docs.png', alt: 'docs' }, - { name: 'Google', img: './assets/images/apps/google.png', alt: 'google' }, - { name: 'Translate', img: './assets/images/apps/translate.png', alt: 'translate' }, - { name: 'Sheets', img: './assets/images/apps/google-sheets.png', alt: 'sheets' } -]; - - -export const menuItems = [ - { label: 'Profile', link: '/pages/profile', icon: 'ti-user-circle' }, - { label: 'Inbox', link: '/pages/email/mailapp', icon: 'ti-inbox', badge: '25' }, - { label: 'Task Manager', link: '/pages/todolist', icon: 'ti-clipboard-check' }, - { label: 'Settings', link: '/pages/email/mailsettings', icon: 'ti-adjustments-horizontal' }, - { label: 'Bal: $7,12,950', link: null, icon: 'ti-wallet' }, // Special case for non-router link - { label: 'Support', link: '/pages/chat', icon: 'ti-headset' }, - { label: 'Log Out', link: '/authentication/sign-in/cover', icon: 'ti-logout' }, -]; - -export const searchTags = [ - { id: 'tag1', label: 'People', icon: 'fe-user' }, - { id: 'tag2', label: 'Pages', icon: 'fe-file-text' }, - { id: 'tag3', label: 'Articles', icon: 'fe-align-left' }, - { id: 'tag4', label: 'Tags', icon: 'fe-server' } -]; - -export const alertItems = [ - { id: 'tag5', label: 'Notifications', link: '/pages/notifications' }, - { id: 'tag6', label: 'Alerts', link: '/uielements/alerts' }, - { id: 'tag7', label: 'Mail', link: '/pages/email/mailapp' } -]; diff --git a/src/app/shared/components/modal/modal.scss b/src/app/shared/components/modal/modal.scss index c251a389..785503d4 100644 --- a/src/app/shared/components/modal/modal.scss +++ b/src/app/shared/components/modal/modal.scss @@ -461,208 +461,6 @@ } -/* ============================ - Cancel Button -============================ */ - - -::ng-deep .modern-modal-footer app-button:first-child button { - - - height: 36px; - - padding: 0 16px; - - border-radius: 10px; - - border: 1px solid #dbe3ef; - - background: #ffffff; - - color: #475569; - - font-weight: 600; - - font-size: 13px; - - transition: .3s ease; - -} - - -::ng-deep .modern-modal-footer app-button:first-child button:hover { - - - background: #f8fafc; - - border-color: #cbd5e1; - - transform: translateY(-2px); - -} - - - -/* ============================ - Save Button -============================ */ - - -::ng-deep .modern-modal-footer app-button:last-child button { - - - position: relative; - - overflow: hidden; - - height: 36px; - - padding: 0 16px; - - border-radius: 10px; - - border: none; - - - color: #ffffff; - - - // background: - - // linear-gradient(135deg, - // #111C43, - // #2563eb); - background: #7c3deb; - - box-shadow: - - 0 8px 20px rgba(125, 61, 235, 0.468); - - - font-weight: 600; - - font-size: 13px; - - - transition: .35s ease; - -} - - - -::ng-deep .modern-modal-footer app-button:last-child button:hover { - - - transform: translateY(-3px); - - - box-shadow: - - 0 12px 28px rgba(37, 99, 235, .45); - -} - - - -/* ============================ - Shine Animation -============================ */ - - -::ng-deep .modern-modal-footer app-button:last-child button::before { - - - content: ""; - - - position: absolute; - - - top: 0; - - left: -120%; - - - width: 70%; - - - height: 100%; - - - background: - - linear-gradient(120deg, - transparent, - rgba(255, 255, 255, .55), - transparent); - - - transform: skewX(-25deg); - - - animation: saveButtonShine 2s infinite; - -} - - - -@keyframes saveButtonShine { - - - 0% { - - left: -120%; - - } - - - 40% { - - left: 130%; - - } - - - 100% { - - left: 130%; - - } - -} - - -/* Disabled */ - -::ng-deep .modern-modal-footer button:disabled { - - - opacity: .55; - - cursor: not-allowed; - - transform: none !important; - - box-shadow: none !important; - -} - -::ng-deep .modern-modal-footer app-button button { - - height: 36px; - - padding: 0 16px; - - border-radius: 10px; - - font-size: 13px; - - font-weight: 600; - - transition: .3s ease; - -} - /* ========================================================== Mobile ========================================================== */ diff --git a/src/app/shared/components/page-header/page-header.html b/src/app/shared/components/page-header/page-header.html deleted file mode 100644 index 6e148ae5..00000000 --- a/src/app/shared/components/page-header/page-header.html +++ /dev/null @@ -1,36 +0,0 @@ -@if(childTitle){ - -} diff --git a/src/app/shared/components/page-header/page-header.scss b/src/app/shared/components/page-header/page-header.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/components/page-header/page-header.ts b/src/app/shared/components/page-header/page-header.ts deleted file mode 100644 index e7504809..00000000 --- a/src/app/shared/components/page-header/page-header.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Component, input } from '@angular/core'; -import { ChildrenOutletContexts, NavigationEnd, Router } from '@angular/router'; -import { filter } from 'rxjs'; - -@Component({ - selector: 'app-page-header', - templateUrl: './page-header.html', - styleUrls: ['./page-header.scss'] -}) -export class PageHeader { - parentTitle?: string; - subParentTitle?: string; - childTitle?: string; - - constructor( - private router: Router, - private childrenOutletContexts: ChildrenOutletContexts - ) { - this.router.events - .pipe(filter(event => event instanceof NavigationEnd)) - .subscribe(() => { - const context = this.childrenOutletContexts.getContext('primary'); - const routeData = context?.route?.snapshot?.data; - if (routeData) { - this.childTitle = routeData['childTitle'] ?? ''; - this.parentTitle = routeData['parentTitle'] ?? ''; - this.subParentTitle = routeData['subParentTitle'] ?? ''; - - } - }); - } -} - - diff --git a/src/app/shared/components/problem-details-toast/problem-details-toast.ts b/src/app/shared/components/problem-details-toast/problem-details-toast.ts deleted file mode 100644 index be1879dc..00000000 --- a/src/app/shared/components/problem-details-toast/problem-details-toast.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Component, Input } from '@angular/core'; - -@Component({ - selector: 'app-problem-details-toast', - standalone: true, - template: ` -
-
{{ title }}
-
{{ detail }}
-
- `, -}) -export class ProblemDetailsToast { - @Input() title = 'Request failed'; - @Input() detail = 'An unexpected error occurred.'; -} diff --git a/src/app/shared/components/sidebar/sidebar.html b/src/app/shared/components/sidebar/sidebar.html deleted file mode 100644 index ec13545c..00000000 --- a/src/app/shared/components/sidebar/sidebar.html +++ /dev/null @@ -1,216 +0,0 @@ - \ No newline at end of file diff --git a/src/app/shared/components/sidebar/sidebar.scss b/src/app/shared/components/sidebar/sidebar.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/components/sidebar/sidebar.ts b/src/app/shared/components/sidebar/sidebar.ts deleted file mode 100644 index fe664641..00000000 --- a/src/app/shared/components/sidebar/sidebar.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { Component, Renderer2, HostListener, ElementRef, } from '@angular/core'; -import { Menu, NavService } from '../../services/nav.service'; -import { Subscription, fromEvent } from 'rxjs'; -import { DomSanitizer } from '@angular/platform-browser'; -import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router'; -import { AppStateService } from '../../services/app-state.service'; -import { NgClass, NgStyle } from '@angular/common'; -import { SimplebarAngularModule } from 'simplebar-angular'; -import { SvgReplaceDirective } from '../../directives/svgReplace.directive'; -@Component({ - selector: 'app-sidebar', - templateUrl: './sidebar.html', - styleUrl: './sidebar.scss', - imports: [NgClass, RouterLink, SimplebarAngularModule, NgStyle, RouterLinkActive, SvgReplaceDirective] -}) - -export class Sidebar { - doublemenuTooltiPosition = 'right' - eventTriggered: boolean = false; - screenWidth!: number; - public localdata = localStorage; - public windowSubscribe$!: Subscription; - options = { autoHide: false, scrollbarMinSize: 100 }; - public menuItems!: Menu[]; - public menuitemsSubscribe$!: Subscription; - constructor( - private navServices: NavService, - public router: Router, - public renderer: Renderer2, - private sanitizer: DomSanitizer, - private appStateService: AppStateService, - private elementRef: ElementRef - ) { } - isDoubleMenu(): boolean { - const htmlElement = document.querySelector('[data-vertical-style="doublemenu"]'); - return htmlElement !== null; - } - - // Method to determine if tooltip should be shown - shouldShowTooltip(menuItem: any): boolean { - return this.isDoubleMenu() && menuItem.title !== ''; - } - clearNavDropdown() { - this.menuItems?.forEach((a: any) => { - a.active = false; - a?.children?.forEach((b: any) => { - b.active = false; - b?.children?.forEach((c: any) => { - c.active = false; - }); - }); - }); - } - - - ngOnInit() { - let bodyElement: any = document.querySelector('.main-content'); - bodyElement.onclick = () => { - if (localStorage.getItem('layoutStyles') == 'icontext' || localStorage.getItem('layoutStyles') == 'icon-hover') { - document.querySelector('html')?.removeAttribute('data-icon-text') - } - - }; - - this.menuitemsSubscribe$ = this.navServices.items.subscribe((items) => { - this.menuItems = items; - }); - - this.setNavActive(null, this.router.url); - this.router.events.subscribe((event) => { - if (event instanceof NavigationEnd) { - this.setNavActive(null, this.router.url); - } - }); - - const WindowResize = fromEvent(window, 'resize'); - // subscribing the Observable - if (WindowResize) { - this.windowSubscribe$ = WindowResize.subscribe(() => { - // to check and adjst the menu on screen size change - // checkHoriMenu(); - }); - } - - if (document.querySelector('html')?.getAttribute('data-nav-layout') == 'horizontal' && window.innerWidth >= 992) { this.clearNavDropdown(); } - } - // Start of Set menu Active event - setNavActive(event: any, currentPath: string, menuData = this.menuItems) { - if (event) { - if (event?.ctrlKey) { - return; - } - } - let html = this.elementRef.nativeElement.ownerDocument.documentElement; - //if (html.getAttribute('data-nav-style') != "icon-hover" && html.getAttribute('data-nav-style') != "menu-hover") { - // if (!event?.ctrlKey) { - for (const item of menuData) { - if (item.path === currentPath) { - item.active = true; - item.selected = true; - this.setMenuAncestorsActive(item); - } else if (!item.active && !item.selected) { - item.active = false; // Set active to false for items not matching the target - item.selected = false; // Set active to false for items not matching the target - } else { - this.removeActiveOtherMenus(item); - } - if (item.children && item.children.length > 0) { - this.setNavActive(event, currentPath, item.children); - } - } - // } - //} - if (window.innerWidth <= 996) { - html?.setAttribute('data-toggled', html?.getAttribute('data-toggled') == 'close' ? 'close' : 'close'); - } - - if (html?.getAttribute('data-vertical-style') == "icontext" && html?.getAttribute('data-icon-text') == 'open' && window.innerWidth >= 992) { html?.setAttribute('data-icon-text', 'close') } - - - } - - getParentObject(obj: any, childObject: Menu) { - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - if (typeof obj[key] === 'object' && JSON.stringify(obj[key]) === JSON.stringify(childObject)) { - return obj; // Return the parent object - } - if (typeof obj[key] === 'object') { - const parentObject: any = this.getParentObject(obj[key], childObject); - if (parentObject !== null) { - return parentObject; - } - } - } - } - return null; // Object not found - } - - hasParent = false; - hasParentLevel = 0; - - setMenuAncestorsActive(targetObject: Menu) { - const parent = this.getParentObject(this.menuItems, targetObject); - let html = document.documentElement; - if (parent) { - if (this.hasParentLevel >= 2) { - this.hasParent = true; - } - parent.active = true; - parent.selected = true; - this.hasParentLevel += 1; - this.setMenuAncestorsActive(parent); - } - else if (!this.hasParent) { - this.hasParentLevel = 0; - if (html.getAttribute('data-vertical-style') == 'doublemenu') { - if (window.innerWidth < 992) { - html.setAttribute('data-toggled', 'close'); - } else { - html.setAttribute('data-toggled', 'double-menu-close'); - } - } - } else { - this.hasParentLevel = 0; - this.hasParent = false; - } - } - removeActiveOtherMenus(item: any) { - if (item) { - if (Array.isArray(item)) { - for (const val of item) { - val.active = false; - val.selected = false; - } - } - item.active = false; - item.selected = false; - - if (item.children && item.children.length > 0) { - this.removeActiveOtherMenus(item.children); - } - } - else { - return; - } - } - - // Start of Toggle menu event - toggleNavActive(event: any, targetObject: Menu, menuData = this.menuItems, state?: any) { - let html = document.documentElement; - let element = event.target; - if (html.getAttribute('data-nav-style') != "icon-hover" && html.getAttribute('data-nav-style') != "menu-hover" || (window.innerWidth < 992) || (html.getAttribute('data-nav-layout') != "horizontal") && (html.getAttribute('data-nav-style') != "icon-hover-closed" && html.getAttribute('data-nav-style') != "menu-hover-closed")) { - for (const item of menuData) { - if (item === targetObject) { - if (html.getAttribute('data-vertical-style') == 'doublemenu' && item.active && window.innerWidth > 992 && state) { return } - item.active = !item.active; - if (item.active) { - this.closeOtherMenus(menuData, item); - } - this.setAncestorsActive(menuData, item); - - } else if (!item.active) { - if (html.getAttribute('data-vertical-style') != 'doublemenu') { - item.active = false; // Set active to false for items not matching the target - } - } - if (item.children && item.children.length > 0) { - this.toggleNavActive(event, targetObject, item.children); - } - } - if (targetObject?.children && targetObject.active) { - if (html.getAttribute('data-vertical-style') == 'doublemenu' && html.getAttribute('data-toggled') != 'double-menu-open') { - html.setAttribute('data-toggled', 'double-menu-open'); - } - } - - if (element && html.getAttribute("data-nav-layout") == 'horizontal' && (html.getAttribute("data-nav-style") == 'menu-click' || html.getAttribute("data-nav-style") == 'icon-click')) { - const listItem = element.closest("li"); - if (listItem) { - // Find the first sibling
    element - const siblingUL = listItem.querySelector("ul"); - let outterUlWidth = 0; - let listItemUL = listItem.closest('ul:not(.main-menu)'); - while (listItemUL) { - listItemUL = listItemUL.parentElement.closest('ul:not(.main-menu)'); - if (listItemUL) { - outterUlWidth += listItemUL.clientWidth; - } - } - if (siblingUL) { - // You've found the sibling
      element - let siblingULRect = listItem.getBoundingClientRect(); - if (html.getAttribute('dir') == 'rtl') { - if ((siblingULRect.left - siblingULRect.width - outterUlWidth + 150 < 0 && outterUlWidth < window.innerWidth) && (outterUlWidth + siblingULRect.width + siblingULRect.width < window.innerWidth)) { - targetObject.dirchange = true; - } else { - targetObject.dirchange = false; - } - } else { - if ((outterUlWidth + siblingULRect.right + siblingULRect.width + 50 > window.innerWidth && siblingULRect.right >= 0) && (outterUlWidth + siblingULRect.width + siblingULRect.width < window.innerWidth)) { - targetObject.dirchange = true; - } else { - targetObject.dirchange = false; - } - } - } - setTimeout(() => { - let computedValue = siblingUL.getBoundingClientRect(); - if ((computedValue.bottom) > window.innerHeight) { - siblingUL.style.height = (window.innerHeight - computedValue.top - 8) + 'px !important'; - siblingUL.style.overflow = 'auto !important'; - } - }, 100); - } - } - } - else { - for (const item of menuData) { - if (item === targetObject) { - if (html.getAttribute('data-vertical-style') == 'doublemenu' && item.active && window.innerWidth > 992 && state) { return } - item.active = !item.active; - if (item.active) { - this.closeOtherMenus(menuData, item); - } - this.setAncestorsActive(menuData, item); - } - } - } - - if (html.getAttribute('data-vertical-style') == 'icontext') { - document.querySelector('html')?.setAttribute('data-icon-text', 'open') - } else { - document.querySelector('html')?.removeAttribute('data-icon-text') - } - - } - - - setAncestorsActive(menuData: Menu[], targetObject: Menu) { - let html = document.documentElement; - const parent = this.findParent(menuData, targetObject); - - if (parent) { - parent.active = true; - if (parent.active) { - html.setAttribute('data-toggled', 'double-menu-open'); - } - this.setAncestorsActive(menuData, parent); - } - } - closeOtherMenus(menuData: Menu[], targetObject: Menu) { - for (const item of menuData) { - if (item !== targetObject) { - item.active = false; - if (item.children && item.children.length > 0) { - this.closeOtherMenus(item.children, targetObject); - } - } - } - } - findParent(menuData: Menu[], targetObject: Menu) { - for (const item of menuData) { - if (item.children && item.children.includes(targetObject)) { - return item; - } - if (item.children && item.children.length > 0) { - const parent: any = this.findParent(item.children, targetObject); - if (parent) { - return parent; - } - } - } - return null; - } - // End of Toggle menu event - HoverToggleInnerMenuFn(event: Event, item: Menu) { - let html = document.documentElement; - let element = event.target as HTMLElement; - if (element && html.getAttribute("data-nav-layout") == 'horizontal' && (html.getAttribute("data-nav-style") == 'menu-hover' || html.getAttribute("data-nav-style") == 'icon-hover')) { - const listItem = element.closest("li"); - if (listItem) { - // Find the first sibling
        element - const siblingUL = listItem.querySelector("ul"); - let outterUlWidth = 0; - let listItemUL: any = listItem.closest('ul:not(.main-menu)'); - while (listItemUL) { - listItemUL = listItemUL.parentElement?.closest('ul:not(.main-menu)'); - if (listItemUL) { - outterUlWidth += listItemUL.clientWidth; - } - } - if (siblingUL) { - // You've found the sibling
          element - let siblingULRect = listItem.getBoundingClientRect(); - if (html.getAttribute('dir') == 'rtl') { - if ((siblingULRect.left - siblingULRect.width - outterUlWidth + 150 < 0 && outterUlWidth < window.innerWidth) && (outterUlWidth + siblingULRect.width + siblingULRect.width < window.innerWidth)) { - item.dirchange = true; - } else { - item.dirchange = false; - } - } else { - if ((outterUlWidth + siblingULRect.right + siblingULRect.width + 50 > window.innerWidth && siblingULRect.right >= 0) && (outterUlWidth + siblingULRect.width + siblingULRect.width < window.innerWidth)) { - item.dirchange = true; - } else { - item.dirchange = false; - } - } - } - } - } - } - - ngAfterViewInit(): void { - //Called after ngAfterContentInit when the component's view has been initialized. Applies to components only. - //Add 'implements AfterViewInit' to the class. - // checkHoriMenu(); - - } - - ngOnDestroy() { - this.menuitemsSubscribe$.unsubscribe(); - this.windowSubscribe$.unsubscribe(); - document.querySelector('html')?.setAttribute('data-vertical-style', 'overlay'); - document.querySelector('html')?.setAttribute('data-nav-layout', 'vertical'); - } - - leftArrowFn() { - // Used to move the slide of the menu in Horizontal and also remove the arrows after click if there was no space - // Used to Slide the menu to Left side - let slideLeft = document.querySelector('.slide-left') as HTMLElement; - let slideRight = document.querySelector('.slide-right') as HTMLElement; - let menuNav = document.querySelector('.main-menu') as HTMLElement; - let mainContainer1 = document.querySelector('.main-sidebar') as HTMLElement; - let marginRightValue = Math.ceil(Number(window.getComputedStyle(menuNav).marginInlineStart.split('px')[0])); - let mainContainer1Width = mainContainer1.offsetWidth; - if (menuNav.scrollWidth > mainContainer1.offsetWidth) { - if (marginRightValue < 0 && !(Math.abs(marginRightValue) < mainContainer1Width)) { - menuNav.style.marginInlineStart = Number(menuNav.style.marginInlineStart.split('px')[0]) + Math.abs(mainContainer1Width) + 'px'; - slideRight.classList.remove('d-none'); - } else if (marginRightValue >= 0) { - menuNav.style.marginInlineStart = '0px'; - slideLeft.classList.add('d-none'); - slideRight.classList.remove('d-none'); - } else { - menuNav.style.marginInlineStart = '0px'; - slideLeft.classList.add('d-none'); - slideRight.classList.remove('d-none'); - } - } - else { - menuNav.style.marginInlineStart = "0px"; - slideLeft.classList.add('d-none'); - } - - let element = document.querySelector(".main-menu > .slide.open") as HTMLElement; - let element1 = document.querySelector(".main-menu > .slide.open >ul") as HTMLElement; - if (element) { - element.classList.remove("open") - } - if (element1) { - element1.style.display = "none" - } - } - rightArrowFn() { - // Used to move the slide of the menu in Horizontal and also remove the arrows after click if there was no space - // Used to Slide the menu to Right side - let slideLeft = document.querySelector('.slide-left') as HTMLElement; - let slideRight = document.querySelector('.slide-right') as HTMLElement; - let menuNav = document.querySelector('.main-menu') as HTMLElement; - let mainContainer1 = document.querySelector('.main-sidebar') as HTMLElement; - let marginRightValue = Math.ceil(Number(window.getComputedStyle(menuNav).marginInlineStart.split('px')[0])); - let check = menuNav.scrollWidth - mainContainer1.offsetWidth; - let mainContainer1Width = mainContainer1.offsetWidth; - if (menuNav.scrollWidth > mainContainer1.offsetWidth) { - if (Math.abs(check) > Math.abs(marginRightValue)) { - if (!(Math.abs(check) > Math.abs(marginRightValue) + mainContainer1Width)) { - mainContainer1Width = Math.abs(check) - Math.abs(marginRightValue); - slideRight.classList.add('d-none'); - } - menuNav.style.marginInlineStart = Number(menuNav.style.marginInlineStart.split('px')[0]) - Math.abs(mainContainer1Width) + 'px'; - slideLeft.classList.remove('d-none'); - } - } - - let element = document.querySelector(".main-menu > .slide.open") as HTMLElement - let element1 = document.querySelector(".main-menu > .slide.open >ul") as HTMLElement - if (element) { - element.classList.remove("open") - } - if (element1) { - element1.style.display = "none" - } - } - - // Addding sticky-pin - scrolled = false; - - @HostListener('window:scroll', []) - onWindowScroll() { - this.scrolled = window.scrollY > 10; - - const sections = document.querySelectorAll('.side-menu__item'); - const scrollPos = - window.pageYOffset || - document.documentElement.scrollTop || - document.body.scrollTop; - - sections.forEach((ele, i) => { - const currLink = sections[i]; - const val: any = currLink.getAttribute('value'); - const refElement: any = document.querySelector('#' + val); - - // Add a null check here before accessing properties of refElement - if (refElement !== null) { - const scrollTopMinus = scrollPos + 73; - if ( - refElement.offsetTop <= scrollTopMinus && - refElement.offsetTop + refElement.offsetHeight > scrollTopMinus - ) { - document.querySelector('.nav-scroll')?.classList.remove('active'); - currLink.classList.add('active'); - } else { - currLink.classList.remove('active'); - } - } - }); - } - - @HostListener('window:resize', ['$event']) - onResize(event: any): void { - this.menuResizeFn(); - - this.screenWidth = window.innerWidth; - - // Check if the event hasn't been triggered and the screen width is less than or equal to your breakpoint - if (!this.eventTriggered && this.screenWidth <= 992) { - document.documentElement?.setAttribute('data-toggled', 'close') - - - // Trigger your event or perform any action here - this.eventTriggered = true; // Set the flag to true to prevent further triggering - } else if (this.screenWidth > 992) { - // Reset the flag when the screen width goes beyond the breakpoint - this.eventTriggered = false; - } - } - WindowPreSize: number[] = [window.innerWidth]; - - menuResizeFn(): void { - this.WindowPreSize.push(window.innerWidth); - const html = document.documentElement; - - if (this.WindowPreSize.length > 2) { - this.WindowPreSize.shift(); - } - if (this.WindowPreSize.length > 1) { - - if (this.WindowPreSize[this.WindowPreSize.length - 1] < 996 && this.WindowPreSize[this.WindowPreSize.length - 2] >= 996) { - // less than 996 - html.setAttribute('data-toggled', 'close'); - } - - if (this.WindowPreSize[this.WindowPreSize.length - 1] >= 996 && this.WindowPreSize[this.WindowPreSize.length - 2] < 996) { - // greater than 996 - html.removeAttribute('data-toggled'); - document.querySelector('#responsive-overlay')?.classList.remove('active'); - } - } - - if ((this.WindowPreSize[this.WindowPreSize.length - 1] >= 996) && (this.WindowPreSize[this.WindowPreSize.length - 2] < 996)) { - if (html.getAttribute('data-vertical-style') === 'doublemenu') { - const doublemenuactive = document.querySelectorAll(".double-menu-active .active"); - if (doublemenuactive.length > 0) { - html.setAttribute('data-toggled', 'double-menu-open'); - - } else { - html.setAttribute('data-toggled', 'double-menu-close'); - } - } else { - html.setAttribute('data-toggled', ''); - } - } - } - -} diff --git a/src/app/shared/components/switcher/switcher.html b/src/app/shared/components/switcher/switcher.html deleted file mode 100644 index fbb7512f..00000000 --- a/src/app/shared/components/switcher/switcher.html +++ /dev/null @@ -1,543 +0,0 @@ - - diff --git a/src/app/shared/components/switcher/switcher.scss b/src/app/shared/components/switcher/switcher.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/components/switcher/switcher.ts b/src/app/shared/components/switcher/switcher.ts deleted file mode 100644 index 1eb50a51..00000000 --- a/src/app/shared/components/switcher/switcher.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { Component, DOCUMENT, ElementRef, inject, Renderer2 } from '@angular/core'; -import { AppStateService } from '../../services/app-state.service'; -import { ColorPickerDirective } from 'ngx-color-picker'; -@Component({ - selector: 'app-switcher', - templateUrl: './switcher.html', - styleUrls: ['./switcher.scss'], - imports: [ColorPickerDirective] -}) -export class Switcher { - public localdata: any; - private document = inject(DOCUMENT); - constructor( - private elementRef: ElementRef, - private appStateService: AppStateService, - private renderer: Renderer2 - ) { - this.appStateService.state$.subscribe(state => { - this.localdata = state; - - }); - } - - - updateTheme(theme: string) { - - this.appStateService.updateState({ theme, menuColor: theme, headerColor: theme }); - if (theme == 'light') { - this.appStateService.updateState({ theme, themeBackground: '', headerColor: 'light', menuColor: 'dark' }); - let html = document.querySelector('html'); - html?.style.removeProperty('--color-bodybg'); - html?.style.removeProperty('--color-bodybg2'); - html?.style.removeProperty('--color-light'); - html?.style.removeProperty('--color-formcontrolbg'); - html?.style.removeProperty('--color-inputborder'); - html?.style.removeProperty('--color-gray3'); - if (window.innerWidth <= 992) { - html?.setAttribute('data-toggled', 'close'); - } - } - if (theme == 'dark') { - this.appStateService.updateState({ theme, themeBackground: '', headerColor: 'dark', menuColor: 'dark' }); - let html = document.querySelector('html'); - html?.style.removeProperty('--color-bodybg'); - html?.style.removeProperty('--color-bodybg2'); - html?.style.removeProperty('--color-light'); - html?.style.removeProperty('--color-formcontrolbg'); - html?.style.removeProperty('--color-inputborder'); - html?.style.removeProperty('--color-gray3'); - if (window.innerWidth <= 992) { - html?.setAttribute('data-toggled', 'close'); - } - } - } - - - updateDirection(direction: string) { - let html = this.elementRef.nativeElement.ownerDocument.documentElement; - this.appStateService.updateState({ direction }); - } - updatemenuType(navigationStyles: string) { - - - this.appStateService.updateState({ navigationStyles }); - if (navigationStyles == 'horizontal') { - this.appStateService.updateState({ navigationStyles, menuStyles: 'menu-click', layoutStyles: '', }); - const menuclickclosed = document.getElementById( - 'switcher-menu-click' - ) as HTMLInputElement; - - menuclickclosed.checked = true; - setTimeout(() => { - const mainContentElement = document.querySelector(".main-content") as HTMLElement | null; - if (mainContentElement) { - mainContentElement.click(); - } - }, 100); - } else if (navigationStyles == 'vertical') { - this.appStateService.updateState({ navigationStyles, menuStyles: '', layoutStyles: 'default', }); - - } - - } - - - updatemenuStyle(menuStyles: string) { - - this.appStateService.updateState({ menuStyles, layoutStyles: '' }); - - - const navStyle = document.documentElement.getAttribute('data-nav-style'); - - if (navStyle === 'icon-hover') { - document.querySelector('.double-menu-active')?.setAttribute('style', 'display: none;'); - const Sidebar: any = document.querySelector(".main-menu"); - if (Sidebar) { - Sidebar.style.marginInline = "0px"; - } - } - - if (navStyle === 'icon-click') { - const Sidebar: any = document.querySelector(".main-menu"); - if (Sidebar) { - Sidebar.style.marginInline = "0px"; - } - - } - } - updatelayoutStyles(layoutStyles: string) { - this.appStateService.updateState({ layoutStyles, menuStyles: '', navigationStyles: '' }); - if (document.querySelector('html')?.getAttribute('data-vertical-style') == 'doublemenu') { - document.querySelector('.slide-menu')?.classList.add('double-menu-active'); - } - else { - document.querySelector('.slide-menu')?.classList.remove('double-menu-active'); - } - } - updatepageStyles(pageStyles: string) { - this.appStateService.updateState({ pageStyles }); - } - updatewidthStyles(widthStyles: string) { - this.appStateService.updateState({ widthStyles }); - } - updatemenuPosition(menuPosition: string) { - this.appStateService.updateState({ menuPosition }); - } - updateheaderPosition(headerPosition: string) { - this.appStateService.updateState({ headerPosition }); - } - updatemenuColor(menuColor: string) { - this.appStateService.updateState({ menuColor }); - } - updateheaderColor(headerColor: string) { - this.appStateService.updateState({ headerColor: headerColor }); - } - updateprimary(themePrimary: string) { - this.appStateService.updateState({ themePrimary: `rgb(${themePrimary})` }); - } - updateBackground(themeBackground: any) { - const background = { - main: `rgb(${themeBackground.main})`, - secondary: `rgb(${themeBackground.secondary})`, - accent: `rgb(${themeBackground.accent})`, - overlay: themeBackground.overlay, - primary: themeBackground.primary, - theme: themeBackground.theme - } - this.appStateService.updateState({ themeBackground: background, menuColor: 'dark', headerColor: 'dark', theme: "dark" }); - } - updateBgImage(backgroundImage: string) { - this.appStateService.updateState({ backgroundImage }); - } - - - defaultPrimary = '#6c5ffc'; - public dynamicLightPrimary(data: any): void { - this.defaultPrimary = data.color; - let primaryColor = this.convertRgbToIndividual(this.defaultPrimary) - - this.updateprimary(primaryColor); - - } - - //background theme change - convertRgbToIndividual(value: string): string { - // Use a regular expression to extract the numeric values - const numericValues = value.match(/\d+/g) || []; - // Join the numeric values with spaces to get the desired format - return numericValues.join(' '); - } - public defaultBg = '#6c5ffc'; - public dynamicTranparentBgPrimary(data: any): void { - this.defaultBg = data.color; - let bgRgb = this.convertRgbToIndividual(this.defaultBg); - let bgRgb2 = this.convertRgbToIndividual(this.defaultBg); - let bg1Update = bgRgb.split(' ').join(', '); - let bg2Update: any = bgRgb2.split(' '); - bg2Update[0] = Number(bg2Update[0]) + 14; - bg2Update[1] = Number(bg2Update[1]) + 14; - bg2Update[2] = Number(bg2Update[2]) + 14; - let bgColor = { - main: bg1Update, secondary: bg2Update.join(', '), - accent: bg2Update.join(', '), overlay: 'rgba(255,255,255,0.1)', - theme: 'dark', - } - this.updateBackground(bgColor); - } - reset() { - this.appStateService.applyReset(); - } -} diff --git a/src/app/shared/components/tab-to-top/tab-to-top.html b/src/app/shared/components/tab-to-top/tab-to-top.html deleted file mode 100644 index 1ac3e07b..00000000 --- a/src/app/shared/components/tab-to-top/tab-to-top.html +++ /dev/null @@ -1,6 +0,0 @@ - -
          - -
          \ No newline at end of file diff --git a/src/app/shared/components/tab-to-top/tab-to-top.scss b/src/app/shared/components/tab-to-top/tab-to-top.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/components/tab-to-top/tab-to-top.ts b/src/app/shared/components/tab-to-top/tab-to-top.ts deleted file mode 100644 index 3c508544..00000000 --- a/src/app/shared/components/tab-to-top/tab-to-top.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ViewportScroller, NgStyle } from '@angular/common'; -import { Component, HostListener, inject } from '@angular/core'; - -@Component({ - selector: 'app-tab-to-top', - templateUrl: './tab-to-top.html', - styleUrl: './tab-to-top.scss', - imports: [NgStyle] -}) -export class TabToTop { - private viewScroller = inject(ViewportScroller); - - public show: boolean = false; - - ngOnInit(): void { - } - - @HostListener("window:scroll", []) - - onWindowScroll() { - let number = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; - if (number > 150) { - this.show = true; - } else { - this.show = false; - } - } - - taptotop() { - let body: any = document.querySelector('body') - body.style.scrollBehavior = 'smooth'; - } -} - - diff --git a/src/app/shared/layouts/authentication-layout/authentication-layout.html b/src/app/shared/layouts/authentication-layout/authentication-layout.html deleted file mode 100644 index f2dc9c19..00000000 --- a/src/app/shared/layouts/authentication-layout/authentication-layout.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/app/shared/layouts/authentication-layout/authentication-layout.scss b/src/app/shared/layouts/authentication-layout/authentication-layout.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/layouts/authentication-layout/authentication-layout.ts b/src/app/shared/layouts/authentication-layout/authentication-layout.ts deleted file mode 100644 index 85f07795..00000000 --- a/src/app/shared/layouts/authentication-layout/authentication-layout.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; - -@Component({ - selector: 'app-authentication-layout', - templateUrl: './authentication-layout.html', - styleUrl: './authentication-layout.scss', - imports: [RouterOutlet] -}) -export class AuthenticationLayout { - -} - - diff --git a/src/app/shared/layouts/content-layout/content-layout.html b/src/app/shared/layouts/content-layout/content-layout.html deleted file mode 100644 index 4609eac5..00000000 --- a/src/app/shared/layouts/content-layout/content-layout.html +++ /dev/null @@ -1,46 +0,0 @@ - - -
          - - -
          -
          - - - - -
          -
          - - - -
          -
          - -@if (sessionTimeoutService.showWarning()) { -
          -
          -
          -
          -
          - - - -
          -

          Session Expiring Soon

          -

          - You have been inactive. You will be signed out in {{ sessionTimeoutService.remainingSeconds() }} seconds unless you continue your session. -

          -
          -
          -
          - -
          -
          -
          -} - diff --git a/src/app/shared/layouts/content-layout/content-layout.scss b/src/app/shared/layouts/content-layout/content-layout.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/shared/layouts/content-layout/content-layout.ts b/src/app/shared/layouts/content-layout/content-layout.ts deleted file mode 100644 index dee0a12f..00000000 --- a/src/app/shared/layouts/content-layout/content-layout.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Component, DOCUMENT, ElementRef, Renderer2, ViewChild, inject } from '@angular/core'; -import { Menu, NavService } from '../../services/nav.service'; -import { Router, RouterOutlet } from '@angular/router'; -import { AppStateService } from '../../services/app-state.service'; -import { Switcher } from '../../components/switcher/switcher'; -import { Header } from '../../components/header/header'; -import { Sidebar } from '../../components/sidebar/sidebar'; -import { HoverEffectSidebarDirective } from '../../directives/hover-effect-sidebar.directive'; -import { PageHeader } from '../../components/page-header/page-header'; -import { Footer } from '../../components/footer/footer'; -import { TabToTop } from '../../components/tab-to-top/tab-to-top'; -import { AppLoader } from '../../components/app-loader/app-loader'; -import { SessionTimeoutService } from '../../../core/services/session-timeout.service'; -import { AppContextService } from '../../../core/services/app-context.service'; -import { AuthService } from '../../../core/auth/auth.service'; -@Component({ - selector: 'app-content-layout', - templateUrl: './content-layout.html', - styleUrl: './content-layout.scss', - providers: [SessionTimeoutService], - imports: [ - Switcher, - Header, - Sidebar, - HoverEffectSidebarDirective, - PageHeader, - RouterOutlet, - Footer, - TabToTop, - AppLoader, - ], -}) -export class ContentLayout { - navServices = inject(NavService); - sessionTimeoutService = inject(SessionTimeoutService); - private readonly appContextService = inject(AppContextService); - private readonly authService = inject(AuthService); - private document = inject(DOCUMENT); - private appStateService = inject(AppStateService); - private elementRef = inject(ElementRef); - private renderer = inject(Renderer2); - @ViewChild('responsiveoverlay') responsiveoverlay!: ElementRef; - public menuItems!: Menu[]; - - constructor() { - this.sessionTimeoutService.start(); - - if (this.authService.isLoggedIn) { - this.appContextService.ensureMenuInitialized().subscribe(); - } - - this.navServices.items.subscribe((menuItems: any) => { - this.menuItems = menuItems; - }); - let html = this.document.documentElement; - this.appStateService.state$.subscribe(state => { - if (state) { - if (window.innerWidth <= 996) { - html?.setAttribute('data-toggled', html?.getAttribute('data-toggled') == 'close' ? 'close' : 'close'); - } - if (state.menuStyles == 'menu-hover' || state.menuStyles == 'icon-hover') { - this.clearNavDropdown() - } - } - - }); - } - - clearNavDropdown() { - - - this.menuItems?.forEach((a: any) => { - a.active = false; - a?.children?.forEach((b: any) => { - b.active = false; - b?.children?.forEach((c: any) => { - c.active = false; - }); - }); - }); - } - - clickOnBody() { - this.responsiveoverlay.nativeElement.classList.remove('active'); - const htmlElement = this.document.documentElement; - this.renderer.removeAttribute(htmlElement, 'data-icon-overlay'); - if (window.innerWidth <= 996) { - htmlElement?.setAttribute('data-toggled', htmlElement?.getAttribute('data-toggled') == 'close' ? 'close' : 'close'); - } - const navStyle = htmlElement.getAttribute('data-nav-style'); - - if (htmlElement.getAttribute('data-toggled') == 'icon-text-close') { - this.renderer.removeAttribute(htmlElement, 'data-icon-text'); - } - if (htmlElement.getAttribute('data-nav-layout') == 'horizontal' - && window.innerWidth > 996) { - this.clearNavDropdown(); - } - else - if (navStyle === 'menu-click' || navStyle === 'menu-hover' || navStyle === 'icon-click' || navStyle === 'icon-hover') { - document.querySelector('.double-menu-active')?.setAttribute('style', 'display: none;'); - } - - const switcher = this.elementRef.nativeElement.querySelector('.switcher'); - if (switcher) { - this.renderer.removeClass(switcher, 'show'); - this.responsiveoverlay.nativeElement.classList.add('active'); - } else { - this.responsiveoverlay.nativeElement.classList.remove('active'); - } - const sidebar = this.elementRef.nativeElement.querySelector('.sidebar'); - if (sidebar) { - this.renderer.removeClass(sidebar, 'show'); - - } - - - } - - closeMenu() { - this.menuItems?.forEach((a: any) => { - if (this.menuItems) { - a.active = false; - } - a?.children?.forEach((b: any) => { - if (a.children) { - b.active = false; - } - }); - }); - } - - - clearToggle() { - let html = this.elementRef.nativeElement.ownerDocument.documentElement; - html?.setAttribute('data-toggled', 'close'); - document.querySelector('#responsive-overlay')?.classList.remove('active'); - } - - -} - - diff --git a/src/app/shared/services/app-state.service.ts b/src/app/shared/services/app-state.service.ts deleted file mode 100644 index 76d41601..00000000 --- a/src/app/shared/services/app-state.service.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { DOCUMENT, ElementRef, inject, Injectable, Renderer2 } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; - -interface StateType { - direction: string; - theme: string; - navigationStyles: string, // vertical, horizontal - menuStyles: string, // menu-click, menu-hover, icon-click, icon-hover - layoutStyles: string, // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu - pageStyles: string, // regular, classic, modern - widthStyles: string, // fullwidth, boxed - menuPosition: string, // fixed, scrollable - headerPosition: string, // fixed, scrollable - menuColor: string, // light, dark, color, gradient, transparent - headerColor: string, // light, dark, color, gradient, transparent - themePrimary: string, // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90' - themeBackground: string, - backgroundImage: string, -}; -@Injectable({ - providedIn: 'root' -}) -export class AppStateService { - private readonly localStorageKey = 'Ynex-ng'; // Customize this key - private initialState: StateType = { - theme: 'light', // light, dark - direction: 'ltr', // ltr, rtl - navigationStyles: 'vertical', // vertical, horizontal - menuStyles: '', // menu-click, menu-hover, icon-click, icon-hover - layoutStyles: 'default', // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu - pageStyles: 'regular', // regular, classic, modern - widthStyles: 'fullwidth', // fullwidth, boxed - menuPosition: 'fixed', // fixed, scrollable - headerPosition: 'fixed', // fixed, scrollable - menuColor: 'dark', // light, dark, color, gradient, transparent - headerColor: 'light', // light, dark, color, gradient, transparent - themePrimary: '', // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90' - themeBackground: '', - backgroundImage: '', // bgimg1, bgimg2, bgimg3, bgimg4, bgimg5 - } // Store initial state - private stateSubject = new BehaviorSubject(this.initialState); // Use any for initial null value - state$ = this.stateSubject.asObservable(); - private document = inject(DOCUMENT); - navigationStyles: any; - private html = this.document.documentElement; - - constructor() { - - const initialState: StateType = this.getInitialStateFromLocalStorage(); - // this.initializeState(); - this.stateSubject.next(initialState); - - } - - private getInitialStateFromLocalStorage(): StateType { - try { - const storedState = localStorage.getItem(this.localStorageKey); - if (storedState) { - return JSON.parse(storedState); - } - } catch (error) { - console.error('Error retrieving initial state from local storage:', error); - } - return this.initialState; - } - - - - - getupdateState() { - const currentState = this.stateSubject.getValue(); - return currentState - } - - updateState(newState?: Partial) { // Use any for partial updates - const currentState = this.stateSubject.getValue(); // Get current state - - if (!currentState) { - - // Handle initial update case (no state emitted yet) - this.updateStateAndEmit(newState); - return; - } - if (newState) { - - const updatedState = { ...currentState, ...newState }; // Merge updates - this.updateStateAndEmit(updatedState); // Update and emit combined state - } else { - this.updateStateAndEmit(currentState); - - return; - } - } - - private state: { [key: string]: any } = {}; - getState(menuStyles: string): any { - return this.state[menuStyles]; - } - private applyThemeBackgroundSpecificChanges(background: any) { - - this.html?.style.setProperty('--color-bodybg', background.main); - this.html?.style.setProperty('--color-bodybg2', background.secondary); - this.html?.style.setProperty('--color-light', background.accent); - this.html?.style.setProperty('--color-formcontrolbg', `rgba(${background.accent})`); - this.html?.style.setProperty('--color-inputborder', background.overlay); - this.html?.style.setProperty('--color-gray3', background.primary); - this.applythemeSpecificChanges(background.theme); - } - - - private applyDirectionSpecificChanges(direction: string) { - - - this.html?.setAttribute('dir', direction); - - } - private applythemeSpecificChanges(theme: string) { - - - this.html?.setAttribute('class', theme); //setting theme style - this.html?.setAttribute('data-header-styles', theme); //setting header style - - - - - - } - - - - private applyNavigationStylesSpecificChanges(navigationStyles: string) { - - - - this.html?.setAttribute('data-nav-layout', navigationStyles); - if (navigationStyles == 'horizontal') { - this.html?.setAttribute('data-nav-style', 'menu-click'); - this.html?.removeAttribute('data-vertical-style'); - - } - } - private applyMenuStylesSpecificChanges(menuStyles: string) { - - - - this.html?.setAttribute('data-nav-style', menuStyles); - this.html?.setAttribute('data-toggled', menuStyles + '-closed'); - this.html?.removeAttribute('data-vertical-style'); - } - private applyLayoutStylesSpecificChanges(layoutStyles: string) { - - this.html?.setAttribute('data-vertical-style', layoutStyles); - this.html?.removeAttribute('data-nav-style'); - switch (layoutStyles) { - case 'default': - this.html?.setAttribute('data-vertical-style', 'overlay'); - this.html?.setAttribute('data-toggled', ''); - break; - case 'closed': - this.html?.setAttribute('data-toggled', 'close-menu-close'); - break; - case 'icontext': - this.html?.setAttribute('data-toggled', 'icon-text-close'); - break; - case 'overlay': - this.html?.setAttribute('data-toggled', 'icon-overlay-close'); - break; - case 'detached': - this.html?.setAttribute('data-toggled', 'detached-close'); - break; - case 'doublemenu': - this.html?.setAttribute('data-toggled', 'double-menu-open'); - break; - } - if (layoutStyles === 'icon-text') { - this.html?.setAttribute('icon-text', 'open'); - } else { - // If not 'icon-text', remove the icon-text attribute - this.html?.removeAttribute('icon-text'); - } - } - private applypageStylesSpecificChanges(pageStyles: string) { - - this.html?.setAttribute('data-page-style', pageStyles); - - const slideRight = document.querySelector('.slide-right') as HTMLElement | null; - if (slideRight) { - // If the element exists, toggle the 'd-none' class - if (slideRight.classList.contains('d-none')) { - slideRight.classList.remove('d-none'); - } else { - slideRight.classList.add('d-none'); - } - } else { - // If the element does not exist (is null), create a safe fallback by adding 'd-none' - const dummySlideRight = document.createElement('div'); - dummySlideRight.classList.add('slide-right', 'd-none'); // Add classes to the new element - document.body.appendChild(dummySlideRight); // Append it to the DOM as a fallback - } - } - private applywidthStylesSpecificChanges(widthStyles: string) { - - this.html?.setAttribute('data-width', widthStyles); - } - private applymenuPositionSpecificChanges(menuPosition: string) { - - this.html?.setAttribute('data-menu-position', menuPosition); - } - private applyheaderPositionSpecificChanges(headerPosition: string) { - - this.html?.setAttribute('data-header-position', headerPosition); - } - private applyheaderColorSpecificChanges(headerColor: string) { - - this.html?.setAttribute('data-header-styles', headerColor); - } - private applymenuColorSpecificChanges(menuColor: string) { - - this.html?.setAttribute('data-menu-styles', menuColor); - } - private applyPrimarySpecificChanges(primary: string) { - - this.html?.style.setProperty('--color-primaryrgb', primary); - this.html?.style.setProperty('--color-primary', primary); - } - private applybackgroundImageSpecificChanges(backgroundImage: string) { - - this.html?.setAttribute('bg-img', backgroundImage); - } - - - - - public applyReset() { - - if (this.html) { - this.html?.style.removeProperty('--color-bodybg'); - this.html?.style.removeProperty('--color-gray3'); - this.html?.style.removeProperty('--color-bodybg2'); - this.html?.style.removeProperty('--color-light'); - this.html?.style.removeProperty('--color-formcontrolbg'); - this.html?.style.removeProperty('--color-inputborder'); - this.html?.style.removeProperty('--color-primary'); - this.html?.style.removeProperty('--color-primaryrgb'); - - } - this.html?.removeAttribute('bg-img'); - this.html?.setAttribute('data-vertical-style', 'overlay'); - this.stateSubject.next(this.initialState); - this.updateStateAndEmit(this.initialState); - localStorage.clear(); - - if (window.innerWidth <= 992) { - this.html?.setAttribute('data-toggled', 'close'); - } - } - - private updateStateAndEmit(state: any) { - // Conditional logic based on direction changes - - const currentState = this.stateSubject.getValue(); // Get current state - // Conditional logic based on theme changes - if (state['theme']) { - - this.applythemeSpecificChanges(state['theme']); - } - if (state['direction']) { - - this.applyDirectionSpecificChanges(state['direction']); - } - // Conditional logic based on theme changes - if (state['navigationStyles']) { - this.applyNavigationStylesSpecificChanges(state['navigationStyles']); - } - // Conditional logic based on theme changes - if (state['menuStyles'] && !state['layoutStyles']) { - this.applyMenuStylesSpecificChanges(state['menuStyles']); - } - if (state['layoutStyles'] && !state['menuStyles']) { - this.applyLayoutStylesSpecificChanges(state['layoutStyles']); - } - if (state['pageStyles']) { - this.applypageStylesSpecificChanges(state['pageStyles']); - } - if (state['widthStyles']) { - this.applywidthStylesSpecificChanges(state['widthStyles']); - } - if (state['menuPosition']) { - this.applymenuPositionSpecificChanges(state['menuPosition']); - } - if (state['headerPosition']) { - this.applyheaderPositionSpecificChanges(state['headerPosition']); - } - if (state['themePrimary']) { - this.applyPrimarySpecificChanges(state['themePrimary']); - } - if (state['themeBackground']) { - this.applyThemeBackgroundSpecificChanges(state['themeBackground']); - } - if (state['headerColor']) { - this.applyheaderColorSpecificChanges(state['headerColor']); - } - if (state['menuColor']) { - this.applymenuColorSpecificChanges(state['menuColor']); - } - if (state['backgroundImage']) { - this.applybackgroundImageSpecificChanges(state['backgroundImage']); - } - - this.stateSubject.next(state); - this.updateLocalStorage(state); - } - - private updateLocalStorage(state: any) { - try { - localStorage.setItem(this.localStorageKey, JSON.stringify(state)); - } catch (error) { - console.error('Error saving state to local storage:', error); - } - } -} diff --git a/src/app/shared/services/auth.service.ts b/src/app/shared/services/auth.service.ts deleted file mode 100644 index 2fad1ee2..00000000 --- a/src/app/shared/services/auth.service.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Injectable, NgZone, inject } from '@angular/core'; -import { AngularFireModule } from '@angular/fire/compat'; -import { AngularFireAuth } from '@angular/fire/compat/auth'; -import { Router } from '@angular/router'; -import { environment } from '../../../environments/environment'; -import { AngularFirestoreDocument } from '@angular/fire/compat/firestore'; -export interface User { - uid: string; - email: string; - displayName: string; - photoURL: string; - emailVerified: boolean; -} -@Injectable({ - providedIn: 'root', -}) -export class AuthService { - private afu = inject(AngularFireAuth); - private router = inject(Router); - ngZone = inject(NgZone); - - authState: any; - afAuth: any; - afs: any; - public showLoader:boolean=false; - - constructor() { - this.afu.authState.subscribe((auth: any) => { - this.authState = auth; - }); - - } - - // all firebase getdata functions - - get isUserAnonymousLoggedIn(): boolean { - return this.authState !== null ? this.authState.isAnonymous : false; - } - - get currentUserId(): string { - return this.authState !== null ? this.authState.uid : ''; - } - - get currentUserName(): string { - return this.authState['email']; - } - - get currentUser(): any { - return this.authState !== null ? this.authState : null; - } - - get isUserEmailLoggedIn(): boolean { - if (this.authState !== null && !this.isUserAnonymousLoggedIn) { - return true; - } else { - return false; - } - } - - registerWithEmail(email: string, password: string) { - return this.afu - .createUserWithEmailAndPassword(email, password) - .then((user: any) => { - this.authState = user; - }) - .catch((_error: any) => { - console.log(_error); - throw _error; - }); - } - - loginWithEmail(email: string, password: string) { - return this.afu - .signInWithEmailAndPassword(email, password) - .then((user: any) => { - this.authState = user; - }) - .catch((_error: any) => { - console.log(_error); - throw _error; - }); - } - - singout(): void { - this.afu.signOut(); - this.router.navigate(['/login']); - } - - - // Sign up with email/password - SignUp(email:any, password:any) { - return this.afAuth.createUserWithEmailAndPassword(email, password) - .then((result:any) => { - /* Call the SendVerificaitonMail() function when new user sign - up and returns promise */ - this.SendVerificationMail(); - this.SetUserData(result.user); - }).catch((error:any) => { - window.alert(error.message) - }) - } - - - // main verification function - SendVerificationMail() { - return this.afAuth.currentUser.then((u:any) => u.sendEmailVerification()).then(() => { - this.router.navigate(['/dashboard']); - }) - } - // Set user - SetUserData(user:any) { - const userRef: AngularFirestoreDocument = this.afs.doc(`users/${user.uid}`); - const userData: User = { - email: user.email, - displayName: user.displayName, - uid: user.uid, - photoURL: user.photoURL || 'src/favicon.ico', - emailVerified: user.emailVerified - }; - userRef.delete().then(function () {}) - .catch(function (error:any) {}); - return userRef.set(userData, { - merge: true - }); - } - // sign in function - SignIn(email:any, password:any) { - return this.afAuth.signInWithEmailAndPassword(email, password) - .then((result:any) => { - if (result.user.emailVerified !== true) { - this.SetUserData(result.user); - this.SendVerificationMail(); - this.showLoader = true; - } else { - this.showLoader = false; - this.ngZone.run(() => { - this.router.navigate(['/auth/login']); - }); - } - }).catch((error:any) => { - throw error; - }) -} -ForgotPassword(passwordResetEmail:any) { - return this.afAuth.sendPasswordResetEmail(passwordResetEmail) - .then(() => { - window.alert('Password reset email sent, check your inbox.'); - }).catch((error:any) => { - window.alert(error); - }); -} -} diff --git a/src/app/shared/services/firebase.service.ts b/src/app/shared/services/firebase.service.ts deleted file mode 100644 index 40d059fb..00000000 --- a/src/app/shared/services/firebase.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@angular/core'; -import { AngularFireModule } from '@angular/fire/compat'; -import { AngularFirestoreModule } from '@angular/fire/compat/firestore'; -import { AngularFireDatabaseModule } from '@angular/fire/compat/database'; -import { AngularFireAuthModule } from '@angular/fire/compat/auth'; -import { environment } from '../../../environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class FirebaseService { - constructor() { - // AngularFireModule.initializeApp(environment.firebase); - } - - getFirestore() { - return AngularFirestoreModule; - } - - getDatabase() { - return AngularFireDatabaseModule; - } - - getAuth() { - return AngularFireAuthModule; - } -} diff --git a/src/app/shared/services/nav.service.ts b/src/app/shared/services/nav.service.ts deleted file mode 100644 index 450e2863..00000000 --- a/src/app/shared/services/nav.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Injectable, OnDestroy } from '@angular/core'; -import { Subject, BehaviorSubject, fromEvent } from 'rxjs'; -import { takeUntil, debounceTime } from 'rxjs/operators'; -import { Router } from '@angular/router'; -// Menu -export interface Menu { - headTitle?: string; - headTitle2?: string; - path?: string; - title?: string; - icon?: string; - type?: string; - badgeValue?: string; - badgeClass?: string; - badgeText?: string; - active?: boolean; - selected?: boolean; - bookmark?: boolean; - children?: Menu[]; - children2?: Menu[]; - Menusub?: boolean; - target?: boolean; - menutype?: string, - dirchange?: boolean, - nochild?: any - -} - -@Injectable({ - providedIn: 'root', -}) -export class NavService implements OnDestroy { - private unsubscriber: Subject = new Subject(); - public screenWidth: BehaviorSubject = new BehaviorSubject( - window.innerWidth - ); - - // Search Box - public search = false; - - // Language - public language = false; - - // Mega Menu - public megaMenu = false; - public levelMenu = false; - public megaMenuColapse: boolean = window.innerWidth < 1199 ? true : false; - - // Collapse Sidebar - public collapseSidebar: boolean = window.innerWidth < 991 ? true : false; - - // For Horizontal Layout Mobile - public horizontal: boolean = window.innerWidth < 991 ? false : true; - - // Full screen - public fullScreen = false; - active: any; - - constructor(private router: Router) { - this.setScreenWidth(window.innerWidth); - fromEvent(window, 'resize') - .pipe(debounceTime(1000), takeUntil(this.unsubscriber)) - .subscribe((evt: any) => { - this.setScreenWidth(evt.target.innerWidth); - if (evt.target.innerWidth < 991) { - this.collapseSidebar = true; - this.megaMenu = false; - this.levelMenu = false; - } - if (evt.target.innerWidth < 1199) { - this.megaMenuColapse = true; - } - }); - if (window.innerWidth < 991) { - // Detect Route change sidebar close - this.router.events.subscribe((event) => { - this.collapseSidebar = true; - this.megaMenu = false; - this.levelMenu = false; - }); - } - } - - ngOnDestroy() { - this.unsubscriber.next; - this.unsubscriber.complete(); - } - - private setScreenWidth(width: number): void { - this.screenWidth.next(width); - } - - items = new BehaviorSubject([]); - - setMenuItems(menuItems: Menu[]): void { - this.items.next(menuItems); - } - - clearMenuItems(): void { - this.items.next([]); - } -} diff --git a/src/app/shell/layouts/content-layout/content-layout.ts b/src/app/shell/layouts/content-layout/content-layout.ts index 4310e75c..98216403 100644 --- a/src/app/shell/layouts/content-layout/content-layout.ts +++ b/src/app/shell/layouts/content-layout/content-layout.ts @@ -12,7 +12,7 @@ import { TabToTop } from '../../ui/tab-to-top/tab-to-top'; import { AppLoader } from '../../ui/app-loader/app-loader'; import { SessionTimeoutService } from '../../../core/services/auth/session-timeout.service'; import { AppContextService } from '../../../core/services/context/app-context.service'; -import { AuthService } from '../../../core/services/auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; @Component({ selector: 'app-content-layout', templateUrl: './content-layout.html', @@ -49,7 +49,7 @@ export class ContentLayout { this.appContextService.ensureMenuInitialized().subscribe(); } - this.navServices.items.subscribe((menuItems: any) => { + this.navServices.items$.subscribe((menuItems: any) => { this.menuItems = menuItems; }); let html = this.document.documentElement; diff --git a/src/app/shell/routes/content.routes.ts b/src/app/shell/routes/content.routes.ts index 41c53d2b..92e23738 100644 --- a/src/app/shell/routes/content.routes.ts +++ b/src/app/shell/routes/content.routes.ts @@ -21,24 +21,12 @@ export const content: Routes = [ path: 'tenants', loadChildren: () => import('../../features/tenants/tenants.routes').then((m) => m.tenantsRoutes), }, - { - path: 'billing', - loadChildren: () => import('../../features/billing/billing.routes').then((m) => m.billingRoutes), - }, - { - path: 'localization', - loadChildren: () => import('../../features/localization/localization.routes').then((m) => m.localizationRoutes), - }, { path: 'theming', loadChildren: () => import('../../features/theming/theming.routes').then((m) => m.themingRoutes), }, { - path: 'platform', - loadChildren: () => import('../../features/platform/platform.routes').then((m) => m.platformRoutes), - }, - { - path: 'monitoring', - loadChildren: () => import('../../features/monitoring/monitoring.routes').then((m) => m.monitoringRoutes), - }, + path: 'settings', + loadChildren: () => import('../../features/settings/settings.routes').then((m) => m.settingsRoutes), + } ]; diff --git a/src/app/shell/ui/header/header.ts b/src/app/shell/ui/header/header.ts index e47f9e23..b75566dd 100644 --- a/src/app/shell/ui/header/header.ts +++ b/src/app/shell/ui/header/header.ts @@ -7,7 +7,7 @@ import { Router, RouterLink } from '@angular/router'; import { FullscreenDirective } from '../../../shared/directives/fullscreen.directive'; import { FormsModule } from '@angular/forms'; import { SlicePipe } from '@angular/common'; -import { AuthService } from '../../../core/services/auth/auth.service'; +import { AuthService } from '../../../features/authentication/data-access/auth.service'; interface Item { id: number; name: string; @@ -17,10 +17,10 @@ interface Item { } declare const HSStaticMethods: any; @Component({ - selector: 'app-header', - templateUrl: './header.html', - styleUrls: ['./header.scss'], - imports: [RouterLink, FullscreenDirective, FormsModule, SlicePipe] + selector: 'app-header', + templateUrl: './header.html', + styleUrls: ['./header.scss'], + imports: [RouterLink, FullscreenDirective, FormsModule, SlicePipe] }) export class Header { @@ -206,13 +206,13 @@ export class Header { // Prevent the click event from propagating to the container event.stopPropagation(); } - removeRow(itemId: string,event: MouseEvent) { + removeRow(itemId: string, event: MouseEvent) { const index = this.headeData.cartItems.findIndex(i => i.id === itemId); if (index !== -1) { this.headeData.cartItems.splice(index, 1); } this.updateCartItemCount(); - event.stopPropagation(); + event.stopPropagation(); } updateCartItemCount() { this.cartItemCount = this.headeData.cartItems.length; @@ -249,7 +249,7 @@ export class Header { } ngOnInit(): void { - this.NavServices.items.subscribe((menuItems) => { + this.NavServices.items$.subscribe((menuItems: any) => { this.items = menuItems; }); } diff --git a/src/app/shell/ui/sidebar/sidebar.ts b/src/app/shell/ui/sidebar/sidebar.ts index f22275f2..90773594 100644 --- a/src/app/shell/ui/sidebar/sidebar.ts +++ b/src/app/shell/ui/sidebar/sidebar.ts @@ -62,7 +62,7 @@ export class Sidebar { }; - this.menuitemsSubscribe$ = this.navServices.items.subscribe((items) => { + this.menuitemsSubscribe$ = this.navServices.items$.subscribe((items: any) => { this.menuItems = items; }); diff --git a/src/app/shell/ui/switcher/switcher.ts b/src/app/shell/ui/switcher/switcher.ts index 36157f41..0b150adb 100644 --- a/src/app/shell/ui/switcher/switcher.ts +++ b/src/app/shell/ui/switcher/switcher.ts @@ -2,10 +2,10 @@ import { Component, DOCUMENT, ElementRef, inject, Renderer2 } from '@angular/cor import { AppStateService } from '../../../core/services/common/app-state.service'; import { ColorPickerDirective } from 'ngx-color-picker'; @Component({ - selector: 'app-switcher', - templateUrl: './switcher.html', - styleUrls: ['./switcher.scss'], - imports: [ColorPickerDirective] + selector: 'app-switcher', + templateUrl: './switcher.html', + styleUrls: ['./switcher.scss'], + imports: [ColorPickerDirective] }) export class Switcher { public localdata: any; diff --git a/src/styles.scss b/src/styles.scss index 68b419e8..c8342b64 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -36,27 +36,23 @@ body { } .total-mail-recepients::-webkit-scrollbar { - @apply h-0!; + height: 0 !important; } - .custom-scrollbar-width::-webkit-scrollbar { - @apply w-[5px]!; + width: 5px !important; } -.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected { - background-color: var(--color-primary) !important; - color: var(--color-white) !important; +.ng-dropdown-panel .ng-dropdown-header, +.ng-select.app-form-select-control .ng-dropdown-header { + border-bottom: none !important; + padding: 0 !important; } .ng-select.app-form-select-control .ng-dropdown-panel { z-index: 99 !important; } -.ng-select.app-form-select-control .ng-dropdown-header { - padding: 0; -} - .swal2-styled.app-confirm-dialog-btn { min-width: 110px; min-height: 44px; @@ -69,6 +65,7 @@ body { .ti-btn-primary-full { margin-bottom: 0 !important; } -.ti-form-select{ - border: 1px solid #7c3deb !important; -} + +.ti-form-select { + border: 1px solid #7c3deb !important; +} \ No newline at end of file