add tenant and user endpoints, services, models and guards

This commit is contained in:
Gagan7900
2026-07-18 16:38:41 +05:30
parent be86ab3564
commit 7c4e34e478
50 changed files with 3064 additions and 451 deletions
@@ -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;
@@ -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;
@@ -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;
@@ -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']);
};
+26 -1
View File
@@ -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<string, unknown>;
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');
}
@@ -1,6 +1,7 @@
export interface CurrencyDto {
id: string;
code: string;
iso2: string;
name: string;
symbol: string;
numericCode: number;
@@ -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';
@@ -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';
+29
View File
@@ -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';
+12 -2
View File
@@ -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',
@@ -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<MenuService['getNavigationMenu']>, path: string): boolean {
return items.some(
(item) =>
item.path === path ||
(item.children ? hasPath(item.children, path) : false) ||
(item.children2 ? hasPath(item.children2, path) : false),
);
}
+16 -2
View File
@@ -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<MenuContext | null>(null);
loadMenu(): Observable<MenuContext> {
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[];
}
}
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
}));
}
}
@@ -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<DataTableResult<TenantCurrencyDto>> {
return this.http.post<DataTableResult<TenantCurrencyDto>>(TENANT_CURRENCIES_ENDPOINTS.dataTable, query);
}
createTenant(request: CreateTenantCurrencyRequest): Observable<TenantCurrencyDto> {
return this.http.post<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.create, request);
}
updateTenant(id: string, request: UpdateTenantCurrencyRequest): Observable<TenantCurrencyDto> {
return this.http.put<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.update(id), request);
}
getTenantById(id: string): Observable<TenantCurrencyDto> {
return this.http.get<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.getById(id));
}
autocomplete(term?: string, limit = 10): Observable<readonly TenantCurrencyLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly TenantCurrencyLookupDto[]>(TENANT_CURRENCIES_ENDPOINTS.autocomplete, { params });
}
}
@@ -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<DataTableResult<TenantDto>> {
return this.http.post<DataTableResult<TenantDto>>(TENANT_ENDPOINTS.dataTable, query);
}
createTenant(request: CreateTenantRequest): Observable<TenantDto> {
return this.http.post<TenantDto>(TENANT_ENDPOINTS.create, request);
}
updateTenant(id: string, request: UpdateTenantRequest): Observable<TenantDto> {
return this.http.put<TenantDto>(TENANT_ENDPOINTS.update(id), request);
}
getTenantById(id: string): Observable<TenantDto> {
return this.http.get<TenantDto>(TENANT_ENDPOINTS.getById(id));
}
autocomplete(term?: string, limit = 10): Observable<readonly TenantLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly TenantLookupDto[]>(TENANT_ENDPOINTS.autocomplete, { params });
}
}
@@ -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<UserDto> {
return this.http.post<UserDto>(USER_ENDPOINTS.create, request);
}
getById(id: string): Observable<UserDto> {
return this.http.get<UserDto>(USER_ENDPOINTS.getById(id));
}
getDataTable(query: DataTableQuery): Observable<DataTableResult<UserDto>> {
return this.http.post<DataTableResult<UserDto>>(USER_ENDPOINTS.dataTable, query);
}
update(id: string, request: UpdateUserRequest): Observable<UserDto> {
return this.http.put<UserDto>(USER_ENDPOINTS.update(id), request);
}
autocomplete(term: string | null, limit = 10): Observable<readonly UserLookupDto[]> {
const normalizedTerm = term?.trim() || null;
let params = new HttpParams().set('limit', limit);
if (normalizedTerm !== null) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly UserLookupDto[]>(USER_ENDPOINTS.autocomplete, { params });
}
}
@@ -1,12 +1,8 @@
<!-- Start::row-1 -->
<div class="grid grid-cols-12 gap-6">
<div class="xl:col-span-12 col-span-12">
<div class="box custom-box">
<div class="box-body p-4">
<app-filter-card title="Location Selection" bodyClass="p-4">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex flex-wrap gap-1 newproject">
<div class="box-title mb-0">Location Selection</div>
</div>
<form [formGroup]="filterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
@@ -55,8 +51,7 @@
</div>
</form>
</div>
</div>
</div>
</app-filter-card>
</div>
</div>
<!-- End::row-1 -->
@@ -64,7 +59,7 @@
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
[showAddButton]="canAddCity()" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
[showAddButton]="true" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)" toolTip="Add City" />
@@ -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 @@
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="name" inputId="city-name" label="City Name"
placeholder="Enter city name" autocomplete="off" [required]="true"
placeholder="Name" autocomplete="off" [required]="true"
[maxLength]="150" [validationMessages]="{
required: 'City Name is required.',
maxlength: 'City Name cannot exceed 150 characters.'
@@ -135,7 +133,7 @@
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="code" inputId="city-code" label="City Code"
placeholder="Enter city code" autocomplete="off" [required]="true"
placeholder="Code" autocomplete="off" [required]="true"
[maxLength]="16" [validationMessages]="{
required: 'City Code is required.',
maxlength: 'City Code cannot exceed 16 characters.',
@@ -148,7 +146,7 @@
formControlName="timezoneId"
inputId="city-timezone"
label="Timezone"
placeholder="Search timezone"
placeholder="Search"
[searchFn]="searchTimezones"
[displayWith]="displayTimezone"
[valueWith]="timezoneValue"
@@ -47,6 +47,7 @@ import {
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { Modal } from '../../../../../shared/components/modal/modal';
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
interface CityTableRow extends DataTableRecord {
id: string;
@@ -65,7 +66,7 @@ interface CityTableRow extends DataTableRecord {
@Component({
selector: 'city-list',
standalone: true,
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete],
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard],
templateUrl: './city-list.html',
styleUrl: './city-list.scss'
})
@@ -148,8 +149,16 @@ export class CityList {
};
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
value => this.countryApi.getCountryById(value).pipe(
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
);
readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
readonly stateValue: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
readonly resolveState: AutocompleteResolveValueFn<StateLookupDto, string> =
value => this.stateApi.getStateById(value).pipe(
map(state => ({ id: state.id, name: state.name, code: state.code ?? '' }))
);
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> =
timezone => `${timezone.ianaId}${timezone.displayName}`;
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> =
@@ -157,12 +166,11 @@ export class CityList {
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> =
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<DataTableColumn<CityTableRow>[]>([
@@ -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 ?? '',
@@ -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',
@@ -20,18 +20,17 @@
(actionClicked)="onActionClick($event)"
>
<ng-template appDataTableCell="name" let-row let-value="value">
<div class="flex items-center gap-2">
<span
class="inline-flex min-w-8 justify-center rounded-sm bg-light px-2 py-1 text-[0.75rem] font-semibold text-primary dark:bg-black/20"
>
{{ row.symbol }}
</span>
<div class="flex items-center gap-2">
@if (getFlagUrl(row.iso2); as flagUrl) {
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
(error)="onFlagError($event)" />
}
<span class="font-semibold">
{{ value }}
</span>
</div>
</ng-template>
<span class="font-semibold">
{{ value }}
</span>
</div>
</ng-template>
<ng-template appDataTableCell="code" let-value="value">
<span class="badge bg-primary/10 text-primary">
@@ -75,6 +74,7 @@
required: 'Currency Name is required.',
maxlength: 'Currency Name cannot exceed 100 characters.'
}"
[submitAttempted]="currencySubmitAttempted()"
/>
</div>
@@ -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()"
/>
</div>
@@ -111,6 +111,7 @@
required: 'Currency Symbol is required.',
maxlength: 'Currency Symbol cannot exceed 8 characters.'
}"
[submitAttempted]="currencySubmitAttempted()"
/>
</div>
@@ -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()"
/>
</div>
@@ -153,6 +155,7 @@
min: 'Decimal Digits cannot be less than 0.',
max: 'Decimal Digits cannot exceed 4.'
}"
[submitAttempted]="currencySubmitAttempted()"
/>
</div>
</div>
@@ -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<DataTableColumn<CurrencyTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
{ key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true },
{ 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';
}
}
@@ -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'
@@ -1,20 +1,14 @@
<!-- Start::row-1 -->
<div class="grid grid-cols-12 gap-6">
<div class="xl:col-span-12 col-span-12">
<div class="box custom-box">
<div class="box-body p-4">
<app-filter-card title="Country Selection" bodyClass="p-4">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex flex-wrap gap-1 newproject">
<div class="box-title mb-0">Country Selection</div>
</div>
<form [formGroup]="countryFilterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
<app-autocomplete
formControlName="countryId"
inputId="state-country-filter"
label="Country"
placeholder="Search country"
placeholder="Search"
[searchFn]="searchCountries"
[displayWith]="displayCountry"
[valueWith]="countryValue"
@@ -31,16 +25,14 @@
</div>
</form>
</div>
</div>
</div>
</app-filter-card>
</div>
</div>
<!-- End::row-1 -->
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="States" buttonTitle="Add" [showSearch]="true"
[showAddButton]="showStateAddButton()" searchPlaceholder="Search states..." [searchDebounceTime]="300"
[showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
@@ -59,6 +51,32 @@
(closed)="closeStateModal()" (submitted)="saveState()">
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="countryId"
inputId="state-country"
label="Country"
placeholder="Search"
[searchFn]="searchCountries"
[displayWith]="displayCountry"
[valueWith]="countryValue"
[resolveValueFn]="resolveCountry"
[selectedItem]="selectedFormCountry()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="stateModalMode() === 'create'"
[required]="true"
[clearable]="true"
[readonly]="stateModalMode() !== 'create'"
[validationMessages]="{ required: 'Country is required.' }"
[submitAttempted]="stateSubmitAttempted()"
wrapperClass="w-full"
(itemSelected)="onFormCountrySelected($event)"
(cleared)="onFormCountryCleared()"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name"
autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
@@ -6,7 +6,7 @@ import {
Validators
} from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { Subject, catchError, distinctUntilChanged, finalize, of, switchMap } from 'rxjs';
import { Subject, catchError, distinctUntilChanged, finalize, map, of, switchMap } from 'rxjs';
import {
CountryLookupDto
@@ -33,12 +33,14 @@ import { DataTable } from '../../../../../shared/components/data-table/data-tabl
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 { Modal } from '../../../../../shared/components/modal/modal';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
interface StateTableRow extends DataTableRecord {
id: string;
@@ -54,12 +56,12 @@ interface StateTableRow extends DataTableRecord {
@Component({
selector: 'state-list',
standalone: true,
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog],
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard],
templateUrl: './state-list.html',
styleUrl: './state-list.scss',
})
export class StateList {
private readonly destroyRef = inject(DestroyRef);
private readonly stateApi = inject(StateService);
private readonly countryApi = inject(CountryService);
@@ -73,6 +75,7 @@ export class StateList {
readonly states = signal<StateTableRow[]>([]);
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
readonly selectedCountryId = signal<string | null>(null);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
@@ -94,6 +97,10 @@ export class StateList {
(term, limit) => this.countryApi.autocomplete(term, limit);
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
value => this.countryApi.getCountryById(value).pipe(
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
);
readonly stateForm = this.formBuilder.nonNullable.group({
countryId: [
@@ -119,10 +126,6 @@ export class StateList {
]
});
readonly showStateAddButton = computed(() =>
!!this.selectedCountryId()
);
readonly emptyMessage = computed(() =>
this.selectedCountryId()
? 'No states found'
@@ -143,14 +146,14 @@ export class StateList {
readonly stateSubmitLabel = computed(() =>
this.stateModalMode() === 'create'
? 'Save State'
: 'Update State'
? 'Save'
: 'Update'
);
readonly stateLoadingLabel = computed(() =>
this.stateModalMode() === 'create'
? 'Saving State...'
: 'Updating State...'
? 'Saving...'
: 'Updating...'
);
readonly stateSubmitAction = computed<'save' | 'update'>(() =>
@@ -161,7 +164,7 @@ export class StateList {
readonly columns = signal<DataTableColumn<StateTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' },
{ key: 'name', header: 'Name', label: 'Name', sortable: true ,align: 'left'},
{ key: 'name', header: 'Name', label: 'Name', sortable: true, align: 'left' },
{ key: 'code', header: 'Code', label: 'Code', sortable: true },
{
key: 'isActive',
@@ -269,6 +272,14 @@ export class StateList {
this.selectedCountryLookup.set(country);
}
onFormCountrySelected(country: CountryLookupDto): void {
this.selectedFormCountry.set(country);
}
onFormCountryCleared(): void {
this.selectedFormCountry.set(null);
}
onSearch(value: string): void {
const query = this.queryState.setSearch(value.trim());
this.loadStates(query);
@@ -336,20 +347,14 @@ export class StateList {
}
onAddState(): void {
const countryId = this.selectedCountryId();
if (!countryId) {
this.toastr.error('Select a country before adding a state.');
return;
}
this.stateModalMode.set('create');
this.selectedStateId.set(null);
this.selectedState.set(null);
this.stateSubmitAttempted.set(false);
this.selectedFormCountry.set(null);
this.stateForm.reset({
countryId,
countryId: '',
name: '',
code: ''
});
@@ -366,6 +371,7 @@ export class StateList {
this.showStateModal.set(false);
this.selectedStateId.set(null);
this.selectedState.set(null);
this.selectedFormCountry.set(null);
this.stateSubmitAttempted.set(false);
}
@@ -486,8 +492,9 @@ export class StateList {
.subscribe({
next: stateDetails => {
this.selectedState.set(stateDetails);
this.selectedFormCountry.set(null);
this.stateForm.reset({
countryId: stateDetails.countryId ?? this.selectedCountryId() ?? '',
countryId: stateDetails.countryId ?? '',
name: stateDetails.name ?? '',
code: stateDetails.code ?? ''
});
@@ -566,6 +573,7 @@ export class StateList {
this.showStateModal.set(false);
this.selectedStateId.set(null);
this.selectedState.set(null);
this.selectedFormCountry.set(null);
this.stateSubmitAttempted.set(false);
this.loadStates(this.queryState.getQuery());
}
@@ -62,7 +62,8 @@
formControlName="ianaId"
inputId="timezone-iana-id"
label="IANA Timezone ID"
placeholder="e.g.: Asia/Kolkata"
placeholder="Id"
help="e.g.: Asia/Kolkata"
autocomplete="off"
[required]="true"
[readonly]="isViewMode()"
@@ -80,7 +81,8 @@
formControlName="displayName"
inputId="timezone-display-name"
label="Display Name"
placeholder="e.g.: India Standard Time"
placeholder="Name"
help="e.g.: India Standard Time"
autocomplete="off"
[required]="true"
[readonly]="isViewMode()"
@@ -100,13 +102,13 @@
label="UTC Offset (minutes)"
type="number"
inputMode="numeric"
placeholder="For example: 330"
placeholder="Minutes"
help="Enter an offset from -720 (-12:00) to 840 (+14:00)."
[required]="true"
[readonly]="isViewMode()"
[min]="-720"
[max]="840"
[step]="1"
hint="e.g.: Enter an offset from -720 (-12:00) to 840 (+14:00)."
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'UTC offset is required.',
@@ -86,8 +86,8 @@ export class TimezoneList implements OnInit {
case 'view': return 'View Timezone';
}
});
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save Timezone' : 'Update Timezone');
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving Timezone...' : 'Updating Timezone...');
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save' : 'Update');
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving...' : 'Updating...');
readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update');
readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([
@@ -0,0 +1,55 @@
<div class="grid grid-cols-12 gap-6">
<div class="xl:col-span-12 col-span-12">
<app-filter-card title="Tenant Selection" bodyClass="p-4">
<div class="flex items-center justify-between flex-wrap gap-4">
<form [formGroup]="tenantCurrencyFilterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
<app-autocomplete
formControlName="tenantId"
inputId="tenant-filter"
label="Tenant"
placeholder="Search"
[searchFn]="searchTenants"
[displayWith]="displayTenant"
[valueWith]="tenantValue"
[selectedItem]="selectedTenantLookup()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[clearable]="true"
[hideLabel]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onTenantLookupSelected($event)"
/>
</div>
</form>
</div>
</app-filter-card>
</div>
</div>
<app-data-table
[columns]="columns()"
[rows]="tenantCurrencies()"
[actions]="actions()"
[totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
tableTitle="Tenant Management"
buttonTitle="Add Tenant"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search tenants..."
[searchDebounceTime]="300"
actionHeaderClass="!text-center"
actionCellClass="!text-center"
emptyMessage="No tenants found"
emptyDescription="There is currently no data to display."
(addClicked)="onAddTenantCurrency()"
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)"
/>
@@ -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<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly queryRequests$ = new Subject<{ tenantId: string; query: DataTableQuery; }>();
readonly queryState = new DataTableQueryState();
readonly tenantCurrencies = signal<TenantCurrencyTableRow[]>([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly saving = signal(false);
readonly showTenantCurrencyModal = signal(false);
readonly tenantCurrencyModalMode = signal<TenantCurrencyModalMode>('create');
readonly selectedTenantId = signal<string | null>(null);
readonly selectedTenantLookup = signal<TenantLookupDto | null>(null);
readonly tenantSubmitAttempted = signal(false);
readonly tenantCurrencyForm = this.formBuilder.group({
tenantId: this.formBuilder.control<string | null>(null, [Validators.required]),
currencyId: this.formBuilder.control<string | null>(null, [Validators.required]),
isActive: this.formBuilder.control<number | null>(1, [Validators.required]),
isBaseCurrency: this.formBuilder.control<boolean>(false, [Validators.required]),
isReporting: this.formBuilder.control<boolean>(false, [Validators.required]),
});
readonly tenantCurrencyFilterForm = this.formBuilder.nonNullable.group({
tenantId: ['']
});
readonly searchTenants:
AutocompleteSearchFn<TenantLookupDto> =
(term, limit) =>
this.tenantApi.autocomplete(term, limit).pipe(
catchError(() => {
this.toastr.error(
'Unable to load tenants.'
);
return of<
readonly TenantLookupDto[]
>([]);
})
);
readonly displayTenant:
AutocompleteDisplayFn<TenantLookupDto> =
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<CurrencyLookupDto> = (term, limit) =>
this.currencyApi.autocomplete(term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load currencies.');
return of<readonly CurrencyLookupDto[]>([]);
})
);
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : '']
.filter(Boolean)
.join(' ');
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = 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<DataTableColumn<TenantCurrencyTableRow>[]>([
{ 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<DataTableAction<TenantCurrencyTableRow>[]>([
{
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<TenantCurrencyTableRow>): 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);
}
}
@@ -1,62 +1,185 @@
<app-data-table
title="Tenant List"
[columns]="columns"
[rows]="tenants"
[actions]="tableActions"
[loading]="loading"
emptyMessage="No tenants found"
[columns]="columns()"
[rows]="tenants()"
[actions]="actions()"
[totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
tableTitle="Tenant Management"
buttonTitle="Add Tenant"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search tenants..."
[searchDebounceTime]="300"
[totalRecords]="totalRecords"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 20, 50]"
[showPaginator]="true"
[allowedPermissions]="allowedPermissions"
tableHeadClass=""
tableBodyClass=""
trHeadClass="border-b border-defaultborder"
trBodyClass="border-b border-defaultborder hover:bg-light cursor-pointer"
defaultThClass="text-start"
defaultTdClass=""
actionHeaderClass="text-center"
actionCellClass="text-center"
actionHeaderClass="!text-center"
actionCellClass="!text-center"
emptyMessage="No tenants found"
emptyDescription="There is currently no data to display."
(addClicked)="onAddTenant()"
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onTableAction($event)"
(rowClicked)="onRowClick($event)"
(actionClicked)="onActionClick($event)"
/>
<modal
[open]="showTenantModal()"
[title]="modalTitle()"
size="lg"
[submitAction]="submitAction()"
[submitLabel]="submitLabel()"
[loadingLabel]="loadingLabel()"
[loading]="saving()"
(closed)="closeTenantModal()"
(submitted)="saveTenant()"
>
<form [formGroup]="tenantForm" (ngSubmit)="saveTenant()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="code"
inputId="tenant-code"
label="Tenant Code"
placeholder="Code"
autocomplete="off"
[required]="true"
[maxLength]="50"
[validationMessages]="{
required: 'Tenant Code is required.',
maxlength: 'Tenant Code cannot exceed 50 characters.'
}"
[submitAttempted]="tenantSubmitAttempted()"
/>
</div>
<ng-template appDataTableCell="tenantName" let-row let-value="value">
<div class="flex items-center">
<span class="avatar avatar-xs me-2 avatar-rounded">
<img [src]="row.logo" [alt]="value">
</span>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="name"
inputId="tenant-name"
label="Tenant Name"
placeholder="Name"
autocomplete="off"
[required]="true"
[maxLength]="150"
[validationMessages]="{
required: 'Tenant Name is required.',
maxlength: 'Tenant Name cannot exceed 150 characters.'
}"
[submitAttempted]="tenantSubmitAttempted()"
/>
</div>
<span class="font-semibold">
{{ value }}
</span>
</div>
</ng-template>
<div class="col-span-12 md:col-span-6">
<app-form-select
formControlName="status"
inputId="tenant-status"
label="Tenant Status"
placeholder="Select"
[options]="tenantStatusOptions()"
[required]="true"
[clearable]="false"
[searchable]="false"
[submitAttempted]="tenantSubmitAttempted()"
[validationMessages]="{
required: 'Tenant Status is required.'
}"
/>
</div>
</app-data-table>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="defaultLanguageId"
inputId="tenant-default-language-id"
label="Default Language"
placeholder="Search"
[searchFn]="searchLanguages"
[displayWith]="displayLanguage"
[valueWith]="languageValue"
[resolveValueFn]="resolveLanguage"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="10"
[clearable]="true"
emptyText="No languages found"
typeToSearchText="Type to search languages"
[required]="true"
[submitAttempted]="tenantSubmitAttempted()"
wrapperClass="w-full"
[validationMessages]="{
required: 'Default Language is required.'
}"
/>
</div>
<app-confirm-dialog
title="Delete Tenant"
text="Do you really want to delete this tenant?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
></app-confirm-dialog>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="defaultCurrencyId"
inputId="tenant-default-currency-id"
label="Default Currency"
placeholder="Search"
[searchFn]="searchCurrencies"
[displayWith]="displayCurrency"
[valueWith]="currencyValue"
[resolveValueFn]="resolveCurrency"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="10"
[clearable]="true"
emptyText="No currencies found"
typeToSearchText="Type to search currencies"
[required]="true"
[submitAttempted]="tenantSubmitAttempted()"
wrapperClass="w-full"
[validationMessages]="{
required: 'Default Currency is required.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="defaultTimezoneId"
inputId="tenant-default-timezone-id"
label="Default Timezone"
placeholder="Search"
[searchFn]="searchTimezones"
[displayWith]="displayTimezone"
[valueWith]="timezoneValue"
[resolveValueFn]="resolveTimezone"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="10"
[clearable]="true"
emptyText="No timezones found"
typeToSearchText="Type to search timezones"
[required]="true"
[submitAttempted]="tenantSubmitAttempted()"
wrapperClass="w-full"
[validationMessages]="{
required: 'Default Timezone is required.'
}"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="dataRegion"
inputId="tenant-data-region"
label="Data Region"
placeholder="Region"
help="e.g.: IN"
autocomplete="off"
[required]="true"
[maxLength]="100"
[validationMessages]="{
required: 'Data Region is required.',
maxlength: 'Data Region cannot exceed 100 characters.'
}"
[submitAttempted]="tenantSubmitAttempted()"
/>
</div>
</div>
</form>
</modal>
@@ -0,0 +1 @@
/* Intentionally empty: tenant screen reuses the shared Ynex layout classes. */
@@ -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<HTMLElement>>(ElementRef);
private readonly toastr = inject(ToastrService);
private readonly queryRequests$ = new Subject<DataTableQuery>();
tableQuery = new DataTableQueryState();
readonly queryState = new DataTableQueryState();
readonly tenants = signal<TenantTableRow[]>([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly saving = signal(false);
readonly showTenantModal = signal(false);
readonly tenantModalMode = signal<TenantModalMode>('create');
readonly selectedTenantId = signal<string | null>(null);
readonly selectedTenant = signal<TenantDto | null>(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 | null>(TenantStatus.Trial, [Validators.required]),
defaultLanguageId: this.formBuilder.control<string | null>(null, [Validators.required]),
defaultCurrencyId: this.formBuilder.control<string | null>(null, [Validators.required]),
defaultTimezoneId: this.formBuilder.control<string | null>(null, [Validators.required]),
dataRegion: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(100)]),
isActive: this.formBuilder.control<number | null>(1, [Validators.required])
});
allowedPermissions: string[] = [
'tenant.view',
'tenant.edit',
'tenant.delete'
];
readonly tenantStatusOptions = signal<FormSelectOption<TenantStatus>[]>([
{ 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<FormSelectOption<number>[]>([
{ 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<LanguageLookupDto> = (term, limit) =>
this.languageApi.autocomplete(term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load languages.');
return of<readonly LanguageLookupDto[]>([]);
})
);
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<CurrencyLookupDto> = (term, limit) =>
this.currencyApi.autocomplete(term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load currencies.');
return of<readonly CurrencyLookupDto[]>([]);
})
);
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<TimezoneLookupDto> = (term, limit) =>
this.timezoneApi.autocomplete(term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load timezones.');
return of<readonly TimezoneLookupDto[]>([]);
})
);
readonly displayLanguage: AutocompleteDisplayFn<LanguageLookupDto> = language => [language.code, language.name].filter(Boolean).join(' - ');
onSearch(searchText: string): void {
this.searchText = searchText;
this.pageIndex = 1;
readonly languageValue: AutocompleteValueFn<LanguageLookupDto, string> = 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<LanguageLookupDto, string> = 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<CurrencyLookupDto> = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : '']
.filter(Boolean)
.join(' ');
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = value =>
this.currencyApi.getCurrencyById(value).pipe(
map(currency => ({
id: currency.id,
code: currency.code,
name: currency.name,
symbol: currency.symbol
}))
);
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> = timezone => [timezone.ianaId, timezone.displayName].filter(Boolean).join(' — ');
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> = timezone => timezone.id;
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> = 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<DataTableColumn<TenantTableRow>[]>([
{ 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<DataTableAction<TenantTableRow>[]>([
{
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<TenantTableRow>): 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<HTMLElement>(
'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);
}
}
+6 -1
View File
@@ -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' },
},
];
@@ -1,4 +1,19 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform users</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Users, roles, assignment, deactivation, and password reset will be implemented here.</p>
</div>
<app-data-table [columns]="columns()" [rows]="users()" [actions]="actions()"
(addClicked)="onAddUser()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Users" buttonTitle="Add"
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search users..." [searchDebounceTime]="300"
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)" toolTip="Add User">
<!-- <ng-template appDataTableCell="name" let-row let-value="value">
<div class="flex items-center gap-2">
@if (getFlagUrl(row.iso2); as flagUrl) {
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
(error)="onFlagError($event)" />
}
<span class="font-semibold">
{{ value }}
</span>
</div>
</ng-template> -->
</app-data-table>
@@ -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<DataTableQuery>();
readonly users = signal<UserTableRow[]>([]);
readonly queryState = new DataTableQueryState();
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly userModalMode = signal<UserModalMode>('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<string[]>(
[],
[
Validators.required,
control =>
control.value.length > 0
? null
: { required: true }
]
)
});
readonly columns = signal<DataTableColumn<UserTableRow>[]>([
{
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<UserTableRow>): 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<DataTableAction<UserTableRow>[]>([]);
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';
}
}
}
+3 -1
View File
@@ -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' },
},
}
];
@@ -0,0 +1,41 @@
<div class="box custom-box" [class]="'box custom-box ' + cardClass()">
<div class="box-header justify-between" [class]="'box-header justify-between ' + headerClass()">
<div class="box-title">{{ title() }}</div>
<div class="flex items-center gap-2">
<ng-content select="[filterCardActions]" />
<button
type="button"
class="ti-btn ti-btn-sm ti-btn-light"
[class]="'ti-btn ti-btn-sm ti-btn-light ' + toggleButtonClass()"
[class.hidden]="!showToggleButton()"
[disabled]="!collapsible()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-controls]="resolvedContentId()"
[attr.aria-label]="currentTooltip()"
[appTooltip]="currentTooltip()"
(click)="toggleCollapse()"
>
<i [class]="currentIcon()" aria-hidden="true"></i>
</button>
</div>
</div>
<div
class="filter-card-body-grid"
[class.is-collapsed]="collapsed()"
[attr.aria-hidden]="collapsed()"
[attr.inert]="collapsed() ? '' : null"
>
<div class="filter-card-body-content">
<div
class="box-body"
[class]="'box-body ' + bodyClass()"
[id]="resolvedContentId()"
>
<ng-content />
</div>
</div>
</div>
</div>
@@ -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: `
<app-filter-card
title="Country Selection"
[defaultCollapsed]="defaultCollapsed()"
[collapsible]="collapsible()"
[collapsedIcon]="collapsedIcon"
[expandedIcon]="expandedIcon"
[contentId]="contentId()"
(collapseChanged)="changes.push($event)"
>
<button filterCardActions type="button">Reset</button>
<input data-testid="projected-input" value="preserved" />
</app-filter-card>
`
})
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<HostComponent>;
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);
});
});
@@ -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<string>('Filters');
readonly defaultCollapsed = input<boolean>(false);
readonly collapsible = input<boolean>(true);
readonly showToggleButton = input<boolean>(true);
readonly collapsedIcon = input<string>('ri-filter-3-line');
readonly expandedIcon = input<string>('ri-arrow-up-s-line');
readonly collapsedTooltip = input<string>('Show Filters');
readonly expandedTooltip = input<string>('Hide Filters');
readonly cardClass = input<string>('');
readonly headerClass = input<string>('');
readonly bodyClass = input<string>('');
readonly toggleButtonClass = input<string>('');
readonly contentId = input<string>('');
readonly collapseChanged = output<boolean>();
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);
}
}
@@ -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()) {
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2" aria-hidden="true">
<span class="ti-spinner h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></span>
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-primary" aria-hidden="true">
<span class="ti-spinner block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
</span>
} @else if (clearable() && (hasSelectedItem() || searchText())) {
<button
type="button"
class="absolute end-3 top-1/2 -translate-y-1/2 text-textmuted hover:text-danger"
class="absolute end-3 top-1/2 inline-flex -translate-y-1/2 items-center justify-center rounded-sm text-textmuted transition-colors hover:bg-light hover:text-danger focus:outline-none focus:ring-1 focus:ring-primary dark:hover:bg-black/20"
aria-label="Clear selection"
[disabled]="isDisabled() || readonly()"
(mousedown)="$event.preventDefault()"
@@ -75,11 +77,11 @@
<div
[id]="panelId()"
role="listbox"
class="max-h-64 overflow-y-auto rounded-sm border border-defaultborder bg-white py-1 text-defaulttextcolor shadow-lg dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70"
class="w-full max-h-64 overflow-y-auto rounded-sm border border-defaultborder bg-white py-1 text-defaulttextcolor shadow-lg dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70"
[class]="panelClass()"
>
@if (message()) {
<div class="px-3 py-2 text-[0.8125rem] text-textmuted" [class.text-danger]="error()">
<div class="bg-white px-3 py-2 text-[0.8125rem] text-textmuted dark:bg-bodybg dark:text-white/50" [class.text-danger]="error()">
{{ message() }}
</div>
} @else {
@@ -88,10 +90,8 @@
type="button"
role="option"
[id]="optionId(index)"
[class]="activeIndex() === index
? 'block w-full px-3 py-2 text-start text-[0.8125rem] bg-light text-primary dark:bg-black/20 dark:text-white/70'
: 'block w-full px-3 py-2 text-start text-[0.8125rem] hover:bg-light dark:hover:bg-black/20'"
[attr.aria-selected]="activeIndex() === index"
[class]="optionClass(item, index)"
[attr.aria-selected]="isOptionSelected(item)"
(mousedown)="$event.preventDefault()"
(mouseenter)="activeIndex.set(index)"
(click)="select(item)"
@@ -99,6 +99,15 @@
{{ displayWith()(item) }}
</button>
}
@if (showingPreview()) {
<div
role="option"
aria-disabled="true"
class="cursor-default border-t border-defaultborder bg-light/50 px-3 py-2 text-[0.8125rem] italic text-textmuted dark:border-defaultborder/10 dark:bg-black/10 dark:text-white/50"
>
{{ previewText() }}
</div>
}
}
</div>
</ng-template>
@@ -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<string | null>(null);
readonly selectedItem = signal<LookupItem | null>(null);
readonly resolveValueFn = signal<AutocompleteResolveValueFn<LookupItem, string> | null>(null);
searchFn: AutocompleteSearchFn<LookupItem> = () => 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<readonly LookupItem[]>();
@@ -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');
@@ -40,6 +40,12 @@ interface SearchResult<TItem> {
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<TItem, TValue> implements ControlValueAccessor {
private readonly injector = inject(Injector);
private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`;
private readonly inputTerms$ = new Subject<string>();
private readonly searchRequests$ = new Subject<SearchRequest>();
private readonly valuesToResolve$ = new Subject<TValue>();
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<TItem, TValue> 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<TItem, TValue> 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<string | null>(null);
@@ -102,6 +111,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly validationMessages = input<ValidationMessageMap>({});
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly labelPosition = input<FormLabelPosition>('top');
readonly itemSelected = output<TItem>();
@@ -118,6 +128,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
readonly activeItem = signal<TItem | null>(null);
readonly searchText = signal('');
readonly error = signal<string | null>(null);
readonly showingPreview = signal(false);
readonly formDisabled = signal(false);
readonly panelWidth = signal(0);
@@ -136,21 +147,24 @@ export class Autocomplete<TItem, TValue> 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<TItem, TValue> 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<SearchResult<TItem>>({ 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<SearchResult<TItem>>({ ...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<SearchResult<TItem>>({ 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<SearchResult<TItem>>({ ...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<TItem, TValue> 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<TItem, TValue> 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<TItem, TValue> 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<TItem, TValue> 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<TItem, TValue> 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 });
}
}
}
@@ -1,45 +1,49 @@
<div [class]="resolvedWrapperClass()">
@if (showLabel()) {
<label [for]="inputId()" [class]="resolvedLabelClass()" >
{{ label() }}
@if (required()) {
<span [class]="requiredClass()" aria-hidden="true" >
*
</span>
<label [for]="inputId()" [class]="resolvedLabelClass()">
{{ label() }}
@if (required()) {
<span [class]="requiredClass()" aria-hidden="true">
*
</span>
<span class="sr-only">
Required
</span>
}
</label>
<span class="sr-only">
Required
</span>
}
@if (showHelpIcon()) {
<button type="button" class="text-muted hover:text-primary" [attr.aria-label]="help()" tooltipVariant="info" [appTooltip]="resolvedHelp()"
aria-label="Field help">
<i class="ti ti-info-circle"></i>
</button>
}
</label>
}
<div [class]="resolvedContentClass()">
@if (description()) {
<p [id]="descriptionId()" [class]="descriptionClass()" >
{{ description() }}
</p>
<p [id]="descriptionId()" [class]="descriptionClass()">
{{ description() }}
</p>
}
<ng-content />
@if (showHint()) {
<p
[id]="hintId()"
[class]="hintClass()"
>
{{ hint() }}
</p>
<p [id]="hintId()" [class]="hintClass()">
{{ hint() }}
</p>
}
@if (!hideValidation()) {
<app-form-validation-message
[id]="validationId()"
[fieldName]="label()"
[control]="control()"
[messages]="validationMessages()"
[showWhenDirty]="showValidationWhenDirty()"
/>
<app-form-validation-message [id]="validationId()" [fieldName]="label()" [control]="control()"
[messages]="validationMessages()" [showWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()" />
}
</div>
</div>
</div>
@@ -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<string | null>(null);
readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly showHelpIcon = computed(() => {
return !!this.help()?.trim();
});
readonly resolvedHelp = computed(() => {
return this.help()?.trim() ?? '';
});
readonly labelPosition = input<FormLabelPosition>('top');
@@ -120,9 +130,8 @@ export class FormField {
}
return (
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showValidationWhenDirty() && control.dirty)
);
});
@@ -6,6 +6,7 @@
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[help]="help()"
[hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()"
@@ -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<string>();
readonly label = input.required<string>();
@@ -54,14 +54,15 @@ export class FormInput implements ControlValueAccessor {
readonly pattern = input<string | null>(null);
readonly min = input<number | null>(null);
readonly max = input<number | null>(null);
readonly step = input<number | string | null>(null);
readonly description = input<string | null>(null);
readonly hint = input<string | null>(null);
readonly help = input<string | null>(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<string | null>(null);
readonly ariaDescription = input<string | null>(null);
readonly value = signal<string | number | null>(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`);
@@ -367,9 +367,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> 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<TValue extends FormSelectPrimitive = string> implements
const showInvalidState = !!(
control?.invalid &&
(
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showValidationWhenDirty() && control.dirty)
)
);
@@ -71,9 +71,8 @@ export class FormValidationMessage {
}
return (
control.touched ||
control.dirty ||
this.submitAttempted()
this.submitAttempted() ||
(this.showWhenDirty() && control.dirty)
);
});
@@ -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<TooltipPosition>('top');
readonly tooltipVariant =
input<TooltipVariant>('default');
readonly tooltipDisabled = input(false);
readonly tooltipDelay = input(200);
private overlayRef: OverlayRef | null = null;
private showTimeout: ReturnType<typeof setTimeout> | null = null;
private tooltipComponentRef:
ComponentRef<Tooltip> | null = null;
private showTimeout:
ReturnType<typeof setTimeout> | 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
}
]
};
@@ -1,7 +1,7 @@
<div
<!-- <div
class="
pointer-events-none
max-w-[250px]
max-w-xs
whitespace-normal
rounded-sm
bg-primary
@@ -16,4 +16,11 @@
role="tooltip"
>
{{ text() }}
</div> -->
<div
[class]="resolvedTooltipClass()"
role="tooltip"
>
{{ text() }}
</div>
@@ -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<string>();
readonly variant =
input<TooltipVariant>('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(' ');
});
}