diff --git a/src/app/core/end-points/tenant/tenant-currencies.endpoints.ts b/src/app/core/end-points/tenant/tenant-currencies.endpoints.ts new file mode 100644 index 00000000..97096281 --- /dev/null +++ b/src/app/core/end-points/tenant/tenant-currencies.endpoints.ts @@ -0,0 +1,32 @@ +import { buildApiUrl } from '../../config/api-url.util'; + + +export const TENANT_CURRENCIES_ENDPOINTS = { + dataTable: buildApiUrl( + 'masterAdmin', + '/v1/tenant-currencies/datatable' + ), + + create: buildApiUrl( + 'masterAdmin', + '/v1/tenant-currencies' + ), + + getById: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/tenant-currencies/${encodeURIComponent(id)}` + ), + + autocomplete: buildApiUrl( + 'masterAdmin', + '/v1/tenant-currencies/autocomplete' + ), + + update: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/tenant-currencies/${encodeURIComponent(id)}` + ) + +} as const; diff --git a/src/app/core/end-points/tenant/tenant.endpoints.ts b/src/app/core/end-points/tenant/tenant.endpoints.ts new file mode 100644 index 00000000..d53f88a6 --- /dev/null +++ b/src/app/core/end-points/tenant/tenant.endpoints.ts @@ -0,0 +1,32 @@ +import { buildApiUrl } from '../../config/api-url.util'; + + +export const TENANT_ENDPOINTS = { + dataTable: buildApiUrl( + 'masterAdmin', + '/v1/tenants/datatable' + ), + + create: buildApiUrl( + 'masterAdmin', + '/v1/tenants' + ), + + getById: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/tenants/${encodeURIComponent(id)}` + ), + + autocomplete: buildApiUrl( + 'masterAdmin', + '/v1/tenants/autocomplete' + ), + + update: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/tenants/${encodeURIComponent(id)}` + ) + +} as const; diff --git a/src/app/core/end-points/user/user.endpoints.ts b/src/app/core/end-points/user/user.endpoints.ts new file mode 100644 index 00000000..c04bfab3 --- /dev/null +++ b/src/app/core/end-points/user/user.endpoints.ts @@ -0,0 +1,10 @@ +import { buildApiUrl } from '../../config/api-url.util'; + + +export const USER_ENDPOINTS = { + dataTable: buildApiUrl('identity', '/v1/users/datatable'), + create: buildApiUrl('identity', '/v1/users'), + getById: (id: string) => buildApiUrl('identity', `/v1/users/${encodeURIComponent(id)}`), + update: (id: string) => buildApiUrl('identity', `/v1/users/${encodeURIComponent(id)}`), + autocomplete: buildApiUrl('identity', '/v1/users/autocomplete') +} as const; diff --git a/src/app/core/guards/auth/super-admin.guard.ts b/src/app/core/guards/auth/super-admin.guard.ts new file mode 100644 index 00000000..d109777b --- /dev/null +++ b/src/app/core/guards/auth/super-admin.guard.ts @@ -0,0 +1,11 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from '../../services/auth/auth.service'; + +export const superAdminGuard: CanActivateFn = () => { + const auth = inject(AuthService); + const router = inject(Router); + return (auth.currentUser?.roles ?? []).includes('super_admin') + ? true + : router.createUrlTree(['/dashboards/crm']); +}; diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index 1fd7c0c3..31706513 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -4,6 +4,31 @@ import { ToastrService } from 'ngx-toastr'; import { catchError, throwError } from 'rxjs'; import { API_CONFIG } from '../config/api.config'; +const backendErrorMessage = (body: unknown): string | null => { + if (typeof body === 'string') return body.trim() || null; + if (typeof body !== 'object' || body === null) return null; + + const record = body as Record; + for (const key of ['detail', 'message', 'title']) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return null; +}; + +const httpErrorMessage = (error: HttpErrorResponse): string => { + const backendMessage = backendErrorMessage(error.error); + if (backendMessage) return backendMessage; + + if (error.status > 0) { + const statusText = error.statusText.trim(); + const meaningfulStatusText = statusText && statusText.toUpperCase() !== 'OK'; + return `HTTP ${error.status}${meaningfulStatusText ? ` ${statusText}` : ''}`; + } + + return error.message || 'Request failed'; +}; + export const errorInterceptor: HttpInterceptorFn = (req, next) => { const toastr = inject(ToastrService); const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`; @@ -13,7 +38,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( catchError((error: HttpErrorResponse) => { if (!isLoginRequest && !isRefreshRequest && error.status !== 401) { - const message = error.error?.detail ?? error.error?.message ?? error.message ?? 'Request failed'; + const message = httpErrorMessage(error); toastr.error(message, 'Request failed'); } diff --git a/src/app/core/models/currency/currency.model.ts b/src/app/core/models/currency/currency.model.ts index fd09d502..012e2a19 100644 --- a/src/app/core/models/currency/currency.model.ts +++ b/src/app/core/models/currency/currency.model.ts @@ -1,6 +1,7 @@ export interface CurrencyDto { id: string; code: string; + iso2: string; name: string; symbol: string; numericCode: number; diff --git a/src/app/core/models/tenant/tenant-currencies.model.ts b/src/app/core/models/tenant/tenant-currencies.model.ts new file mode 100644 index 00000000..8ba9c947 --- /dev/null +++ b/src/app/core/models/tenant/tenant-currencies.model.ts @@ -0,0 +1,53 @@ +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 new file mode 100644 index 00000000..5aa875b4 --- /dev/null +++ b/src/app/core/models/tenant/tenant.model.ts @@ -0,0 +1,68 @@ +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/user/user.model.ts b/src/app/core/models/user/user.model.ts new file mode 100644 index 00000000..b7cd6b9c --- /dev/null +++ b/src/app/core/models/user/user.model.ts @@ -0,0 +1,29 @@ +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/common/menu.data.ts b/src/app/core/services/common/menu.data.ts index 4288b964..137a2564 100644 --- a/src/app/core/services/common/menu.data.ts +++ b/src/app/core/services/common/menu.data.ts @@ -24,8 +24,6 @@ export const SAAS_MENU_DATA: MenuContext = { selected: false, dirchange: false, children: [ - { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false }, - { path: '/users', title: 'Users', type: 'link', dirchange: false }, { title: 'Global Master', type: 'sub', @@ -41,6 +39,18 @@ export const SAAS_MENU_DATA: MenuContext = { { 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', diff --git a/src/app/core/services/common/menu.service.spec.ts b/src/app/core/services/common/menu.service.spec.ts new file mode 100644 index 00000000..ec059a31 --- /dev/null +++ b/src/app/core/services/common/menu.service.spec.ts @@ -0,0 +1,74 @@ +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/menu.service.ts b/src/app/core/services/common/menu.service.ts index 5639b4e5..50dc8eb6 100644 --- a/src/app/core/services/common/menu.service.ts +++ b/src/app/core/services/common/menu.service.ts @@ -1,15 +1,18 @@ -import { Injectable, signal } from '@angular/core'; +import { Injectable, inject, signal } from '@angular/core'; import { Observable, of, tap } from 'rxjs'; import { MenuContext } from '../../models/context/context.model'; import { SAAS_MENU_DATA } from './menu.data'; import { Menu } from '../../../core/services/common/nav.service'; +import { TokenStorageService } from '../auth/token-storage.service'; @Injectable({ providedIn: 'root' }) export class MenuService { + private readonly tokenStorage = inject(TokenStorageService); readonly menuContext = signal(null); loadMenu(): Observable { const context = this.cloneMenuContext(SAAS_MENU_DATA); + context.items = this.filterAuthorizedItems(context.items); return of(context).pipe( tap((menuContext) => { this.menuContext.set(menuContext); @@ -39,4 +42,15 @@ export class MenuService { private cloneMenuItems(items: Menu[]): Menu[] { return JSON.parse(JSON.stringify(items)) as Menu[]; } -} \ No newline at end of file + + private filterAuthorizedItems(items: Menu[]): Menu[] { + const isSuperAdmin = (this.tokenStorage.getUser()?.roles ?? []).includes('super_admin'); + return items + .filter(item => isSuperAdmin || item.path !== '/users') + .map(item => ({ + ...item, + children: item.children ? this.filterAuthorizedItems(item.children) : item.children, + children2: item.children2 ? this.filterAuthorizedItems(item.children2) : item.children2 + })); + } +} diff --git a/src/app/core/services/tenant/tenant-currencies.service.ts b/src/app/core/services/tenant/tenant-currencies.service.ts new file mode 100644 index 00000000..f4e93da0 --- /dev/null +++ b/src/app/core/services/tenant/tenant-currencies.service.ts @@ -0,0 +1,47 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { TENANT_CURRENCIES_ENDPOINTS } from '../../end-points/tenant/tenant-currencies.endpoints'; +import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types'; +import { + CreateTenantCurrencyRequest, + TenantCurrencyDto, + TenantCurrencyDto as TenantCurrencyDtoAlias, + TenantCurrencyLookupDto, + UpdateTenantCurrencyRequest +} from '../../models/tenant/tenant-currencies.model'; + +@Injectable({ + providedIn: 'root' +}) +export class TenantCurrenciesService { + private readonly http = inject(HttpClient); + + getTenantDataTable(query: DataTableQuery): Observable> { + return this.http.post>(TENANT_CURRENCIES_ENDPOINTS.dataTable, query); + } + + createTenant(request: CreateTenantCurrencyRequest): Observable { + return this.http.post(TENANT_CURRENCIES_ENDPOINTS.create, request); + } + + updateTenant(id: string, request: UpdateTenantCurrencyRequest): Observable { + return this.http.put(TENANT_CURRENCIES_ENDPOINTS.update(id), request); + } + + getTenantById(id: string): Observable { + return this.http.get(TENANT_CURRENCIES_ENDPOINTS.getById(id)); + } + + autocomplete(term?: string, limit = 10): Observable { + let params = new HttpParams().set('limit', limit); + const normalizedTerm = term?.trim(); + + if (normalizedTerm) { + params = params.set('term', normalizedTerm); + } + + return this.http.get(TENANT_CURRENCIES_ENDPOINTS.autocomplete, { params }); + } +} diff --git a/src/app/core/services/tenant/tenant.service.ts b/src/app/core/services/tenant/tenant.service.ts new file mode 100644 index 00000000..8cde943f --- /dev/null +++ b/src/app/core/services/tenant/tenant.service.ts @@ -0,0 +1,46 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { TENANT_ENDPOINTS } from '../../end-points/tenant/tenant.endpoints'; +import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types'; +import { + CreateTenantRequest, + TenantDto, + TenantLookupDto, + UpdateTenantRequest +} from '../../models/tenant/tenant.model'; + +@Injectable({ + providedIn: 'root' +}) +export class TenantService { + private readonly http = inject(HttpClient); + + getTenantDataTable(query: DataTableQuery): Observable> { + return this.http.post>(TENANT_ENDPOINTS.dataTable, query); + } + + createTenant(request: CreateTenantRequest): Observable { + return this.http.post(TENANT_ENDPOINTS.create, request); + } + + updateTenant(id: string, request: UpdateTenantRequest): Observable { + return this.http.put(TENANT_ENDPOINTS.update(id), request); + } + + getTenantById(id: string): Observable { + return this.http.get(TENANT_ENDPOINTS.getById(id)); + } + + autocomplete(term?: string, limit = 10): Observable { + let params = new HttpParams().set('limit', limit); + const normalizedTerm = term?.trim(); + + if (normalizedTerm) { + params = params.set('term', normalizedTerm); + } + + return this.http.get(TENANT_ENDPOINTS.autocomplete, { params }); + } +} diff --git a/src/app/core/services/user/user.service.ts b/src/app/core/services/user/user.service.ts new file mode 100644 index 00000000..1a7ab36c --- /dev/null +++ b/src/app/core/services/user/user.service.ts @@ -0,0 +1,40 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { CreateUserRequest, UpdateUserRequest, UserDto, UserLookupDto } from '../../models/user/user.model'; +import { USER_ENDPOINTS } from '../../end-points/user/user.endpoints'; +import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types'; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + private readonly http = inject(HttpClient); + + createUser(request: CreateUserRequest): Observable { + return this.http.post(USER_ENDPOINTS.create, request); + } + + getById(id: string): Observable { + return this.http.get(USER_ENDPOINTS.getById(id)); + } + + getDataTable(query: DataTableQuery): Observable> { + return this.http.post>(USER_ENDPOINTS.dataTable, query); + } + + update(id: string, request: UpdateUserRequest): Observable { + return this.http.put(USER_ENDPOINTS.update(id), request); + } + + autocomplete(term: string | null, limit = 10): Observable { + const normalizedTerm = term?.trim() || null; + let params = new HttpParams().set('limit', limit); + if (normalizedTerm !== null) { + params = params.set('term', normalizedTerm); + } + return this.http.get(USER_ENDPOINTS.autocomplete, { params }); + } + +} 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 abb7b310..a3c21ea5 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,12 +1,8 @@
-
-
+
-
-
Location Selection
-
@@ -55,8 +51,7 @@
-
-
+
@@ -64,7 +59,7 @@ @@ -80,15 +75,16 @@ formControlName="countryId" inputId="city-country" label="Country" - placeholder="Search country" + placeholder="Search" [searchFn]="searchCountries" [displayWith]="displayCountry" [valueWith]="countryValue" + [resolveValueFn]="resolveCountry" [selectedItem]="selectedFormCountry()" [minSearchLength]="1" [debounceTime]="300" [limit]="50" - [clearable]="false" + [clearable]="true" [required]="true" [readonly]="modalMode() !== 'create'" [validationMessages]="{ required: 'Country is required.' }" @@ -105,14 +101,16 @@ inputId="city-state" label="State" [placeholder]="formStatePlaceholder()" + [help]="'Select a country first'" [searchFn]="searchFormStates" [displayWith]="displayState" [valueWith]="stateValue" + [resolveValueFn]="resolveState" [selectedItem]="selectedFormState()" [minSearchLength]="1" [debounceTime]="300" [limit]="50" - [clearable]="false" + [clearable]="true" [required]="true" [disabled]="!cityForm.controls.countryId.value" [readonly]="modalMode() !== 'create'" @@ -126,7 +124,7 @@
= 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 = @@ -157,12 +166,11 @@ export class CityList { readonly resolveTimezone: AutocompleteResolveValueFn = value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone))); readonly filterStatePlaceholder = computed(() => - this.selectedCountryId() ? 'Search state' : 'Select a country first' + this.selectedCountryId() ? 'Search' : 'Select a country first' ); readonly formStatePlaceholder = computed(() => - this.cityForm.controls.countryId.value ? 'Search state' : 'Select a country first' + this.cityForm.controls.countryId.value ? 'Search' : 'Search' ); - readonly canAddCity = computed(() => !!this.selectedStateId()); readonly emptyMessage = computed(() => this.selectedCountryId() && this.selectedStateId() ? 'No cities found' @@ -179,10 +187,10 @@ export class CityList { return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City'; }); readonly submitLabel = computed(() => - this.modalMode() === 'create' ? 'Save City' : 'Update City' + this.modalMode() === 'create' ? 'Save' : 'Update' ); readonly loadingLabel = computed(() => - this.modalMode() === 'create' ? 'Saving City...' : 'Updating City...' + this.modalMode() === 'create' ? 'Saving...' : 'Updating...' ); readonly columns = signal[]>([ @@ -288,11 +296,7 @@ export class CityList { const city = this.toCityDto(event.row); switch (event.action.type) { case 'edit': - this.openExistingCity(city, 'edit', { - id: event.row.stateId, - name: event.row.stateName, - code: '' - }); + this.openExistingCity(city, 'edit'); break; case 'activate': this.updateCityStatus(city, true); @@ -304,21 +308,14 @@ export class CityList { } onAddCity(): void { - const countryId = this.selectedCountryId(); - const stateId = this.selectedStateId(); - if (!countryId || !stateId) { - this.toastr.error('Select a country and state before adding a city.'); - return; - } - this.modalMode.set('create'); this.selectedCity.set(null); this.submitAttempted.set(false); - this.selectedFormCountry.set(this.selectedCountry()); - this.selectedFormState.set(this.selectedFilterState()); + 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.cityForm.controls.stateId.enable({ emitEvent: false }); + this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { emitEvent: false }); + this.cityForm.controls.stateId.disable({ emitEvent: false }); this.resetFormState(); this.showCityModal.set(true); } @@ -456,21 +453,21 @@ export class CityList { }); } - private openExistingCity(city: CityDto, mode: 'edit', stateSeed?: StateLookupDto): void { + 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 => { + ).subscribe(({ details, countryId }) => { this.selectedCity.set(details); this.modalMode.set(mode); this.submitAttempted.set(false); - this.selectedFormCountry.set(this.selectedCountry()); - this.selectedFormState.set( - stateSeed - ?? (this.selectedFilterState()?.id === details.stateId ? this.selectedFilterState() : null) - ); + this.selectedFormCountry.set(null); + this.selectedFormState.set(null); this.cityForm.enable({ emitEvent: false }); this.cityForm.reset({ - countryId: this.selectedCountryId() ?? '', + countryId, stateId: details.stateId, name: details.name ?? '', code: details.code ?? '', 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 19ec664c..d51620c3 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 @@ -135,14 +135,14 @@ export class CountryList { readonly countrySubmitLabel = computed(() => this.countryModalMode() === 'create' - ? 'Save Country' - : 'Update Country' + ? 'Save' + : 'Update' ); readonly countryLoadingLabel = computed(() => this.countryModalMode() === 'create' - ? 'Saving Country...' - : 'Updating Country...' + ? 'Saving...' + : 'Updating...' ); readonly countrySubmitAction = computed<'save' | 'update'>(() => @@ -157,6 +157,7 @@ export class CountryList { { 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: 'isActive', label: 'Status', 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 63c60709..b1131052 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 @@ -20,18 +20,17 @@ (actionClicked)="onActionClick($event)" > -
- - {{ row.symbol }} - +
+ @if (getFlagUrl(row.iso2); as flagUrl) { + + } - - {{ value }} - -
- + + {{ value }} + +
+
@@ -75,6 +74,7 @@ required: 'Currency Name is required.', maxlength: 'Currency Name cannot exceed 100 characters.' }" + [submitAttempted]="currencySubmitAttempted()" />
@@ -84,7 +84,6 @@ inputId="currency-code" label="Currency Code" placeholder="e.g.: AED" - inputClass="uppercase" autocomplete="off" [required]="true" [minLength]="3" @@ -95,6 +94,7 @@ maxlength: 'Currency Code must contain exactly 3 letters.', pattern: 'Currency Code can contain letters only.' }" + [submitAttempted]="currencySubmitAttempted()" /> @@ -111,6 +111,7 @@ required: 'Currency Symbol is required.', maxlength: 'Currency Symbol cannot exceed 8 characters.' }" + [submitAttempted]="currencySubmitAttempted()" /> @@ -123,7 +124,7 @@ inputMode="numeric" placeholder="e.g.: 784" autocomplete="off" - [required]="true" + [required]="false" [min]="1" [max]="999" [step]="1" @@ -132,6 +133,7 @@ min: 'Numeric Code must be at least 1.', max: 'Numeric Code cannot exceed 999.' }" + [submitAttempted]="currencySubmitAttempted()" /> @@ -153,6 +155,7 @@ min: 'Decimal Digits cannot be less than 0.', max: 'Decimal Digits cannot exceed 4.' }" + [submitAttempted]="currencySubmitAttempted()" /> 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 7ab5c7e1..a749e989 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 @@ -34,6 +34,7 @@ import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/c interface CurrencyTableRow extends DataTableRecord { id: string; code: string; + iso2: string; name: string; symbol: string; numericCode: number; @@ -124,14 +125,14 @@ export class CurrencyList { readonly currencySubmitLabel = computed(() => this.currencyModalMode() === 'create' - ? 'Save Currency' - : 'Update Currency' + ? 'Save' + : 'Update' ); readonly currencyLoadingLabel = computed(() => this.currencyModalMode() === 'create' - ? 'Saving Currency...' - : 'Updating Currency...' + ? 'Saving...' + : 'Updating...' ); readonly currencySubmitAction = computed<'save' | 'update'>(() => @@ -143,6 +144,7 @@ export class CurrencyList { 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 }, { 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 }, @@ -495,6 +497,7 @@ export class CurrencyList { return { id: row.id, code: row.code, + iso2: row.iso2?.toLowerCase() ?? '', name: row.name, symbol: row.symbol, numericCode: row.numericCode, @@ -504,4 +507,16 @@ export class CurrencyList { modifiedOn: row.modifiedOn }; } + + getFlagUrl(iso2: string | null | undefined): string { + const code = iso2?.trim().toLowerCase(); + + return code && /^[a-z]{2}$/.test(code) + ? `https://flagcdn.com/24x18/${code}.png` + : ''; + } + onFlagError(event: Event): void { + const image = event.target as HTMLImageElement; + image.style.display = 'none'; + } } 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 fe40cf7a..bc3ba2ef 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 @@ -84,10 +84,10 @@ export class LanguageList { this.modalMode() === 'create' ? 'Add Language' : 'Edit Language' ); readonly submitLabel = computed(() => - this.modalMode() === 'create' ? 'Save Language' : 'Update Language' + this.modalMode() === 'create' ? 'Save' : 'Update' ); readonly loadingLabel = computed(() => - this.modalMode() === 'create' ? 'Saving Language...' : 'Updating Language...' + this.modalMode() === 'create' ? 'Saving...' : 'Updating...' ); readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update' 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 87e3e591..f560158d 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,20 +1,14 @@ -
-
-
+
-
-
Country Selection
-
-
-
-
+
- @@ -59,6 +51,32 @@ (closed)="closeStateModal()" (submitted)="saveState()">
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+
+ diff --git a/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.scss b/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts b/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts new file mode 100644 index 00000000..c4edeea4 --- /dev/null +++ b/src/app/features/tenants/pages/tenant-currencies/tenant-currencies.ts @@ -0,0 +1,410 @@ +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 { TenantCurrenciesService } from '../../../../core/services/tenant/tenant-currencies.service'; +import { LanguageService } from '../../../../core/services/language/language.service'; +import { CurrencyService } from '../../../../core/services/currency/currency.service'; +import { TimezoneService } from '../../../../core/services/timezone/timezone.service'; +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 '../../../../core/models/tenant/tenant-currencies.model'; +import { LanguageLookupDto } from '../../../../core/models/language/language.model'; +import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; +import { CurrencyLookupDto } from '../../../../core/models/currency/currency.model'; +import { TenantLookupDto } from '../../../../core/models/tenant/tenant.model'; +import { TenantService } from '../../../../core/services/tenant/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], + templateUrl: './tenant-currencies.html', + styleUrl: './tenant-currencies.scss', +}) +export class TenantCurrencies { + + private readonly destroyRef = inject(DestroyRef); + private readonly tenantCurrencyApi = inject(TenantCurrenciesService); + 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<{ tenantId: string; query: DataTableQuery; }>(); + + readonly queryState = new DataTableQueryState(); + readonly tenantCurrencies = signal([]); + readonly totalRecords = signal(0); + readonly filteredRecords = signal(0); + + readonly saving = signal(false); + readonly showTenantCurrencyModal = signal(false); + readonly tenantCurrencyModalMode = signal('create'); + readonly selectedTenantId = signal(null); + readonly selectedTenantLookup = signal(null); + readonly tenantSubmitAttempted = signal(false); + + + + readonly tenantCurrencyForm = this.formBuilder.group({ + tenantId: this.formBuilder.control(null, [Validators.required]), + currencyId: this.formBuilder.control(null, [Validators.required]), + isActive: this.formBuilder.control(1, [Validators.required]), + isBaseCurrency: this.formBuilder.control(false, [Validators.required]), + isReporting: this.formBuilder.control(false, [Validators.required]), + }); + readonly tenantCurrencyFilterForm = this.formBuilder.nonNullable.group({ + tenantId: [''] + }); + + readonly searchTenants: + AutocompleteSearchFn = + (term, limit) => + this.tenantApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error( + 'Unable to load tenants.' + ); + + return of< + readonly TenantLookupDto[] + >([]); + }) + ); + + readonly displayTenant: + AutocompleteDisplayFn = + tenant => + [tenant.code, tenant.name] + .filter(Boolean) + .join(' - '); + + readonly tenantValue: + AutocompleteValueFn< + TenantLookupDto, + string + > = + tenant => tenant.id; + + readonly resolveTenant: + AutocompleteResolveValueFn< + TenantLookupDto, + string + > = + tenantId => + this.tenantApi + .getTenantById(tenantId) + .pipe( + map(tenant => ({ + id: tenant.id, + code: tenant.code, + name: tenant.name + })) + ); + + readonly searchCurrencies: AutocompleteSearchFn = (term, limit) => + this.currencyApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load currencies.'); + return of([]); + }) + ); + + 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 modalTitle = computed(() => + this.tenantCurrencyModalMode() === 'create' ? 'Add Tenant' : 'Edit Tenant' + ); + + readonly submitLabel = computed(() => + this.tenantCurrencyModalMode() === 'create' ? 'Save' : 'Update' + ); + + readonly loadingLabel = computed(() => + this.tenantCurrencyModalMode() === 'create' ? 'Saving...' : 'Updating...' + ); + + readonly submitAction = computed<'save' | 'update'>(() => + this.tenantCurrencyModalMode() === '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: 'isBaseCurrency', + label: 'Base Currency', + header: 'Base Currency', + sortable: true, + badge: true, + formatter: value => value === true ? 'Yes' : 'No' + }, + { + key: 'isReporting', + label: 'Reporting Currency', + header: 'Reporting Currency', + sortable: true, + badge: true, + formatter: value => value === true ? 'Yes' : 'No' + }, + { + 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 + } + ]); + + 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(); + } + + constructor() { + this.queryRequests$ + .pipe( + switchMap(({ tenantId, query }) => + this.tenantCurrencyApi + .getTenantDataTable(this.buildTenantCurrencyQuery(query)) + .pipe( + map(response => ({ + response, + tenantId, + query + })), + catchError(() => { + this.toastr.error( + 'Unable to load tenant currencies.' + ); + + this.clearTenantCurrencyGrid(); + + return of(null); + }) + ) + ), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(result => { + if (!result) { + return; + } + + const { + response, + tenantId, + query + } = result; + + if (this.selectedTenantId() !== tenantId) { + return; + } + + if (response.draw !== query.draw) { + return; + } + + const rows: TenantCurrencyTableRow[] = + response.rows.map( + (tenantCurrency, index) => ({ + ...tenantCurrency, + serialNumber: + (query.page - 1) * + query.pageSize + + index + + 1 + }) + ); + + this.tenantCurrencies.set(rows); + this.totalRecords.set(response.total); + this.filteredRecords.set( + response.filtered + ); + }); + } + ngOnInit(): void { + this.clearTenantCurrencyGrid(); + } + + loadTenantCurrencies(query: DataTableQuery): void { + const tenantId = this.selectedTenantId(); + + if (!tenantId) { + this.clearTenantCurrencyGrid(); + return; + } + + this.queryRequests$.next({ + tenantId, + query + }); + } + + onSearch(value: string): void { + this.loadTenantCurrencies(this.queryState.setSearch(value.trim())); + } + + onPageChange(event: DataTablePageEvent): void { + this.loadTenantCurrencies(this.queryState.setPage(event)); + } + + onSortChange(event: DataTableSortEvent): void { + this.loadTenantCurrencies(this.queryState.setSort(event)); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'edit') { + this.openEditTenantCurrency(event.row.id); + } + } + private buildTenantCurrencyQuery(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; + } + } + + onTenantLookupSelected(tenant: TenantLookupDto | null): void { + this.selectedTenantLookup.set(tenant); + + const tenantId = tenant?.id ?? null; + + this.tenantCurrencyFilterForm.controls.tenantId + .setValue(tenantId ?? ''); + + this.onTenantSelected(tenantId); + } + + onAddTenantCurrency(): void { + this.tenantCurrencyModalMode.set('create'); + + this.showTenantCurrencyModal.set(true); + } + + closeTenantCurrencyModal(): void { + if (this.saving()) { + return; + } + + this.showTenantCurrencyModal.set(false); + } + + private openEditTenantCurrency(id: string): void { + this.tenantCurrencyModalMode.set('edit'); + + } + + private onTenantSelected(tenantId: string | null): void { + this.selectedTenantId.set(tenantId); + + + this.clearTenantCurrencyGrid(); + + const query = this.queryState.reset(); + + if (!tenantId) { + return; + } + + this.loadTenantCurrencies(query); + } + + + private clearTenantCurrencyGrid(): void { + this.tenantCurrencies.set([]); + this.totalRecords.set(0); + this.filteredRecords.set(0); + } + +} 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 c54a2ab0..c5dcba5a 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.html +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.html @@ -1,62 +1,185 @@ + + +
+
+
+ +
- -
- - - +
+ +
- - {{ value }} - -
-
+
+ +
- +
+ +
- \ No newline at end of file +
+ +
+ +
+ +
+ +
+ +
+
+
+
\ No newline at end of file diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.scss b/src/app/features/tenants/pages/tenant-list/tenant-list.scss index e69de29b..a0638cc7 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.scss +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.scss @@ -0,0 +1 @@ +/* Intentionally empty: tenant screen reuses the shared Ynex layout classes. */ 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 e8c1ba0e..2365dce2 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.ts +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.ts @@ -1,195 +1,579 @@ -import { Component, signal, viewChild } from '@angular/core'; -import { CommonModule } from '@angular/common'; +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 { CreateTenantRequest, TenantDto, TenantModalMode, TenantStatus, TenantTableRow, UpdateTenantRequest } from '../../../../core/models/tenant/tenant.model'; +import { CurrencyLookupDto } from '../../../../core/models/currency/currency.model'; +import { LanguageLookupDto } from '../../../../core/models/language/language.model'; +import { TimezoneLookupDto } from '../../../../core/models/timezone/timezone.model'; +import { CurrencyService } from '../../../../core/services/currency/currency.service'; +import { LanguageService } from '../../../../core/services/language/language.service'; +import { TenantService } from '../../../../core/services/tenant/tenant.service'; +import { TimezoneService } from '../../../../core/services/timezone/timezone.service'; import { DataTable } from '../../../../shared/components/data-table/data-table'; -import { DataTableColumn, DataTableAction } from '../../../../shared/components/data-table/data-table.types'; import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state'; -import { DataTablePageEvent, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types'; -import { DataTableCellDirective } from '../../../../shared/directives/data-table-cell.directive'; -import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; +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'; + + @Component({ - selector: 'tenant-list', - imports: [CommonModule, DataTable, DataTableCellDirective, ConfirmDialog], - templateUrl: './tenant-list.html', - styleUrl: './tenant-list.scss', + selector: 'tenant-list', + standalone: true, + imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete], + 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(); - tableQuery = new DataTableQueryState(); + 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); - loading = false; - pageIndex = 1; - pageSize = 10; - totalRecords = 3; - searchText = ''; - readonly pendingDeleteTenant = signal<{ id: number } | null>(null); - readonly deleteConfirmDialog = viewChild(ConfirmDialog); + 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]) + }); - allowedPermissions: string[] = [ - 'tenant.view', - 'tenant.edit', - 'tenant.delete' - ]; + readonly tenantStatusOptions = signal[]>([ + { value: TenantStatus.Trial, label: 'Trial' }, + { value: TenantStatus.Active, label: 'Active' }, + { value: TenantStatus.Suspended, label: 'Suspended' }, + { value: TenantStatus.Cancelled, label: 'Cancelled' } + ]); - columns: DataTableColumn[] = [ - { key: 'id', header: 'Id', label: 'Id', sortable: true, headerClass: 'text-center', cellClass: 'text-center' }, - { key: 'tenantName', header: 'Tenant Name', label: 'Tenant Name', sortable: true }, - { key: 'companyCode', header: 'Company Code', label: 'Company Code', sortable: true }, - { key: 'email', header: 'Email', label: 'Email' }, - { key: 'country', header: 'Country', label: 'Country', sortable: true }, - { - key: 'status', - header: 'Status', - label: 'Status', - sortable: true, - cellClass: 'text-center', - badge: true, - badgeClass: value => - value === 'Active' - ? 'badge bg-success/10 text-success' - : 'badge bg-danger/10 text-danger' - } - ]; + readonly activeStatusOptions = signal[]>([ + { value: 1, label: 'Active' }, + { value: 0, label: 'Inactive' } + ]); - tenants = [ - { - id: 1, - tenantName: 'Syscom Corporation', - companyCode: 'SYSCOM', - email: 'admin@syscom.com', - country: 'UAE', - status: 'Active', - logo: 'assets/images/brand-logos/erp-logo-icon.png' - }, - { - id: 2, - tenantName: 'Biz360 Demo', - companyCode: 'BIZ360', - email: 'demo@biz360.com', - country: 'India', - status: 'Active', - logo:'assets/images/brand-logos/erp-logo-icon.png' - }, - { - id: 3, - tenantName: 'Test Tenant', - companyCode: 'TEST', - email: 'test@test.com', - country: 'India', - status: 'Inactive' - } - ]; + readonly searchLanguages: AutocompleteSearchFn = (term, limit) => + this.languageApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load languages.'); + return of([]); + }) + ); - tenants1 = [ - { - id: 1, - tenantName: 'Syscom Corporation', - companyCode: 'SYSCOM', - email: 'admin@syscom.com', - country: 'UAE', - status: 'Active' - }, - { - id: 2, - tenantName: 'Biz360 Demo', - companyCode: 'BIZ360', - email: 'demo@biz360.com', - country: 'India', - status: 'Active' - }, - { - id: 3, - tenantName: 'Test Tenant', - companyCode: 'TEST', - email: 'test@test.com', - country: 'India', - status: 'Inactive' - } - ]; + readonly searchCurrencies: AutocompleteSearchFn = (term, limit) => + this.currencyApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load currencies.'); + return of([]); + }) + ); - tableActions: DataTableAction[] = [ - { - type: 'download', - label: 'Download', - icon: 'ri-download-2-line !mb-0', - className: 'ti-btn ti-btn-sm ti-btn-success !rounded-full', - permission: 'tenant.view' - }, - { - type: 'edit', - label: 'Edit', - icon: 'ri-edit-line !mb-0', - className: 'ti-btn ti-btn-sm ti-btn-info !rounded-full', - permission: 'tenant.edit' - }, - { - type: 'delete', - label: 'Delete', - icon: 'ri-delete-bin-line', - className: 'ti-btn ti-btn-sm ti-btn-danger !rounded-full', - permission: 'tenant.delete', - //visible: row => row.status !== 'Active' - } - ]; + readonly searchTimezones: AutocompleteSearchFn = (term, limit) => + this.timezoneApi.autocomplete(term, limit).pipe( + catchError(() => { + this.toastr.error('Unable to load timezones.'); + return of([]); + }) + ); + readonly displayLanguage: AutocompleteDisplayFn = language => [language.code, language.name].filter(Boolean).join(' - '); - onSearch(searchText: string): void { - this.searchText = searchText; - this.pageIndex = 1; + readonly languageValue: AutocompleteValueFn = language => language.id; - if (searchText.trim() === '') { - this.tenants = [...this.tenants1]; - } else { - this.tenants = this.tenants.filter(tenant => - tenant.tenantName.toLowerCase().includes(searchText.toLowerCase()) || - tenant.companyCode.toLowerCase().includes(searchText.toLowerCase()) || - tenant.email.toLowerCase().includes(searchText.toLowerCase()) || - tenant.country.toLowerCase().includes(searchText.toLowerCase()) || - tenant.status.toLowerCase().includes(searchText.toLowerCase()) - ); + 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); + }); } - } - - onPageChange(event: DataTablePageEvent): void { - const query = this.tableQuery.setPage(event); - //this.loadTenants(query); - } - - onSortChange(event: DataTableSortEvent): void { - const query = this.tableQuery.setSort(event); - //this.loadTenants(query); - } - - onTableAction(event: any): void { - if (event?.action?.type === 'delete') { - this.pendingDeleteTenant.set(event.row as { id: number }); - this.deleteConfirmDialog()?.open(); - return; + ngOnInit(): void { + this.loadTenants(this.queryState.getQuery()); } - console.log('Action:', event.action.type, event.row); - } - - onDeleteConfirmed(): void { - const tenant = this.pendingDeleteTenant(); - - if (!tenant) { - return; + loadTenants(query: DataTableQuery): void { + this.queryRequests$.next(query); } - this.pendingDeleteTenant.set(null); - this.tenants = this.tenants.filter(currentTenant => currentTenant.id !== tenant.id); - this.tenants1 = this.tenants1.filter(currentTenant => currentTenant.id !== tenant.id); - this.totalRecords = this.tenants.length; - console.log('Deleted tenant:', tenant); - } + onSearch(value: string): void { + this.loadTenants(this.queryState.setSearch(value.trim())); + } - onDeleteCancelled(): void { - this.pendingDeleteTenant.set(null); - } + onPageChange(event: DataTablePageEvent): void { + this.loadTenants(this.queryState.setPage(event)); + } - onRowClick(row: any): void { - console.log('Row clicked:', row); - } + 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 .form-control.is-invalid, modal .ti-form-select.is-invalid, modal [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); + } } \ No newline at end of file diff --git a/src/app/features/tenants/tenants.routes.ts b/src/app/features/tenants/tenants.routes.ts index aaf22ed5..17d06206 100644 --- a/src/app/features/tenants/tenants.routes.ts +++ b/src/app/features/tenants/tenants.routes.ts @@ -4,6 +4,11 @@ export const tenantsRoutes: Routes = [ { path: '', loadComponent: () => import('./pages/tenant-list/tenant-list').then((m) => m.TenantList), - data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' }, + data: { childTitle: 'Tenant Management', parentTitle: 'Platform', subParentTitle: 'Configuration' }, + }, + { + path: 'tenant-currencies', + loadComponent: () => import('./pages/tenant-currencies/tenant-currencies').then((m) => m.TenantCurrencies), + data: { childTitle: 'Tenant Currencies', parentTitle: 'Platform', subParentTitle: 'Configuration' }, }, ]; \ No newline at end of file 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 cd3dfc73..e373e763 100644 --- a/src/app/features/users/pages/users-list/users-list.html +++ b/src/app/features/users/pages/users-list/users-list.html @@ -1,4 +1,19 @@ -
-

Platform users

-

Users, roles, assignment, deactivation, and password reset will be implemented here.

-
+ + + 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 0c2637bb..2efd1869 100644 --- a/src/app/features/users/pages/users-list/users-list.ts +++ b/src/app/features/users/pages/users-list/users-list.ts @@ -1,11 +1,521 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; +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 '../../../../core/models/user/user.model'; +import { UserService } from '../../../../core/services/user/user.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 { Modal } from '../../../../shared/components/modal/modal'; + +interface UserTableRow extends DataTableRecord { + id: string; + email: string; + status: UserStatus; + roles: string[]; + isActive: boolean; + serialNumber: number; + createdOn: string; + lastLoginOn: string | null; +} @Component({ selector: 'app-users-list', standalone: true, - imports: [CommonModule], + imports: [ + ReactiveFormsModule, + RouterLink, + DataTable, + Modal + ], templateUrl: './users-list.html', styleUrl: './users-list.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) -export class UsersList {} +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 columns = signal[]>([ + { + key: 'serialNumber', + label: 'Sr. No.', + header: 'Sr. No.', + sortable: false, + width: '100px' + }, + { + key: 'email', + label: 'Email', + header: 'Email', + sortable: true, + align: 'left' + }, + { + key: 'status', + label: 'User Status', + header: 'User Status', + sortable: true, + formatter: value => + this.formatUserStatus(value as UserStatus) + }, + { + key: 'roles', + label: 'Roles', + header: 'Roles', + sortable: false, + align: 'left', + formatter: value => + this.formatRoles( + Array.isArray(value) + ? value.filter( + (item): item is string => + typeof item === 'string' + ) + : [] + ) + }, + { + key: 'createdOn', + label: 'Created On', + header: 'Created On', + sortable: true + }, + { + key: 'lastLoginOn', + label: 'Last Login On', + header: 'Last Login On', + sortable: true, + formatter: value => + typeof value === 'string' && value.trim().length > 0 + ? value + : 'Never' + }, + { + 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' + } + ]); + + 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); + } + + onAddUser(): void { + this.userModalMode.set('create'); + this.resetUserForm(); + this.showUserModal.set(true); + } + + closeUserModal(): void { + if (this.saving()) { + return; + } + + this.showUserModal.set(false); + this.resetUserForm(); + } + + 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; + } + + private formatRoles(roles: readonly string[]): string { + 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()}` + ) + .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'; + } + } +} \ No newline at end of file diff --git a/src/app/features/users/users.routes.ts b/src/app/features/users/users.routes.ts index efc2a578..5f2a8847 100644 --- a/src/app/features/users/users.routes.ts +++ b/src/app/features/users/users.routes.ts @@ -1,9 +1,11 @@ import { Routes } from '@angular/router'; +import { superAdminGuard } from '../../core/guards/auth/super-admin.guard'; export const usersRoutes: Routes = [ { path: '', + canActivate: [superAdminGuard], loadComponent: () => import('./pages/users-list/users-list').then((m) => m.UsersList), data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' }, - }, + } ]; diff --git a/src/app/shared/components/filter-card/filter-card.html b/src/app/shared/components/filter-card/filter-card.html new file mode 100644 index 00000000..0162008d --- /dev/null +++ b/src/app/shared/components/filter-card/filter-card.html @@ -0,0 +1,41 @@ +
+
+
{{ title() }}
+ +
+ + + +
+
+ +
+
+
+ +
+
+
+
diff --git a/src/app/shared/components/filter-card/filter-card.spec.ts b/src/app/shared/components/filter-card/filter-card.spec.ts new file mode 100644 index 00000000..e54b2147 --- /dev/null +++ b/src/app/shared/components/filter-card/filter-card.spec.ts @@ -0,0 +1,142 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FilterCard } from './filter-card'; + +@Component({ + standalone: true, + imports: [FilterCard], + template: ` + + + + + ` +}) +class HostComponent { + readonly defaultCollapsed = signal(false); + readonly collapsible = signal(true); + readonly contentId = signal(''); + readonly changes: boolean[] = []; + collapsedIcon = 'custom-collapsed'; + expandedIcon = 'custom-expanded'; +} + +describe('FilterCard', () => { + let fixture: ComponentFixture; + let host: HostComponent; + let overlayContainer: OverlayContainer; + + const card = (): FilterCard => fixture.debugElement.children[0].componentInstance; + const toggle = (): HTMLButtonElement => fixture.nativeElement.querySelector('button[aria-controls]'); + const icon = (): HTMLElement => toggle().querySelector('i') as HTMLElement; + const bodyGrid = (): HTMLElement => fixture.nativeElement.querySelector('.filter-card-body-grid'); + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + overlayContainer = TestBed.inject(OverlayContainer); + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + overlayContainer.ngOnDestroy(); + }); + + it('starts expanded by default', () => { + expect(card().collapsed()).toBe(false); + expect(bodyGrid().classList.contains('is-collapsed')).toBe(false); + }); + + it('reacts to a collapsed default state', () => { + host.defaultCollapsed.set(true); + fixture.detectChanges(); + expect(card().collapsed()).toBe(true); + expect(bodyGrid().classList.contains('is-collapsed')).toBe(true); + }); + + it('switches icons and tooltips when toggled', () => { + expect(icon().className).toBe('custom-expanded'); + expect(toggle().getAttribute('aria-label')).toBe('Hide Filters'); + toggle().click(); + fixture.detectChanges(); + expect(icon().className).toBe('custom-collapsed'); + expect(toggle().getAttribute('aria-label')).toBe('Show Filters'); + }); + + it('updates an open tooltip when toggled without requiring another hover', () => { + vi.useFakeTimers(); + toggle().dispatchEvent(new MouseEvent('mouseenter')); + vi.advanceTimersByTime(200); + fixture.detectChanges(); + expect(overlayContainer.getContainerElement().textContent).toContain('Hide Filters'); + + toggle().click(); + fixture.detectChanges(); + + expect(overlayContainer.getContainerElement().textContent).toContain('Show Filters'); + expect(overlayContainer.getContainerElement().textContent).not.toContain('Hide Filters'); + }); + + it('emits the new collapsed state', () => { + toggle().click(); + toggle().click(); + expect(host.changes).toEqual([true, false]); + }); + + it('sets accessible expanded and controls attributes', () => { + const id = card().resolvedContentId(); + expect(toggle().getAttribute('aria-expanded')).toBe('true'); + expect(toggle().getAttribute('aria-controls')).toBe(id); + expect(fixture.nativeElement.querySelector(`#${id}`)).not.toBeNull(); + }); + + it('keeps projected content mounted while collapsed', () => { + const input = fixture.nativeElement.querySelector('[data-testid="projected-input"]'); + toggle().click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="projected-input"]')).toBe(input); + expect(fixture.nativeElement.textContent).toContain('Reset'); + }); + + it('uses custom icons', () => { + expect(icon().classList.contains('custom-expanded')).toBe(true); + toggle().click(); + fixture.detectChanges(); + expect(icon().classList.contains('custom-collapsed')).toBe(true); + }); + + it('uses a custom content id', () => { + host.contentId.set('state-filter-content'); + fixture.detectChanges(); + expect(toggle().getAttribute('aria-controls')).toBe('state-filter-content'); + expect(fixture.nativeElement.querySelector('#state-filter-content')).not.toBeNull(); + }); + + it('generates a stable content id', () => { + const generatedId = card().resolvedContentId(); + fixture.detectChanges(); + expect(generatedId).toMatch(/^filter-card-content-\d+$/); + expect(card().resolvedContentId()).toBe(generatedId); + }); + + it('does not collapse or emit when non-collapsible', () => { + host.collapsible.set(false); + fixture.detectChanges(); + toggle().click(); + expect(card().collapsed()).toBe(false); + expect(host.changes).toEqual([]); + expect(toggle().disabled).toBe(true); + }); +}); diff --git a/src/app/shared/components/filter-card/filter-card.ts b/src/app/shared/components/filter-card/filter-card.ts new file mode 100644 index 00000000..48e84993 --- /dev/null +++ b/src/app/shared/components/filter-card/filter-card.ts @@ -0,0 +1,71 @@ +import { Component, computed, effect, input, output, signal } from '@angular/core'; + +import { TooltipDirective } from '../../directives/tooltip/tooltip.directive'; + +let nextFilterCardId = 0; + +@Component({ + selector: 'app-filter-card', + standalone: true, + imports: [TooltipDirective], + templateUrl: './filter-card.html', + styles: ` + :host { display: block; } + .filter-card-body-grid { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 200ms ease, visibility 200ms ease; + visibility: visible; + } + .filter-card-body-grid.is-collapsed { + grid-template-rows: 0fr; + visibility: hidden; + } + .filter-card-body-content { min-height: 0; overflow: hidden; } + @media (prefers-reduced-motion: reduce) { + .filter-card-body-grid { transition: none; } + } + ` +}) +export class FilterCard { + readonly title = input('Filters'); + readonly defaultCollapsed = input(false); + readonly collapsible = input(true); + readonly showToggleButton = input(true); + readonly collapsedIcon = input('ri-filter-3-line'); + readonly expandedIcon = input('ri-arrow-up-s-line'); + readonly collapsedTooltip = input('Show Filters'); + readonly expandedTooltip = input('Hide Filters'); + readonly cardClass = input(''); + readonly headerClass = input(''); + readonly bodyClass = input(''); + readonly toggleButtonClass = input(''); + readonly contentId = input(''); + + readonly collapseChanged = output(); + readonly collapsed = signal(false); + + private readonly generatedContentId = `filter-card-content-${++nextFilterCardId}`; + + readonly resolvedContentId = computed(() => this.contentId().trim() || this.generatedContentId); + readonly currentIcon = computed(() => + this.collapsed() ? this.collapsedIcon() : this.expandedIcon() + ); + readonly currentTooltip = computed(() => + this.collapsed() ? this.collapsedTooltip() : this.expandedTooltip() + ); + + constructor() { + effect(() => this.collapsed.set(this.defaultCollapsed())); + } + + toggleCollapse(): void { + if (!this.collapsible()) { + return; + } + + const collapsed = !this.collapsed(); + this.collapsed.set(collapsed); + this.collapseChanged.emit(collapsed); + } +} diff --git a/src/app/shared/components/form/autocomplete/autocomplete.html b/src/app/shared/components/form/autocomplete/autocomplete.html index 7d45c52f..3d65d104 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.html +++ b/src/app/shared/components/form/autocomplete/autocomplete.html @@ -6,6 +6,7 @@ [disabled]="isDisabled()" [description]="description()" [hint]="hint()" + [help]="help()" [labelPosition]="labelPosition()" [hideLabel]="hideLabel()" [hideValidation]="hideValidation()" @@ -35,18 +36,19 @@ [attr.aria-readonly]="readonly()" (input)="onInput($event)" (focus)="onFocus()" + (click)="onClick()" (blur)="onBlur()" (keydown)="onKeydown($event)" /> @if (loading()) { -
diff --git a/src/app/shared/components/form/autocomplete/autocomplete.spec.ts b/src/app/shared/components/form/autocomplete/autocomplete.spec.ts index faa8c086..7773a44c 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.spec.ts +++ b/src/app/shared/components/form/autocomplete/autocomplete.spec.ts @@ -1,11 +1,11 @@ import { OverlayContainer } from '@angular/cdk/overlay'; import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { Observable, Subject, of, throwError } from 'rxjs'; import { Autocomplete } from './autocomplete'; -import { AutocompleteSearchFn } from './autocomplete.types'; +import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types'; interface LookupItem { readonly code: string; @@ -26,24 +26,29 @@ const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' }; [searchFn]="searchFn" [displayWith]="displayWith" [valueWith]="valueWith" + placeholder="e.g.: USD" [selectedItem]="selectedItem()" + [resolveValueFn]="resolveValueFn()" [minSearchLength]="minLength" [debounceTime]="delay()" [readonly]="readonly()" - [showDropdownOnFocus]="openOnFocus" + [showDropdownOnFocus]="openOnFocus()" + [submitAttempted]="submitAttempted()" /> ` }) class HostComponent { readonly control = new FormControl(null); readonly selectedItem = signal(null); + readonly resolveValueFn = signal | null>(null); searchFn: AutocompleteSearchFn = () => of([INDIA, INDONESIA]); readonly displayWith = (item: LookupItem): string => item.title; readonly valueWith = (item: LookupItem): string => item.code; minLength = 2; readonly delay = signal(300); readonly readonly = signal(false); - openOnFocus = false; + readonly openOnFocus = signal(false); + readonly submitAttempted = signal(false); } describe('Autocomplete', () => { @@ -73,6 +78,11 @@ describe('Autocomplete', () => { it('initializes with an empty selection', () => { expect(component.searchText()).toBe(''); expect(component.options()).toEqual([]); + expect(input().value).toBe(''); + expect(input().placeholder).toBe('e.g.: USD'); + expect(input().className).not.toContain('ti-form-control'); + expect(input().className).toContain('placeholder:text-textmuted'); + expect(input().className).toContain('dark:placeholder:text-white/50'); }); it('integrates with a reactive form control', () => { @@ -80,6 +90,14 @@ describe('Autocomplete', () => { expect(host.control.value).toBe('IN'); }); + it('treats edited selected text as search text rather than a selected item', () => { + component.select(INDIA); + expect(component.activeItem()).toEqual(INDIA); + type('Ind'); + expect(component.searchText()).toBe('Ind'); + expect(component.activeItem()).toBeNull(); + }); + it('does not emit onChange from writeValue', () => { const change = vi.fn(); component.registerOnChange(change); @@ -87,6 +105,28 @@ describe('Autocomplete', () => { expect(change).not.toHaveBeenCalled(); }); + it('does not show invalid styling after touch before submit', () => { + host.control.setValidators(Validators.required); + host.control.updateValueAndValidity(); + host.control.markAsTouched(); + fixture.detectChanges(); + expect(input().classList.contains('is-invalid')).toBe(false); + }); + + it('shows invalid styling after submit and removes it when valid', () => { + host.control.setValidators(Validators.required); + host.control.updateValueAndValidity(); + host.submitAttempted.set(true); + fixture.detectChanges(); + expect(input().classList.contains('is-invalid')).toBe(true); + expect(fixture.nativeElement.textContent).toContain('Country is required.'); + + host.control.setValue('IN'); + fixture.detectChanges(); + expect(input().classList.contains('is-invalid')).toBe(false); + expect(fixture.nativeElement.textContent).not.toContain('Country is required.'); + }); + it('applies a disabled form state', () => { host.control.disable(); fixture.detectChanges(); @@ -117,6 +157,40 @@ describe('Autocomplete', () => { vi.useRealTimers(); }); + it('loads five preview records on focus before the user types', () => { + vi.useFakeTimers(); + const search = vi.fn(() => of([INDIA, INDONESIA])); + host.openOnFocus.set(true); + host.searchFn = search; + fixture.detectChanges(); + input().focus(); + vi.advanceTimersByTime(0); + fixture.detectChanges(); + expect(search).toHaveBeenCalledWith('', 5); + expect(component.options()).toEqual([INDIA, INDONESIA]); + expect(component.showingPreview()).toBe(true); + expect(overlayContainer.getContainerElement().textContent).toContain('Type to search more...'); + vi.useRealTimers(); + }); + + it('replaces preview records with typed search results', () => { + vi.useFakeTimers(); + host.openOnFocus.set(true); + host.delay.set(0); + host.searchFn = (term, _limit) => of(term ? [INDONESIA] : [INDIA]); + fixture.detectChanges(); + input().focus(); + vi.advanceTimersByTime(0); + expect(component.options()).toEqual([INDIA]); + type('in'); + vi.advanceTimersByTime(0); + fixture.detectChanges(); + expect(component.options()).toEqual([INDONESIA]); + expect(component.showingPreview()).toBe(false); + expect(overlayContainer.getContainerElement().textContent).not.toContain('Type to search more...'); + vi.useRealTimers(); + }); + it('cancels stale search requests', () => { vi.useFakeTimers(); const first = new Subject(); @@ -227,6 +301,36 @@ describe('Autocomplete', () => { expect(component.searchText()).toBe('India'); }); + it('does not resolve an empty string form value', () => { + const resolve = vi.fn(() => of(INDIA)); + host.resolveValueFn.set(resolve); + fixture.detectChanges(); + host.control.setValue(''); + fixture.detectChanges(); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('uses the matching selected item without resolving it again', () => { + const resolve = vi.fn(() => of(INDIA)); + host.resolveValueFn.set(resolve); + host.selectedItem.set(INDIA); + fixture.detectChanges(); + host.control.setValue('IN'); + fixture.detectChanges(); + expect(component.searchText()).toBe('India'); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('resolves a non-empty value when no matching selected item is supplied', () => { + const resolve = vi.fn(() => of(INDIA)); + host.resolveValueFn.set(resolve); + fixture.detectChanges(); + host.control.setValue('IN'); + fixture.detectChanges(); + expect(resolve).toHaveBeenCalledWith('IN'); + expect(component.searchText()).toBe('India'); + }); + it('does not retain a stale label when edit values change', () => { host.selectedItem.set(INDIA); host.control.setValue('IN'); diff --git a/src/app/shared/components/form/autocomplete/autocomplete.ts b/src/app/shared/components/form/autocomplete/autocomplete.ts index 51dc00eb..94f2aa4d 100644 --- a/src/app/shared/components/form/autocomplete/autocomplete.ts +++ b/src/app/shared/components/form/autocomplete/autocomplete.ts @@ -40,6 +40,12 @@ interface SearchResult { readonly term: string; readonly options: readonly TItem[]; readonly failed: boolean; + readonly preview: boolean; +} + +interface SearchRequest { + readonly term: string; + readonly preview: boolean; } @Component({ @@ -59,9 +65,10 @@ export class Autocomplete implements ControlValueAccessor { private readonly injector = inject(Injector); private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`; - private readonly inputTerms$ = new Subject(); + private readonly searchRequests$ = new Subject(); private readonly valuesToResolve$ = new Subject(); private formValue: TValue | null = null; + private readonly controlStateVersion = signal(0); private labelEdited = false; private onChange: (value: TValue | null) => void = () => {}; private onTouched: () => void = () => {}; @@ -81,6 +88,8 @@ export class Autocomplete implements ControlValueAccessor { readonly minSearchLength = input(1); readonly debounceTime = input(300); readonly limit = input(10); + readonly previewLimit = input(5); + readonly previewText = input('Type to search more...'); readonly disabled = input(false); readonly readonly = input(false); readonly clearable = input(true); @@ -89,7 +98,7 @@ export class Autocomplete implements ControlValueAccessor { readonly emptyText = input('No results found'); readonly typeToSearchText = input('Type to search'); readonly errorText = input('Unable to load results'); - readonly showDropdownOnFocus = input(false); + readonly showDropdownOnFocus = input(true); readonly closeOnSelect = input(true); readonly autocomplete = input('off'); readonly ariaLabel = input(null); @@ -102,6 +111,7 @@ export class Autocomplete implements ControlValueAccessor { readonly validationMessages = input({}); readonly description = input(null); readonly hint = input(null); + readonly help = input(null); readonly labelPosition = input('top'); readonly itemSelected = output(); @@ -118,6 +128,7 @@ export class Autocomplete implements ControlValueAccessor { readonly activeItem = signal(null); readonly searchText = signal(''); readonly error = signal(null); + readonly showingPreview = signal(false); readonly formDisabled = signal(false); readonly panelWidth = signal(0); @@ -136,21 +147,24 @@ export class Autocomplete implements ControlValueAccessor { readonly message = computed(() => { if (this.loading()) return this.loadingText(); if (this.error()) return this.errorText(); + if (this.showingPreview()) return ''; if (this.searchText().trim().length < this.minSearchLength()) { return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`; } return this.options().length ? '' : this.emptyText(); }); readonly resolvedInputClass = computed(() => { + this.controlStateVersion(); const control = this.control(); - const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted())); + const invalid = !!(control?.invalid && this.submitAttempted()); return [ - 'form-control w-full rounded-sm border-defaultborder text-defaulttextcolor', + 'ti-form-select w-full rounded-sm border border-defaultborder bg-white text-defaulttextcolor', 'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70', - 'focus:border-primary focus:ring-1 focus:ring-primary', - 'pe-16', + 'placeholder:text-textmuted placeholder:opacity-100 dark:placeholder:text-white/50', + 'focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none', + 'pe-10 transition-colors', invalid ? 'is-invalid border-danger' : '', - this.isDisabled() ? 'cursor-not-allowed opacity-60' : '', + this.isDisabled() ? 'cursor-not-allowed bg-light opacity-60 dark:bg-black/20' : '', this.inputClass() ].filter(Boolean).join(' '); }); @@ -161,24 +175,49 @@ export class Autocomplete implements ControlValueAccessor { ]; constructor() { - this.inputTerms$.pipe( - map(term => term.trim()), - debounce(() => timer(Math.max(0, this.debounceTime()))), - distinctUntilChanged(), - switchMap(term => { - if (term.length < this.minSearchLength()) { - return of>({ term, options: [], failed: false }); + effect((onCleanup) => { + const control = this.control(); + if (!control) return; + + const statusSubscription = control.statusChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + const valueSubscription = control.valueChanges.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + const eventsSubscription = control.events?.subscribe(() => { + this.controlStateVersion.update(value => value + 1); + }); + + onCleanup(() => { + statusSubscription.unsubscribe(); + valueSubscription.unsubscribe(); + eventsSubscription?.unsubscribe(); + }); + }); + + this.searchRequests$.pipe( + map(request => ({ ...request, term: request.term.trim() })), + debounce(request => timer(request.preview ? 0 : Math.max(0, this.debounceTime()))), + distinctUntilChanged((previous, current) => + previous.term === current.term && previous.preview === current.preview + ), + switchMap(request => { + if (!request.preview && request.term.length < this.minSearchLength()) { + return of>({ ...request, options: [], failed: false }); } this.loading.set(true); this.error.set(null); - return this.searchFn()(term, this.limit()).pipe( - map(options => ({ term, options, failed: false })), - catchError(() => of>({ term, options: [], failed: true })) + const limit = request.preview ? this.previewLimit() : this.limit(); + return this.searchFn()(request.term, limit).pipe( + map(options => ({ ...request, options, failed: false })), + catchError(() => of>({ ...request, options: [], failed: true })) ); }), takeUntilDestroyed() ).subscribe(result => { this.loading.set(false); + this.showingPreview.set(result.preview && !result.failed); this.options.set(result.options); this.activeIndex.set(-1); this.error.set(result.failed ? this.errorText() : null); @@ -203,7 +242,7 @@ export class Autocomplete implements ControlValueAccessor { writeValue(value: TValue | null): void { this.formValue = value ?? null; this.labelEdited = false; - if (this.formValue === null) { + if (!this.hasResolvableValue(this.formValue)) { this.applyResolvedItem(null); return; } @@ -228,17 +267,21 @@ export class Autocomplete implements ControlValueAccessor { const text = event.target.value; const previousText = this.searchText(); this.searchText.set(text); + this.activeItem.set(null); + this.showingPreview.set(false); + this.options.set([]); this.labelEdited = this.formValue !== null && text !== previousText; this.searchChanged.emit(text.trim()); this.open(); - this.inputTerms$.next(text); + this.searchRequests$.next({ term: text, preview: false }); } onFocus(): void { - if (this.showDropdownOnFocus()) { - this.open(); - this.inputTerms$.next(this.searchText()); - } + if (this.showDropdownOnFocus()) this.openPreview(); + } + + onClick(): void { + if (this.showDropdownOnFocus()) this.openPreview(); } onBlur(): void { @@ -305,6 +348,19 @@ export class Autocomplete implements ControlValueAccessor { optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; } optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; } + isOptionSelected(item: TItem): boolean { + return this.formValue !== null && this.valuesEqual(this.valueWith()(item), this.formValue); + } + optionClass(item: TItem, index: number): string { + const highlighted = this.activeIndex() === index; + const selected = this.isOptionSelected(item); + return [ + 'block w-full px-3 py-2 text-start text-[0.8125rem] transition-colors', + highlighted || selected + ? 'bg-primary text-white' + : 'bg-white text-defaulttextcolor hover:bg-primary hover:text-white dark:bg-bodybg dark:text-white/70 dark:hover:bg-primary dark:hover:text-white' + ].join(' '); + } private setActive(index: number): void { if (index < 0 || index >= this.options().length) return; @@ -319,6 +375,7 @@ export class Autocomplete implements ControlValueAccessor { this.options.set([]); this.error.set(null); this.loading.set(false); + this.showingPreview.set(false); this.labelEdited = false; this.onChange(null); this.onTouched(); @@ -335,4 +392,16 @@ export class Autocomplete implements ControlValueAccessor { } private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); } + + private hasResolvableValue(value: TValue | null): value is TValue { + return value !== null && (typeof value !== 'string' || value.trim().length > 0); + } + + private openPreview(): void { + if (this.isDisabled() || this.readonly() || this.isOpen()) return; + this.open(); + if (!this.searchText().trim()) { + this.searchRequests$.next({ term: '', preview: true }); + } + } } diff --git a/src/app/shared/components/form/form-field/form-field.html b/src/app/shared/components/form/form-field/form-field.html index c8e05937..cbd3cf39 100644 --- a/src/app/shared/components/form/form-field/form-field.html +++ b/src/app/shared/components/form/form-field/form-field.html @@ -1,45 +1,49 @@
@if (showLabel()) { - }
@if (description()) { -

- {{ description() }} -

+

+ {{ description() }} +

} @if (showHint()) { -

- {{ hint() }} -

+

+ {{ hint() }} +

} @if (!hideValidation()) { - + }
-
+
\ No newline at end of file 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 1194fb70..3f869a92 100644 --- a/src/app/shared/components/form/form-field/form-field.ts +++ b/src/app/shared/components/form/form-field/form-field.ts @@ -12,13 +12,14 @@ import { FormValidationMessage, ValidationMessageMap } from '../form-validation-message/form-validation-message'; +import { TooltipDirective } from '../../../directives/tooltip/tooltip.directive'; export type FormLabelPosition = 'top' | 'left' | 'hidden'; @Component({ selector: 'app-form-field', standalone: true, - imports: [FormValidationMessage], + imports: [FormValidationMessage, TooltipDirective], templateUrl: './form-field.html', changeDetection: ChangeDetectionStrategy.OnPush }) @@ -36,6 +37,15 @@ export class FormField { readonly description = input(null); readonly hint = input(null); + readonly help = input(null); + + readonly showHelpIcon = computed(() => { + return !!this.help()?.trim(); + }); + + readonly resolvedHelp = computed(() => { + return this.help()?.trim() ?? ''; + }); readonly labelPosition = input('top'); @@ -120,9 +130,8 @@ export class FormField { } return ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ); }); diff --git a/src/app/shared/components/form/form-input/form-input.html b/src/app/shared/components/form/form-input/form-input.html index 465c489f..c8756cca 100644 --- a/src/app/shared/components/form/form-input/form-input.html +++ b/src/app/shared/components/form/form-input/form-input.html @@ -6,6 +6,7 @@ [disabled]="isDisabled()" [description]="description()" [hint]="hint()" + [help]="help()" [hideValidation]="hideValidation()" [showValidationWhenDirty]="showValidationWhenDirty()" [submitAttempted]="submitAttempted()" diff --git a/src/app/shared/components/form/form-input/form-input.ts b/src/app/shared/components/form/form-input/form-input.ts index 8adaeff0..14592d6c 100644 --- a/src/app/shared/components/form/form-input/form-input.ts +++ b/src/app/shared/components/form/form-input/form-input.ts @@ -7,7 +7,7 @@ import { ValidationMessageMap } from '../form-validation-message/form-validation export type FormInputType = | 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search'; -export type FormInputMode = | 'none'| 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; +export type FormInputMode = | 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; export type FormInputIconPosition = 'left' | 'right'; @@ -28,7 +28,7 @@ export type FormInputIconPosition = 'left' | 'right'; export class FormInput implements ControlValueAccessor { private readonly injector = inject(Injector); - + readonly inputId = input.required(); readonly label = input.required(); @@ -54,14 +54,15 @@ export class FormInput implements ControlValueAccessor { readonly pattern = input(null); - + readonly min = input(null); readonly max = input(null); readonly step = input(null); - + readonly description = input(null); readonly hint = input(null); + readonly help = input(null); readonly hideValidation = input(false); readonly showValidationWhenDirty = input(false); @@ -78,25 +79,25 @@ export class FormInput implements ControlValueAccessor { readonly showPasswordToggle = input(true); readonly loading = input(false); - + readonly wrapperClass = input(''); readonly fieldContentClass = input(''); readonly labelClass = input(''); readonly inputClass = input(''); - + readonly ariaLabel = input(null); readonly ariaDescription = input(null); - + readonly value = signal(null); readonly formDisabled = signal(false); readonly passwordVisible = signal(false); private readonly controlStateVersion = signal(0); - private onChange: (value: string | number | null) => void = () => {}; + private onChange: (value: string | number | null) => void = () => { }; - private onTouched: () => void = () => {}; + private onTouched: () => void = () => { }; constructor() { effect(() => { @@ -192,9 +193,8 @@ export class FormInput implements ControlValueAccessor { const showInvalidState = !!( control?.invalid && ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ) ); @@ -228,9 +228,8 @@ export class FormInput implements ControlValueAccessor { if ( control?.invalid && ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ) ) { ids.push(`${this.inputId()}-validation`); 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 24de8de0..9144ae3c 100644 --- a/src/app/shared/components/form/form-select/form-select.ts +++ b/src/app/shared/components/form/form-select/form-select.ts @@ -367,9 +367,8 @@ export class FormSelect implements if ( control?.invalid && ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ) ) { ids.push(`${this.resolvedInputId()}-validation`); @@ -406,9 +405,8 @@ export class FormSelect implements const showInvalidState = !!( control?.invalid && ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showValidationWhenDirty() && control.dirty) ) ); diff --git a/src/app/shared/components/form/form-validation-message/form-validation-message.ts b/src/app/shared/components/form/form-validation-message/form-validation-message.ts index aa975827..804d5e92 100644 --- a/src/app/shared/components/form/form-validation-message/form-validation-message.ts +++ b/src/app/shared/components/form/form-validation-message/form-validation-message.ts @@ -71,9 +71,8 @@ export class FormValidationMessage { } return ( - control.touched || - control.dirty || - this.submitAttempted() + this.submitAttempted() || + (this.showWhenDirty() && control.dirty) ); }); diff --git a/src/app/shared/directives/tooltip/tooltip.directive.ts b/src/app/shared/directives/tooltip/tooltip.directive.ts index f65b5542..1028027f 100644 --- a/src/app/shared/directives/tooltip/tooltip.directive.ts +++ b/src/app/shared/directives/tooltip/tooltip.directive.ts @@ -1,10 +1,24 @@ -import { Directive, ElementRef, HostListener, OnDestroy, inject, input } from '@angular/core'; - -import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; - +import { + ComponentRef, + Directive, + ElementRef, + HostListener, + OnDestroy, + effect, + inject, + input +} from '@angular/core'; +import { + ConnectedPosition, + Overlay, + OverlayRef +} from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; -import { Tooltip } from './tooltip/tooltip'; +import { + Tooltip +} from './tooltip/tooltip'; +import { TooltipVariant } from './tooltip/tooltip'; export type TooltipPosition = | 'top' @@ -27,20 +41,42 @@ export class TooltipDirective implements OnDestroy { readonly tooltipPosition = input('top'); + readonly tooltipVariant = + input('default'); + readonly tooltipDisabled = input(false); readonly tooltipDelay = input(200); private overlayRef: OverlayRef | null = null; - private showTimeout: ReturnType | null = null; + private tooltipComponentRef: + ComponentRef | null = null; + + private showTimeout: + ReturnType | null = null; + + constructor() { + effect(() => { + const text = this.appTooltip(); + const variant = this.tooltipVariant(); + + this.tooltipComponentRef?.setInput('text', text); + this.tooltipComponentRef?.setInput( + 'variant', + variant + ); + }); + } @HostListener('mouseenter') @HostListener('focusin') show(): void { + const tooltipText = this.appTooltip().trim(); + if ( this.tooltipDisabled() || - !this.appTooltip() + tooltipText.length === 0 ) { return; } @@ -48,8 +84,9 @@ export class TooltipDirective implements OnDestroy { this.clearTimeout(); this.showTimeout = setTimeout(() => { + this.showTimeout = null; this.openTooltip(); - }, this.tooltipDelay()); + }, Math.max(0, this.tooltipDelay())); } @HostListener('mouseleave') @@ -60,37 +97,49 @@ export class TooltipDirective implements OnDestroy { } private openTooltip(): void { - if (this.overlayRef) { + if (this.overlayRef?.hasAttached()) { return; } const positionStrategy = this.overlay .position() - .flexibleConnectedTo(this.elementRef) - .withPositions(this.getPositions()); + .flexibleConnectedTo( + this.elementRef.nativeElement + ) + .withPositions(this.getPositions()) + .withPush(true); this.overlayRef = this.overlay.create({ positionStrategy, - scrollStrategy: this.overlay.scrollStrategies.reposition() + scrollStrategy: + this.overlay.scrollStrategies.reposition() }); const portal = new ComponentPortal(Tooltip); - const componentRef = this.overlayRef.attach(portal); + this.tooltipComponentRef = + this.overlayRef.attach(portal); - componentRef.setInput( + this.tooltipComponentRef.setInput( 'text', - this.appTooltip() + this.appTooltip().trim() + ); + + this.tooltipComponentRef.setInput( + 'variant', + this.tooltipVariant() ); } private closeTooltip(): void { this.overlayRef?.dispose(); + this.overlayRef = null; + this.tooltipComponentRef = null; } private clearTimeout(): void { - if (!this.showTimeout) { + if (this.showTimeout === null) { return; } @@ -110,6 +159,13 @@ export class TooltipDirective implements OnDestroy { overlayX: 'center', overlayY: 'bottom', offsetY: -8 + }, + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8 } ], @@ -120,6 +176,13 @@ export class TooltipDirective implements OnDestroy { overlayX: 'center', overlayY: 'top', offsetY: 8 + }, + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8 } ], @@ -130,6 +193,13 @@ export class TooltipDirective implements OnDestroy { overlayX: 'end', overlayY: 'center', offsetX: -8 + }, + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8 } ], @@ -140,6 +210,13 @@ export class TooltipDirective implements OnDestroy { overlayX: 'start', overlayY: 'center', offsetX: 8 + }, + { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8 } ] }; diff --git a/src/app/shared/directives/tooltip/tooltip/tooltip.html b/src/app/shared/directives/tooltip/tooltip/tooltip.html index fe845cbc..ca66ff07 100644 --- a/src/app/shared/directives/tooltip/tooltip/tooltip.html +++ b/src/app/shared/directives/tooltip/tooltip/tooltip.html @@ -1,7 +1,7 @@ -
{{ text() }} +
--> + +
+ {{ text() }}
\ No newline at end of file diff --git a/src/app/shared/directives/tooltip/tooltip/tooltip.ts b/src/app/shared/directives/tooltip/tooltip/tooltip.ts index b182b1e7..d38b7207 100644 --- a/src/app/shared/directives/tooltip/tooltip/tooltip.ts +++ b/src/app/shared/directives/tooltip/tooltip/tooltip.ts @@ -1,9 +1,15 @@ import { ChangeDetectionStrategy, Component, - input + input, + computed } from '@angular/core'; + +export type TooltipVariant = + | 'default' + | 'info'; + @Component({ selector: 'app-tooltip', standalone: true, @@ -12,4 +18,42 @@ import { }) export class Tooltip { readonly text = input.required(); + + readonly variant = + input('default'); + + readonly resolvedTooltipClass = computed(() => { + const baseClasses = [ + 'pointer-events-none', + 'max-w-xs', + 'whitespace-normal', + 'rounded-sm', + 'px-2', + 'py-1', + 'text-xs', + 'font-medium', + 'leading-4' + ]; + + const variantClasses = + this.variant() === 'info' + ? [ + 'border', + 'border-defaultborder', + 'bg-white', + 'text-defaulttextcolor', + 'shadow-lg', + 'dark:bg-bodybg' + ] + : [ + 'bg-primary', + 'text-white', + 'shadow-sm' + ]; + + return [ + ...baseClasses, + ...variantClasses + ].join(' '); + }); } \ No newline at end of file