add tenant and user endpoints, services, models and guards
This commit is contained in:
@@ -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']);
|
||||||
|
};
|
||||||
@@ -4,6 +4,31 @@ import { ToastrService } from 'ngx-toastr';
|
|||||||
import { catchError, throwError } from 'rxjs';
|
import { catchError, throwError } from 'rxjs';
|
||||||
import { API_CONFIG } from '../config/api.config';
|
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) => {
|
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
const toastr = inject(ToastrService);
|
const toastr = inject(ToastrService);
|
||||||
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
|
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
|
||||||
@@ -13,7 +38,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
return next(req).pipe(
|
return next(req).pipe(
|
||||||
catchError((error: HttpErrorResponse) => {
|
catchError((error: HttpErrorResponse) => {
|
||||||
if (!isLoginRequest && !isRefreshRequest && error.status !== 401) {
|
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');
|
toastr.error(message, 'Request failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface CurrencyDto {
|
export interface CurrencyDto {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
iso2: string;
|
||||||
name: string;
|
name: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
numericCode: number;
|
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';
|
||||||
@@ -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';
|
||||||
@@ -24,8 +24,6 @@ export const SAAS_MENU_DATA: MenuContext = {
|
|||||||
selected: false,
|
selected: false,
|
||||||
dirchange: false,
|
dirchange: false,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
|
|
||||||
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
|
|
||||||
{
|
{
|
||||||
title: 'Global Master',
|
title: 'Global Master',
|
||||||
type: 'sub',
|
type: 'sub',
|
||||||
@@ -41,6 +39,18 @@ export const SAAS_MENU_DATA: MenuContext = {
|
|||||||
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
|
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Tenant Master',
|
||||||
|
type: 'sub',
|
||||||
|
active: false,
|
||||||
|
selected: false,
|
||||||
|
dirchange: false,
|
||||||
|
children: [
|
||||||
|
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
|
||||||
|
{ path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false }
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
|
||||||
{
|
{
|
||||||
title: 'Configuration',
|
title: 'Configuration',
|
||||||
type: 'sub',
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { Observable, of, tap } from 'rxjs';
|
import { Observable, of, tap } from 'rxjs';
|
||||||
import { MenuContext } from '../../models/context/context.model';
|
import { MenuContext } from '../../models/context/context.model';
|
||||||
import { SAAS_MENU_DATA } from './menu.data';
|
import { SAAS_MENU_DATA } from './menu.data';
|
||||||
import { Menu } from '../../../core/services/common/nav.service';
|
import { Menu } from '../../../core/services/common/nav.service';
|
||||||
|
import { TokenStorageService } from '../auth/token-storage.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MenuService {
|
export class MenuService {
|
||||||
|
private readonly tokenStorage = inject(TokenStorageService);
|
||||||
readonly menuContext = signal<MenuContext | null>(null);
|
readonly menuContext = signal<MenuContext | null>(null);
|
||||||
|
|
||||||
loadMenu(): Observable<MenuContext> {
|
loadMenu(): Observable<MenuContext> {
|
||||||
const context = this.cloneMenuContext(SAAS_MENU_DATA);
|
const context = this.cloneMenuContext(SAAS_MENU_DATA);
|
||||||
|
context.items = this.filterAuthorizedItems(context.items);
|
||||||
return of(context).pipe(
|
return of(context).pipe(
|
||||||
tap((menuContext) => {
|
tap((menuContext) => {
|
||||||
this.menuContext.set(menuContext);
|
this.menuContext.set(menuContext);
|
||||||
@@ -39,4 +42,15 @@ export class MenuService {
|
|||||||
private cloneMenuItems(items: Menu[]): Menu[] {
|
private cloneMenuItems(items: Menu[]): Menu[] {
|
||||||
return JSON.parse(JSON.stringify(items)) as 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 -->
|
<!-- Start::row-1 -->
|
||||||
<div class="grid grid-cols-12 gap-6">
|
<div class="grid grid-cols-12 gap-6">
|
||||||
<div class="xl:col-span-12 col-span-12">
|
<div class="xl:col-span-12 col-span-12">
|
||||||
<div class="box custom-box">
|
<app-filter-card title="Location Selection" bodyClass="p-4">
|
||||||
<div class="box-body p-4">
|
|
||||||
<div class="flex items-center justify-between flex-wrap gap-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">
|
<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">
|
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||||
@@ -55,8 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</app-filter-card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- End::row-1 -->
|
<!-- End::row-1 -->
|
||||||
@@ -64,7 +59,7 @@
|
|||||||
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
|
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
|
||||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||||
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
|
[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)"
|
searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)"
|
||||||
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
||||||
(actionClicked)="onActionClick($event)" toolTip="Add City" />
|
(actionClicked)="onActionClick($event)" toolTip="Add City" />
|
||||||
@@ -80,15 +75,16 @@
|
|||||||
formControlName="countryId"
|
formControlName="countryId"
|
||||||
inputId="city-country"
|
inputId="city-country"
|
||||||
label="Country"
|
label="Country"
|
||||||
placeholder="Search country"
|
placeholder="Search"
|
||||||
[searchFn]="searchCountries"
|
[searchFn]="searchCountries"
|
||||||
[displayWith]="displayCountry"
|
[displayWith]="displayCountry"
|
||||||
[valueWith]="countryValue"
|
[valueWith]="countryValue"
|
||||||
|
[resolveValueFn]="resolveCountry"
|
||||||
[selectedItem]="selectedFormCountry()"
|
[selectedItem]="selectedFormCountry()"
|
||||||
[minSearchLength]="1"
|
[minSearchLength]="1"
|
||||||
[debounceTime]="300"
|
[debounceTime]="300"
|
||||||
[limit]="50"
|
[limit]="50"
|
||||||
[clearable]="false"
|
[clearable]="true"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[readonly]="modalMode() !== 'create'"
|
[readonly]="modalMode() !== 'create'"
|
||||||
[validationMessages]="{ required: 'Country is required.' }"
|
[validationMessages]="{ required: 'Country is required.' }"
|
||||||
@@ -105,14 +101,16 @@
|
|||||||
inputId="city-state"
|
inputId="city-state"
|
||||||
label="State"
|
label="State"
|
||||||
[placeholder]="formStatePlaceholder()"
|
[placeholder]="formStatePlaceholder()"
|
||||||
|
[help]="'Select a country first'"
|
||||||
[searchFn]="searchFormStates"
|
[searchFn]="searchFormStates"
|
||||||
[displayWith]="displayState"
|
[displayWith]="displayState"
|
||||||
[valueWith]="stateValue"
|
[valueWith]="stateValue"
|
||||||
|
[resolveValueFn]="resolveState"
|
||||||
[selectedItem]="selectedFormState()"
|
[selectedItem]="selectedFormState()"
|
||||||
[minSearchLength]="1"
|
[minSearchLength]="1"
|
||||||
[debounceTime]="300"
|
[debounceTime]="300"
|
||||||
[limit]="50"
|
[limit]="50"
|
||||||
[clearable]="false"
|
[clearable]="true"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[disabled]="!cityForm.controls.countryId.value"
|
[disabled]="!cityForm.controls.countryId.value"
|
||||||
[readonly]="modalMode() !== 'create'"
|
[readonly]="modalMode() !== 'create'"
|
||||||
@@ -126,7 +124,7 @@
|
|||||||
|
|
||||||
<div class="col-span-12 md:col-span-6">
|
<div class="col-span-12 md:col-span-6">
|
||||||
<app-form-input formControlName="name" inputId="city-name" label="City Name"
|
<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]="{
|
[maxLength]="150" [validationMessages]="{
|
||||||
required: 'City Name is required.',
|
required: 'City Name is required.',
|
||||||
maxlength: 'City Name cannot exceed 150 characters.'
|
maxlength: 'City Name cannot exceed 150 characters.'
|
||||||
@@ -135,7 +133,7 @@
|
|||||||
|
|
||||||
<div class="col-span-12 md:col-span-6">
|
<div class="col-span-12 md:col-span-6">
|
||||||
<app-form-input formControlName="code" inputId="city-code" label="City Code"
|
<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]="{
|
[maxLength]="16" [validationMessages]="{
|
||||||
required: 'City Code is required.',
|
required: 'City Code is required.',
|
||||||
maxlength: 'City Code cannot exceed 16 characters.',
|
maxlength: 'City Code cannot exceed 16 characters.',
|
||||||
@@ -148,7 +146,7 @@
|
|||||||
formControlName="timezoneId"
|
formControlName="timezoneId"
|
||||||
inputId="city-timezone"
|
inputId="city-timezone"
|
||||||
label="Timezone"
|
label="Timezone"
|
||||||
placeholder="Search timezone"
|
placeholder="Search"
|
||||||
[searchFn]="searchTimezones"
|
[searchFn]="searchTimezones"
|
||||||
[displayWith]="displayTimezone"
|
[displayWith]="displayTimezone"
|
||||||
[valueWith]="timezoneValue"
|
[valueWith]="timezoneValue"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
AutocompleteValueFn
|
AutocompleteValueFn
|
||||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||||
|
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||||
|
|
||||||
interface CityTableRow extends DataTableRecord {
|
interface CityTableRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,7 +66,7 @@ interface CityTableRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'city-list',
|
selector: 'city-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete],
|
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard],
|
||||||
templateUrl: './city-list.html',
|
templateUrl: './city-list.html',
|
||||||
styleUrl: './city-list.scss'
|
styleUrl: './city-list.scss'
|
||||||
})
|
})
|
||||||
@@ -148,8 +149,16 @@ export class CityList {
|
|||||||
};
|
};
|
||||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||||
|
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||||
|
value => this.countryApi.getCountryById(value).pipe(
|
||||||
|
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||||
|
);
|
||||||
readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
|
readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
|
||||||
readonly stateValue: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
|
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> =
|
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> =
|
||||||
timezone => `${timezone.ianaId} — ${timezone.displayName}`;
|
timezone => `${timezone.ianaId} — ${timezone.displayName}`;
|
||||||
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> =
|
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> =
|
||||||
@@ -157,12 +166,11 @@ export class CityList {
|
|||||||
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> =
|
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> =
|
||||||
value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone)));
|
value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone)));
|
||||||
readonly filterStatePlaceholder = computed(() =>
|
readonly filterStatePlaceholder = computed(() =>
|
||||||
this.selectedCountryId() ? 'Search state' : 'Select a country first'
|
this.selectedCountryId() ? 'Search' : 'Select a country first'
|
||||||
);
|
);
|
||||||
readonly formStatePlaceholder = computed(() =>
|
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(() =>
|
readonly emptyMessage = computed(() =>
|
||||||
this.selectedCountryId() && this.selectedStateId()
|
this.selectedCountryId() && this.selectedStateId()
|
||||||
? 'No cities found'
|
? 'No cities found'
|
||||||
@@ -179,10 +187,10 @@ export class CityList {
|
|||||||
return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City';
|
return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City';
|
||||||
});
|
});
|
||||||
readonly submitLabel = computed(() =>
|
readonly submitLabel = computed(() =>
|
||||||
this.modalMode() === 'create' ? 'Save City' : 'Update City'
|
this.modalMode() === 'create' ? 'Save' : 'Update'
|
||||||
);
|
);
|
||||||
readonly loadingLabel = computed(() =>
|
readonly loadingLabel = computed(() =>
|
||||||
this.modalMode() === 'create' ? 'Saving City...' : 'Updating City...'
|
this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly columns = signal<DataTableColumn<CityTableRow>[]>([
|
readonly columns = signal<DataTableColumn<CityTableRow>[]>([
|
||||||
@@ -288,11 +296,7 @@ export class CityList {
|
|||||||
const city = this.toCityDto(event.row);
|
const city = this.toCityDto(event.row);
|
||||||
switch (event.action.type) {
|
switch (event.action.type) {
|
||||||
case 'edit':
|
case 'edit':
|
||||||
this.openExistingCity(city, 'edit', {
|
this.openExistingCity(city, 'edit');
|
||||||
id: event.row.stateId,
|
|
||||||
name: event.row.stateName,
|
|
||||||
code: ''
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case 'activate':
|
case 'activate':
|
||||||
this.updateCityStatus(city, true);
|
this.updateCityStatus(city, true);
|
||||||
@@ -304,21 +308,14 @@ export class CityList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onAddCity(): void {
|
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.modalMode.set('create');
|
||||||
this.selectedCity.set(null);
|
this.selectedCity.set(null);
|
||||||
this.submitAttempted.set(false);
|
this.submitAttempted.set(false);
|
||||||
this.selectedFormCountry.set(this.selectedCountry());
|
this.selectedFormCountry.set(null);
|
||||||
this.selectedFormState.set(this.selectedFilterState());
|
this.selectedFormState.set(null);
|
||||||
this.cityForm.enable({ emitEvent: false });
|
this.cityForm.enable({ emitEvent: false });
|
||||||
this.cityForm.reset({ countryId, stateId, name: '', code: '', timezoneId: null }, { emitEvent: false });
|
this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { emitEvent: false });
|
||||||
this.cityForm.controls.stateId.enable({ emitEvent: false });
|
this.cityForm.controls.stateId.disable({ emitEvent: false });
|
||||||
this.resetFormState();
|
this.resetFormState();
|
||||||
this.showCityModal.set(true);
|
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(
|
this.cityApi.getCityById(city.id).pipe(
|
||||||
|
switchMap(details => this.stateApi.getStateById(details.stateId).pipe(
|
||||||
|
map(stateDetails => ({ details, countryId: stateDetails.countryId }))
|
||||||
|
)),
|
||||||
take(1)
|
take(1)
|
||||||
).subscribe(details => {
|
).subscribe(({ details, countryId }) => {
|
||||||
this.selectedCity.set(details);
|
this.selectedCity.set(details);
|
||||||
this.modalMode.set(mode);
|
this.modalMode.set(mode);
|
||||||
this.submitAttempted.set(false);
|
this.submitAttempted.set(false);
|
||||||
this.selectedFormCountry.set(this.selectedCountry());
|
this.selectedFormCountry.set(null);
|
||||||
this.selectedFormState.set(
|
this.selectedFormState.set(null);
|
||||||
stateSeed
|
|
||||||
?? (this.selectedFilterState()?.id === details.stateId ? this.selectedFilterState() : null)
|
|
||||||
);
|
|
||||||
this.cityForm.enable({ emitEvent: false });
|
this.cityForm.enable({ emitEvent: false });
|
||||||
this.cityForm.reset({
|
this.cityForm.reset({
|
||||||
countryId: this.selectedCountryId() ?? '',
|
countryId,
|
||||||
stateId: details.stateId,
|
stateId: details.stateId,
|
||||||
name: details.name ?? '',
|
name: details.name ?? '',
|
||||||
code: details.code ?? '',
|
code: details.code ?? '',
|
||||||
|
|||||||
@@ -135,14 +135,14 @@ export class CountryList {
|
|||||||
|
|
||||||
readonly countrySubmitLabel = computed(() =>
|
readonly countrySubmitLabel = computed(() =>
|
||||||
this.countryModalMode() === 'create'
|
this.countryModalMode() === 'create'
|
||||||
? 'Save Country'
|
? 'Save'
|
||||||
: 'Update Country'
|
: 'Update'
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly countryLoadingLabel = computed(() =>
|
readonly countryLoadingLabel = computed(() =>
|
||||||
this.countryModalMode() === 'create'
|
this.countryModalMode() === 'create'
|
||||||
? 'Saving Country...'
|
? 'Saving...'
|
||||||
: 'Updating Country...'
|
: 'Updating...'
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly countrySubmitAction = computed<'save' | 'update'>(() =>
|
readonly countrySubmitAction = computed<'save' | 'update'>(() =>
|
||||||
@@ -157,6 +157,7 @@ export class CountryList {
|
|||||||
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
|
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
|
||||||
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
|
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
|
||||||
{ key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', 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',
|
key: 'isActive',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
|
|||||||
@@ -20,18 +20,17 @@
|
|||||||
(actionClicked)="onActionClick($event)"
|
(actionClicked)="onActionClick($event)"
|
||||||
>
|
>
|
||||||
<ng-template appDataTableCell="name" let-row let-value="value">
|
<ng-template appDataTableCell="name" let-row let-value="value">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
@if (getFlagUrl(row.iso2); as flagUrl) {
|
||||||
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"
|
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
|
||||||
>
|
(error)="onFlagError($event)" />
|
||||||
{{ row.symbol }}
|
}
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="font-semibold">
|
<span class="font-semibold">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template appDataTableCell="code" let-value="value">
|
<ng-template appDataTableCell="code" let-value="value">
|
||||||
<span class="badge bg-primary/10 text-primary">
|
<span class="badge bg-primary/10 text-primary">
|
||||||
@@ -75,6 +74,7 @@
|
|||||||
required: 'Currency Name is required.',
|
required: 'Currency Name is required.',
|
||||||
maxlength: 'Currency Name cannot exceed 100 characters.'
|
maxlength: 'Currency Name cannot exceed 100 characters.'
|
||||||
}"
|
}"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,7 +84,6 @@
|
|||||||
inputId="currency-code"
|
inputId="currency-code"
|
||||||
label="Currency Code"
|
label="Currency Code"
|
||||||
placeholder="e.g.: AED"
|
placeholder="e.g.: AED"
|
||||||
inputClass="uppercase"
|
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[minLength]="3"
|
[minLength]="3"
|
||||||
@@ -95,6 +94,7 @@
|
|||||||
maxlength: 'Currency Code must contain exactly 3 letters.',
|
maxlength: 'Currency Code must contain exactly 3 letters.',
|
||||||
pattern: 'Currency Code can contain letters only.'
|
pattern: 'Currency Code can contain letters only.'
|
||||||
}"
|
}"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
required: 'Currency Symbol is required.',
|
required: 'Currency Symbol is required.',
|
||||||
maxlength: 'Currency Symbol cannot exceed 8 characters.'
|
maxlength: 'Currency Symbol cannot exceed 8 characters.'
|
||||||
}"
|
}"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="e.g.: 784"
|
placeholder="e.g.: 784"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
[required]="true"
|
[required]="false"
|
||||||
[min]="1"
|
[min]="1"
|
||||||
[max]="999"
|
[max]="999"
|
||||||
[step]="1"
|
[step]="1"
|
||||||
@@ -132,6 +133,7 @@
|
|||||||
min: 'Numeric Code must be at least 1.',
|
min: 'Numeric Code must be at least 1.',
|
||||||
max: 'Numeric Code cannot exceed 999.'
|
max: 'Numeric Code cannot exceed 999.'
|
||||||
}"
|
}"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@
|
|||||||
min: 'Decimal Digits cannot be less than 0.',
|
min: 'Decimal Digits cannot be less than 0.',
|
||||||
max: 'Decimal Digits cannot exceed 4.'
|
max: 'Decimal Digits cannot exceed 4.'
|
||||||
}"
|
}"
|
||||||
|
[submitAttempted]="currencySubmitAttempted()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/c
|
|||||||
interface CurrencyTableRow extends DataTableRecord {
|
interface CurrencyTableRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
iso2: string;
|
||||||
name: string;
|
name: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
numericCode: number;
|
numericCode: number;
|
||||||
@@ -124,14 +125,14 @@ export class CurrencyList {
|
|||||||
|
|
||||||
readonly currencySubmitLabel = computed(() =>
|
readonly currencySubmitLabel = computed(() =>
|
||||||
this.currencyModalMode() === 'create'
|
this.currencyModalMode() === 'create'
|
||||||
? 'Save Currency'
|
? 'Save'
|
||||||
: 'Update Currency'
|
: 'Update'
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly currencyLoadingLabel = computed(() =>
|
readonly currencyLoadingLabel = computed(() =>
|
||||||
this.currencyModalMode() === 'create'
|
this.currencyModalMode() === 'create'
|
||||||
? 'Saving Currency...'
|
? 'Saving...'
|
||||||
: 'Updating Currency...'
|
: 'Updating...'
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly currencySubmitAction = computed<'save' | 'update'>(() =>
|
readonly currencySubmitAction = computed<'save' | 'update'>(() =>
|
||||||
@@ -143,6 +144,7 @@ export class CurrencyList {
|
|||||||
readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([
|
readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
||||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
|
{ 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: 'code', label: 'Code', header: 'Code', sortable: true },
|
||||||
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true },
|
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true },
|
||||||
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true },
|
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true },
|
||||||
@@ -495,6 +497,7 @@ export class CurrencyList {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
|
iso2: row.iso2?.toLowerCase() ?? '',
|
||||||
name: row.name,
|
name: row.name,
|
||||||
symbol: row.symbol,
|
symbol: row.symbol,
|
||||||
numericCode: row.numericCode,
|
numericCode: row.numericCode,
|
||||||
@@ -504,4 +507,16 @@ export class CurrencyList {
|
|||||||
modifiedOn: row.modifiedOn
|
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'
|
this.modalMode() === 'create' ? 'Add Language' : 'Edit Language'
|
||||||
);
|
);
|
||||||
readonly submitLabel = computed(() =>
|
readonly submitLabel = computed(() =>
|
||||||
this.modalMode() === 'create' ? 'Save Language' : 'Update Language'
|
this.modalMode() === 'create' ? 'Save' : 'Update'
|
||||||
);
|
);
|
||||||
readonly loadingLabel = computed(() =>
|
readonly loadingLabel = computed(() =>
|
||||||
this.modalMode() === 'create' ? 'Saving Language...' : 'Updating Language...'
|
this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
|
||||||
);
|
);
|
||||||
readonly submitAction = computed<'save' | 'update'>(() =>
|
readonly submitAction = computed<'save' | 'update'>(() =>
|
||||||
this.modalMode() === 'create' ? 'save' : 'update'
|
this.modalMode() === 'create' ? 'save' : 'update'
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
<!-- Start::row-1 -->
|
|
||||||
<div class="grid grid-cols-12 gap-6">
|
<div class="grid grid-cols-12 gap-6">
|
||||||
<div class="xl:col-span-12 col-span-12">
|
<div class="xl:col-span-12 col-span-12">
|
||||||
<div class="box custom-box">
|
<app-filter-card title="Country Selection" bodyClass="p-4">
|
||||||
<div class="box-body p-4">
|
|
||||||
<div class="flex items-center justify-between flex-wrap gap-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">
|
<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">
|
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||||
<app-autocomplete
|
<app-autocomplete
|
||||||
formControlName="countryId"
|
formControlName="countryId"
|
||||||
inputId="state-country-filter"
|
inputId="state-country-filter"
|
||||||
label="Country"
|
label="Country"
|
||||||
placeholder="Search country"
|
placeholder="Search"
|
||||||
[searchFn]="searchCountries"
|
[searchFn]="searchCountries"
|
||||||
[displayWith]="displayCountry"
|
[displayWith]="displayCountry"
|
||||||
[valueWith]="countryValue"
|
[valueWith]="countryValue"
|
||||||
@@ -31,16 +25,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</app-filter-card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- End::row-1 -->
|
|
||||||
|
|
||||||
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
|
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
|
||||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||||
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="States" buttonTitle="Add" [showSearch]="true"
|
[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()"
|
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||||
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
||||||
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
|
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
|
||||||
@@ -59,6 +51,32 @@
|
|||||||
(closed)="closeStateModal()" (submitted)="saveState()">
|
(closed)="closeStateModal()" (submitted)="saveState()">
|
||||||
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
|
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
|
||||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
<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">
|
<div class="col-span-12 md:col-span-6">
|
||||||
<app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name"
|
<app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name"
|
||||||
autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
|
autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Validators
|
Validators
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
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 {
|
import {
|
||||||
CountryLookupDto
|
CountryLookupDto
|
||||||
@@ -33,12 +33,14 @@ import { DataTable } from '../../../../../shared/components/data-table/data-tabl
|
|||||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
import {
|
import {
|
||||||
AutocompleteDisplayFn,
|
AutocompleteDisplayFn,
|
||||||
|
AutocompleteResolveValueFn,
|
||||||
AutocompleteSearchFn,
|
AutocompleteSearchFn,
|
||||||
AutocompleteValueFn
|
AutocompleteValueFn
|
||||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||||
|
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||||
|
|
||||||
interface StateTableRow extends DataTableRecord {
|
interface StateTableRow extends DataTableRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -54,12 +56,12 @@ interface StateTableRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'state-list',
|
selector: 'state-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog],
|
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard],
|
||||||
templateUrl: './state-list.html',
|
templateUrl: './state-list.html',
|
||||||
styleUrl: './state-list.scss',
|
styleUrl: './state-list.scss',
|
||||||
})
|
})
|
||||||
export class StateList {
|
export class StateList {
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly stateApi = inject(StateService);
|
private readonly stateApi = inject(StateService);
|
||||||
private readonly countryApi = inject(CountryService);
|
private readonly countryApi = inject(CountryService);
|
||||||
@@ -73,6 +75,7 @@ export class StateList {
|
|||||||
|
|
||||||
readonly states = signal<StateTableRow[]>([]);
|
readonly states = signal<StateTableRow[]>([]);
|
||||||
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
||||||
|
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||||
readonly selectedCountryId = signal<string | null>(null);
|
readonly selectedCountryId = signal<string | null>(null);
|
||||||
readonly totalRecords = signal(0);
|
readonly totalRecords = signal(0);
|
||||||
readonly filteredRecords = signal(0);
|
readonly filteredRecords = signal(0);
|
||||||
@@ -94,6 +97,10 @@ export class StateList {
|
|||||||
(term, limit) => this.countryApi.autocomplete(term, limit);
|
(term, limit) => this.countryApi.autocomplete(term, limit);
|
||||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||||
|
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||||
|
value => this.countryApi.getCountryById(value).pipe(
|
||||||
|
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||||
|
);
|
||||||
|
|
||||||
readonly stateForm = this.formBuilder.nonNullable.group({
|
readonly stateForm = this.formBuilder.nonNullable.group({
|
||||||
countryId: [
|
countryId: [
|
||||||
@@ -119,10 +126,6 @@ export class StateList {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly showStateAddButton = computed(() =>
|
|
||||||
!!this.selectedCountryId()
|
|
||||||
);
|
|
||||||
|
|
||||||
readonly emptyMessage = computed(() =>
|
readonly emptyMessage = computed(() =>
|
||||||
this.selectedCountryId()
|
this.selectedCountryId()
|
||||||
? 'No states found'
|
? 'No states found'
|
||||||
@@ -143,14 +146,14 @@ export class StateList {
|
|||||||
|
|
||||||
readonly stateSubmitLabel = computed(() =>
|
readonly stateSubmitLabel = computed(() =>
|
||||||
this.stateModalMode() === 'create'
|
this.stateModalMode() === 'create'
|
||||||
? 'Save State'
|
? 'Save'
|
||||||
: 'Update State'
|
: 'Update'
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly stateLoadingLabel = computed(() =>
|
readonly stateLoadingLabel = computed(() =>
|
||||||
this.stateModalMode() === 'create'
|
this.stateModalMode() === 'create'
|
||||||
? 'Saving State...'
|
? 'Saving...'
|
||||||
: 'Updating State...'
|
: 'Updating...'
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly stateSubmitAction = computed<'save' | 'update'>(() =>
|
readonly stateSubmitAction = computed<'save' | 'update'>(() =>
|
||||||
@@ -161,7 +164,7 @@ export class StateList {
|
|||||||
|
|
||||||
readonly columns = signal<DataTableColumn<StateTableRow>[]>([
|
readonly columns = signal<DataTableColumn<StateTableRow>[]>([
|
||||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' },
|
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '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: 'code', header: 'Code', label: 'Code', sortable: true },
|
||||||
{
|
{
|
||||||
key: 'isActive',
|
key: 'isActive',
|
||||||
@@ -269,6 +272,14 @@ export class StateList {
|
|||||||
this.selectedCountryLookup.set(country);
|
this.selectedCountryLookup.set(country);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFormCountrySelected(country: CountryLookupDto): void {
|
||||||
|
this.selectedFormCountry.set(country);
|
||||||
|
}
|
||||||
|
|
||||||
|
onFormCountryCleared(): void {
|
||||||
|
this.selectedFormCountry.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
onSearch(value: string): void {
|
onSearch(value: string): void {
|
||||||
const query = this.queryState.setSearch(value.trim());
|
const query = this.queryState.setSearch(value.trim());
|
||||||
this.loadStates(query);
|
this.loadStates(query);
|
||||||
@@ -336,20 +347,14 @@ export class StateList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onAddState(): void {
|
onAddState(): void {
|
||||||
const countryId = this.selectedCountryId();
|
|
||||||
|
|
||||||
if (!countryId) {
|
|
||||||
this.toastr.error('Select a country before adding a state.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stateModalMode.set('create');
|
this.stateModalMode.set('create');
|
||||||
this.selectedStateId.set(null);
|
this.selectedStateId.set(null);
|
||||||
this.selectedState.set(null);
|
this.selectedState.set(null);
|
||||||
this.stateSubmitAttempted.set(false);
|
this.stateSubmitAttempted.set(false);
|
||||||
|
|
||||||
|
this.selectedFormCountry.set(null);
|
||||||
this.stateForm.reset({
|
this.stateForm.reset({
|
||||||
countryId,
|
countryId: '',
|
||||||
name: '',
|
name: '',
|
||||||
code: ''
|
code: ''
|
||||||
});
|
});
|
||||||
@@ -366,6 +371,7 @@ export class StateList {
|
|||||||
this.showStateModal.set(false);
|
this.showStateModal.set(false);
|
||||||
this.selectedStateId.set(null);
|
this.selectedStateId.set(null);
|
||||||
this.selectedState.set(null);
|
this.selectedState.set(null);
|
||||||
|
this.selectedFormCountry.set(null);
|
||||||
this.stateSubmitAttempted.set(false);
|
this.stateSubmitAttempted.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,8 +492,9 @@ export class StateList {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: stateDetails => {
|
next: stateDetails => {
|
||||||
this.selectedState.set(stateDetails);
|
this.selectedState.set(stateDetails);
|
||||||
|
this.selectedFormCountry.set(null);
|
||||||
this.stateForm.reset({
|
this.stateForm.reset({
|
||||||
countryId: stateDetails.countryId ?? this.selectedCountryId() ?? '',
|
countryId: stateDetails.countryId ?? '',
|
||||||
name: stateDetails.name ?? '',
|
name: stateDetails.name ?? '',
|
||||||
code: stateDetails.code ?? ''
|
code: stateDetails.code ?? ''
|
||||||
});
|
});
|
||||||
@@ -566,6 +573,7 @@ export class StateList {
|
|||||||
this.showStateModal.set(false);
|
this.showStateModal.set(false);
|
||||||
this.selectedStateId.set(null);
|
this.selectedStateId.set(null);
|
||||||
this.selectedState.set(null);
|
this.selectedState.set(null);
|
||||||
|
this.selectedFormCountry.set(null);
|
||||||
this.stateSubmitAttempted.set(false);
|
this.stateSubmitAttempted.set(false);
|
||||||
this.loadStates(this.queryState.getQuery());
|
this.loadStates(this.queryState.getQuery());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
formControlName="ianaId"
|
formControlName="ianaId"
|
||||||
inputId="timezone-iana-id"
|
inputId="timezone-iana-id"
|
||||||
label="IANA Timezone ID"
|
label="IANA Timezone ID"
|
||||||
placeholder="e.g.: Asia/Kolkata"
|
placeholder="Id"
|
||||||
|
help="e.g.: Asia/Kolkata"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[readonly]="isViewMode()"
|
[readonly]="isViewMode()"
|
||||||
@@ -80,7 +81,8 @@
|
|||||||
formControlName="displayName"
|
formControlName="displayName"
|
||||||
inputId="timezone-display-name"
|
inputId="timezone-display-name"
|
||||||
label="Display Name"
|
label="Display Name"
|
||||||
placeholder="e.g.: India Standard Time"
|
placeholder="Name"
|
||||||
|
help="e.g.: India Standard Time"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[readonly]="isViewMode()"
|
[readonly]="isViewMode()"
|
||||||
@@ -100,13 +102,13 @@
|
|||||||
label="UTC Offset (minutes)"
|
label="UTC Offset (minutes)"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="For example: 330"
|
placeholder="Minutes"
|
||||||
|
help="Enter an offset from -720 (-12:00) to 840 (+14:00)."
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[readonly]="isViewMode()"
|
[readonly]="isViewMode()"
|
||||||
[min]="-720"
|
[min]="-720"
|
||||||
[max]="840"
|
[max]="840"
|
||||||
[step]="1"
|
[step]="1"
|
||||||
hint="e.g.: Enter an offset from -720 (-12:00) to 840 (+14:00)."
|
|
||||||
[submitAttempted]="submitAttempted()"
|
[submitAttempted]="submitAttempted()"
|
||||||
[validationMessages]="{
|
[validationMessages]="{
|
||||||
required: 'UTC offset is required.',
|
required: 'UTC offset is required.',
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ export class TimezoneList implements OnInit {
|
|||||||
case 'view': return 'View Timezone';
|
case 'view': return 'View Timezone';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save Timezone' : 'Update Timezone');
|
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save' : 'Update');
|
||||||
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving Timezone...' : 'Updating Timezone...');
|
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving...' : 'Updating...');
|
||||||
readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update');
|
readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update');
|
||||||
|
|
||||||
readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([
|
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
|
<app-data-table
|
||||||
title="Tenant List"
|
[columns]="columns()"
|
||||||
|
[rows]="tenants()"
|
||||||
[columns]="columns"
|
[actions]="actions()"
|
||||||
[rows]="tenants"
|
[totalRecords]="totalRecords()"
|
||||||
[actions]="tableActions"
|
[pageIndex]="queryState.pageIndex()"
|
||||||
|
[pageSize]="queryState.pageSize()"
|
||||||
[loading]="loading"
|
[pageSizeOptions]="[5, 10, 20, 50]"
|
||||||
emptyMessage="No tenants found"
|
tableTitle="Tenant Management"
|
||||||
|
buttonTitle="Add Tenant"
|
||||||
[showSearch]="true"
|
[showSearch]="true"
|
||||||
|
[showAddButton]="true"
|
||||||
searchPlaceholder="Search tenants..."
|
searchPlaceholder="Search tenants..."
|
||||||
[searchDebounceTime]="300"
|
[searchDebounceTime]="300"
|
||||||
|
actionHeaderClass="!text-center"
|
||||||
[totalRecords]="totalRecords"
|
actionCellClass="!text-center"
|
||||||
[pageIndex]="pageIndex"
|
emptyMessage="No tenants found"
|
||||||
[pageSize]="pageSize"
|
emptyDescription="There is currently no data to display."
|
||||||
[pageSizeOptions]="[5, 10, 20, 50]"
|
(addClicked)="onAddTenant()"
|
||||||
[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"
|
|
||||||
|
|
||||||
(searchChanged)="onSearch($event)"
|
(searchChanged)="onSearch($event)"
|
||||||
(pageChanged)="onPageChange($event)"
|
(pageChanged)="onPageChange($event)"
|
||||||
(sortChanged)="onSortChange($event)"
|
(sortChanged)="onSortChange($event)"
|
||||||
(actionClicked)="onTableAction($event)"
|
(actionClicked)="onActionClick($event)"
|
||||||
(rowClicked)="onRowClick($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="col-span-12 md:col-span-6">
|
||||||
<div class="flex items-center">
|
<app-form-input
|
||||||
<span class="avatar avatar-xs me-2 avatar-rounded">
|
formControlName="name"
|
||||||
<img [src]="row.logo" [alt]="value">
|
inputId="tenant-name"
|
||||||
</span>
|
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">
|
<div class="col-span-12 md:col-span-6">
|
||||||
{{ value }}
|
<app-form-select
|
||||||
</span>
|
formControlName="status"
|
||||||
</div>
|
inputId="tenant-status"
|
||||||
</ng-template>
|
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
|
<div class="col-span-12 md:col-span-6">
|
||||||
title="Delete Tenant"
|
<app-autocomplete
|
||||||
text="Do you really want to delete this tenant?"
|
formControlName="defaultCurrencyId"
|
||||||
confirmButtonText="Delete"
|
inputId="tenant-default-currency-id"
|
||||||
cancelButtonText="Cancel"
|
label="Default Currency"
|
||||||
(confirmed)="onDeleteConfirmed()"
|
placeholder="Search"
|
||||||
(cancelled)="onDeleteCancelled()"
|
[searchFn]="searchCurrencies"
|
||||||
></app-confirm-dialog>
|
[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 { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { CommonModule } from '@angular/common';
|
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 { 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 { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state';
|
||||||
import { DataTablePageEvent, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
|
import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableRecord, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
|
||||||
import { DataTableCellDirective } from '../../../../shared/directives/data-table-cell.directive';
|
import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete';
|
||||||
import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog';
|
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({
|
@Component({
|
||||||
selector: 'tenant-list',
|
selector: 'tenant-list',
|
||||||
imports: [CommonModule, DataTable, DataTableCellDirective, ConfirmDialog],
|
standalone: true,
|
||||||
templateUrl: './tenant-list.html',
|
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete],
|
||||||
styleUrl: './tenant-list.scss',
|
templateUrl: './tenant-list.html',
|
||||||
|
styleUrl: './tenant-list.scss'
|
||||||
})
|
})
|
||||||
export class TenantList {
|
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;
|
readonly tenantForm = this.formBuilder.group({
|
||||||
pageIndex = 1;
|
code: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(50)]),
|
||||||
pageSize = 10;
|
name: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]),
|
||||||
totalRecords = 3;
|
status: this.formBuilder.control<TenantStatus | null>(TenantStatus.Trial, [Validators.required]),
|
||||||
searchText = '';
|
defaultLanguageId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||||
readonly pendingDeleteTenant = signal<{ id: number } | null>(null);
|
defaultCurrencyId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
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[] = [
|
readonly tenantStatusOptions = signal<FormSelectOption<TenantStatus>[]>([
|
||||||
'tenant.view',
|
{ value: TenantStatus.Trial, label: 'Trial' },
|
||||||
'tenant.edit',
|
{ value: TenantStatus.Active, label: 'Active' },
|
||||||
'tenant.delete'
|
{ value: TenantStatus.Suspended, label: 'Suspended' },
|
||||||
];
|
{ value: TenantStatus.Cancelled, label: 'Cancelled' }
|
||||||
|
]);
|
||||||
|
|
||||||
columns: DataTableColumn[] = [
|
readonly activeStatusOptions = signal<FormSelectOption<number>[]>([
|
||||||
{ key: 'id', header: 'Id', label: 'Id', sortable: true, headerClass: 'text-center', cellClass: 'text-center' },
|
{ value: 1, label: 'Active' },
|
||||||
{ key: 'tenantName', header: 'Tenant Name', label: 'Tenant Name', sortable: true },
|
{ value: 0, label: 'Inactive' }
|
||||||
{ 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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
tenants = [
|
readonly searchLanguages: AutocompleteSearchFn<LanguageLookupDto> = (term, limit) =>
|
||||||
{
|
this.languageApi.autocomplete(term, limit).pipe(
|
||||||
id: 1,
|
catchError(() => {
|
||||||
tenantName: 'Syscom Corporation',
|
this.toastr.error('Unable to load languages.');
|
||||||
companyCode: 'SYSCOM',
|
return of<readonly LanguageLookupDto[]>([]);
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
tenants1 = [
|
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
|
||||||
{
|
this.currencyApi.autocomplete(term, limit).pipe(
|
||||||
id: 1,
|
catchError(() => {
|
||||||
tenantName: 'Syscom Corporation',
|
this.toastr.error('Unable to load currencies.');
|
||||||
companyCode: 'SYSCOM',
|
return of<readonly CurrencyLookupDto[]>([]);
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
tableActions: DataTableAction[] = [
|
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> = (term, limit) =>
|
||||||
{
|
this.timezoneApi.autocomplete(term, limit).pipe(
|
||||||
type: 'download',
|
catchError(() => {
|
||||||
label: 'Download',
|
this.toastr.error('Unable to load timezones.');
|
||||||
icon: 'ri-download-2-line !mb-0',
|
return of<readonly TimezoneLookupDto[]>([]);
|
||||||
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 displayLanguage: AutocompleteDisplayFn<LanguageLookupDto> = language => [language.code, language.name].filter(Boolean).join(' - ');
|
||||||
|
|
||||||
onSearch(searchText: string): void {
|
readonly languageValue: AutocompleteValueFn<LanguageLookupDto, string> = language => language.id;
|
||||||
this.searchText = searchText;
|
|
||||||
this.pageIndex = 1;
|
|
||||||
|
|
||||||
if (searchText.trim() === '') {
|
readonly resolveLanguage: AutocompleteResolveValueFn<LanguageLookupDto, string> = value =>
|
||||||
this.tenants = [...this.tenants1];
|
this.languageApi.getById(value).pipe(
|
||||||
} else {
|
map(language => ({
|
||||||
this.tenants = this.tenants.filter(tenant =>
|
id: language.id,
|
||||||
tenant.tenantName.toLowerCase().includes(searchText.toLowerCase()) ||
|
code: language.code,
|
||||||
tenant.companyCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
name: language.name,
|
||||||
tenant.email.toLowerCase().includes(searchText.toLowerCase()) ||
|
nativeName: language.nativeName,
|
||||||
tenant.country.toLowerCase().includes(searchText.toLowerCase()) ||
|
isRightToLeft: language.isRightToLeft
|
||||||
tenant.status.toLowerCase().includes(searchText.toLowerCase())
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
ngOnInit(): void {
|
||||||
|
this.loadTenants(this.queryState.getQuery());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Action:', event.action.type, event.row);
|
loadTenants(query: DataTableQuery): void {
|
||||||
}
|
this.queryRequests$.next(query);
|
||||||
|
|
||||||
onDeleteConfirmed(): void {
|
|
||||||
const tenant = this.pendingDeleteTenant();
|
|
||||||
|
|
||||||
if (!tenant) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingDeleteTenant.set(null);
|
onSearch(value: string): void {
|
||||||
this.tenants = this.tenants.filter(currentTenant => currentTenant.id !== tenant.id);
|
this.loadTenants(this.queryState.setSearch(value.trim()));
|
||||||
this.tenants1 = this.tenants1.filter(currentTenant => currentTenant.id !== tenant.id);
|
}
|
||||||
this.totalRecords = this.tenants.length;
|
|
||||||
console.log('Deleted tenant:', tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeleteCancelled(): void {
|
onPageChange(event: DataTablePageEvent): void {
|
||||||
this.pendingDeleteTenant.set(null);
|
this.loadTenants(this.queryState.setPage(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
onRowClick(row: any): void {
|
onSortChange(event: DataTableSortEvent): void {
|
||||||
console.log('Row clicked:', row);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,11 @@ export const tenantsRoutes: Routes = [
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadComponent: () => import('./pages/tenant-list/tenant-list').then((m) => m.TenantList),
|
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">
|
<app-data-table [columns]="columns()" [rows]="users()" [actions]="actions()"
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform users</h3>
|
(addClicked)="onAddUser()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
|
||||||
<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>
|
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Users" buttonTitle="Add"
|
||||||
</div>
|
[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 {
|
||||||
import { CommonModule } from '@angular/common';
|
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({
|
@Component({
|
||||||
selector: 'app-users-list',
|
selector: 'app-users-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [
|
||||||
|
ReactiveFormsModule,
|
||||||
|
RouterLink,
|
||||||
|
DataTable,
|
||||||
|
Modal
|
||||||
|
],
|
||||||
templateUrl: './users-list.html',
|
templateUrl: './users-list.html',
|
||||||
styleUrl: './users-list.scss',
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { superAdminGuard } from '../../core/guards/auth/super-admin.guard';
|
||||||
|
|
||||||
export const usersRoutes: Routes = [
|
export const usersRoutes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
canActivate: [superAdminGuard],
|
||||||
loadComponent: () => import('./pages/users-list/users-list').then((m) => m.UsersList),
|
loadComponent: () => import('./pages/users-list/users-list').then((m) => m.UsersList),
|
||||||
data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' },
|
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()"
|
[disabled]="isDisabled()"
|
||||||
[description]="description()"
|
[description]="description()"
|
||||||
[hint]="hint()"
|
[hint]="hint()"
|
||||||
|
[help]="help()"
|
||||||
[labelPosition]="labelPosition()"
|
[labelPosition]="labelPosition()"
|
||||||
[hideLabel]="hideLabel()"
|
[hideLabel]="hideLabel()"
|
||||||
[hideValidation]="hideValidation()"
|
[hideValidation]="hideValidation()"
|
||||||
@@ -35,18 +36,19 @@
|
|||||||
[attr.aria-readonly]="readonly()"
|
[attr.aria-readonly]="readonly()"
|
||||||
(input)="onInput($event)"
|
(input)="onInput($event)"
|
||||||
(focus)="onFocus()"
|
(focus)="onFocus()"
|
||||||
|
(click)="onClick()"
|
||||||
(blur)="onBlur()"
|
(blur)="onBlur()"
|
||||||
(keydown)="onKeydown($event)"
|
(keydown)="onKeydown($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2" aria-hidden="true">
|
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-primary" 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="ti-spinner block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||||
</span>
|
</span>
|
||||||
} @else if (clearable() && (hasSelectedItem() || searchText())) {
|
} @else if (clearable() && (hasSelectedItem() || searchText())) {
|
||||||
<button
|
<button
|
||||||
type="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"
|
aria-label="Clear selection"
|
||||||
[disabled]="isDisabled() || readonly()"
|
[disabled]="isDisabled() || readonly()"
|
||||||
(mousedown)="$event.preventDefault()"
|
(mousedown)="$event.preventDefault()"
|
||||||
@@ -75,11 +77,11 @@
|
|||||||
<div
|
<div
|
||||||
[id]="panelId()"
|
[id]="panelId()"
|
||||||
role="listbox"
|
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()"
|
[class]="panelClass()"
|
||||||
>
|
>
|
||||||
@if (message()) {
|
@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() }}
|
{{ message() }}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -88,10 +90,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
[id]="optionId(index)"
|
[id]="optionId(index)"
|
||||||
[class]="activeIndex() === index
|
[class]="optionClass(item, 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'
|
[attr.aria-selected]="isOptionSelected(item)"
|
||||||
: '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"
|
|
||||||
(mousedown)="$event.preventDefault()"
|
(mousedown)="$event.preventDefault()"
|
||||||
(mouseenter)="activeIndex.set(index)"
|
(mouseenter)="activeIndex.set(index)"
|
||||||
(click)="select(item)"
|
(click)="select(item)"
|
||||||
@@ -99,6 +99,15 @@
|
|||||||
{{ displayWith()(item) }}
|
{{ displayWith()(item) }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||||
import { Component, signal } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
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 { Observable, Subject, of, throwError } from 'rxjs';
|
||||||
|
|
||||||
import { Autocomplete } from './autocomplete';
|
import { Autocomplete } from './autocomplete';
|
||||||
import { AutocompleteSearchFn } from './autocomplete.types';
|
import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types';
|
||||||
|
|
||||||
interface LookupItem {
|
interface LookupItem {
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
@@ -26,24 +26,29 @@ const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };
|
|||||||
[searchFn]="searchFn"
|
[searchFn]="searchFn"
|
||||||
[displayWith]="displayWith"
|
[displayWith]="displayWith"
|
||||||
[valueWith]="valueWith"
|
[valueWith]="valueWith"
|
||||||
|
placeholder="e.g.: USD"
|
||||||
[selectedItem]="selectedItem()"
|
[selectedItem]="selectedItem()"
|
||||||
|
[resolveValueFn]="resolveValueFn()"
|
||||||
[minSearchLength]="minLength"
|
[minSearchLength]="minLength"
|
||||||
[debounceTime]="delay()"
|
[debounceTime]="delay()"
|
||||||
[readonly]="readonly()"
|
[readonly]="readonly()"
|
||||||
[showDropdownOnFocus]="openOnFocus"
|
[showDropdownOnFocus]="openOnFocus()"
|
||||||
|
[submitAttempted]="submitAttempted()"
|
||||||
/>
|
/>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
class HostComponent {
|
class HostComponent {
|
||||||
readonly control = new FormControl<string | null>(null);
|
readonly control = new FormControl<string | null>(null);
|
||||||
readonly selectedItem = signal<LookupItem | null>(null);
|
readonly selectedItem = signal<LookupItem | null>(null);
|
||||||
|
readonly resolveValueFn = signal<AutocompleteResolveValueFn<LookupItem, string> | null>(null);
|
||||||
searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]);
|
searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]);
|
||||||
readonly displayWith = (item: LookupItem): string => item.title;
|
readonly displayWith = (item: LookupItem): string => item.title;
|
||||||
readonly valueWith = (item: LookupItem): string => item.code;
|
readonly valueWith = (item: LookupItem): string => item.code;
|
||||||
minLength = 2;
|
minLength = 2;
|
||||||
readonly delay = signal(300);
|
readonly delay = signal(300);
|
||||||
readonly readonly = signal(false);
|
readonly readonly = signal(false);
|
||||||
openOnFocus = false;
|
readonly openOnFocus = signal(false);
|
||||||
|
readonly submitAttempted = signal(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Autocomplete', () => {
|
describe('Autocomplete', () => {
|
||||||
@@ -73,6 +78,11 @@ describe('Autocomplete', () => {
|
|||||||
it('initializes with an empty selection', () => {
|
it('initializes with an empty selection', () => {
|
||||||
expect(component.searchText()).toBe('');
|
expect(component.searchText()).toBe('');
|
||||||
expect(component.options()).toEqual([]);
|
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', () => {
|
it('integrates with a reactive form control', () => {
|
||||||
@@ -80,6 +90,14 @@ describe('Autocomplete', () => {
|
|||||||
expect(host.control.value).toBe('IN');
|
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', () => {
|
it('does not emit onChange from writeValue', () => {
|
||||||
const change = vi.fn();
|
const change = vi.fn();
|
||||||
component.registerOnChange(change);
|
component.registerOnChange(change);
|
||||||
@@ -87,6 +105,28 @@ describe('Autocomplete', () => {
|
|||||||
expect(change).not.toHaveBeenCalled();
|
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', () => {
|
it('applies a disabled form state', () => {
|
||||||
host.control.disable();
|
host.control.disable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -117,6 +157,40 @@ describe('Autocomplete', () => {
|
|||||||
vi.useRealTimers();
|
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', () => {
|
it('cancels stale search requests', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const first = new Subject<readonly LookupItem[]>();
|
const first = new Subject<readonly LookupItem[]>();
|
||||||
@@ -227,6 +301,36 @@ describe('Autocomplete', () => {
|
|||||||
expect(component.searchText()).toBe('India');
|
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', () => {
|
it('does not retain a stale label when edit values change', () => {
|
||||||
host.selectedItem.set(INDIA);
|
host.selectedItem.set(INDIA);
|
||||||
host.control.setValue('IN');
|
host.control.setValue('IN');
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ interface SearchResult<TItem> {
|
|||||||
readonly term: string;
|
readonly term: string;
|
||||||
readonly options: readonly TItem[];
|
readonly options: readonly TItem[];
|
||||||
readonly failed: boolean;
|
readonly failed: boolean;
|
||||||
|
readonly preview: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchRequest {
|
||||||
|
readonly term: string;
|
||||||
|
readonly preview: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -59,9 +65,10 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
|
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`;
|
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 readonly valuesToResolve$ = new Subject<TValue>();
|
||||||
private formValue: TValue | null = null;
|
private formValue: TValue | null = null;
|
||||||
|
private readonly controlStateVersion = signal(0);
|
||||||
private labelEdited = false;
|
private labelEdited = false;
|
||||||
private onChange: (value: TValue | null) => void = () => {};
|
private onChange: (value: TValue | null) => void = () => {};
|
||||||
private onTouched: () => void = () => {};
|
private onTouched: () => void = () => {};
|
||||||
@@ -81,6 +88,8 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
readonly minSearchLength = input(1);
|
readonly minSearchLength = input(1);
|
||||||
readonly debounceTime = input(300);
|
readonly debounceTime = input(300);
|
||||||
readonly limit = input(10);
|
readonly limit = input(10);
|
||||||
|
readonly previewLimit = input(5);
|
||||||
|
readonly previewText = input('Type to search more...');
|
||||||
readonly disabled = input(false);
|
readonly disabled = input(false);
|
||||||
readonly readonly = input(false);
|
readonly readonly = input(false);
|
||||||
readonly clearable = input(true);
|
readonly clearable = input(true);
|
||||||
@@ -89,7 +98,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
readonly emptyText = input('No results found');
|
readonly emptyText = input('No results found');
|
||||||
readonly typeToSearchText = input('Type to search');
|
readonly typeToSearchText = input('Type to search');
|
||||||
readonly errorText = input('Unable to load results');
|
readonly errorText = input('Unable to load results');
|
||||||
readonly showDropdownOnFocus = input(false);
|
readonly showDropdownOnFocus = input(true);
|
||||||
readonly closeOnSelect = input(true);
|
readonly closeOnSelect = input(true);
|
||||||
readonly autocomplete = input('off');
|
readonly autocomplete = input('off');
|
||||||
readonly ariaLabel = input<string | null>(null);
|
readonly ariaLabel = input<string | null>(null);
|
||||||
@@ -102,6 +111,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
readonly validationMessages = input<ValidationMessageMap>({});
|
readonly validationMessages = input<ValidationMessageMap>({});
|
||||||
readonly description = input<string | null>(null);
|
readonly description = input<string | null>(null);
|
||||||
readonly hint = input<string | null>(null);
|
readonly hint = input<string | null>(null);
|
||||||
|
readonly help = input<string | null>(null);
|
||||||
readonly labelPosition = input<FormLabelPosition>('top');
|
readonly labelPosition = input<FormLabelPosition>('top');
|
||||||
|
|
||||||
readonly itemSelected = output<TItem>();
|
readonly itemSelected = output<TItem>();
|
||||||
@@ -118,6 +128,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
readonly activeItem = signal<TItem | null>(null);
|
readonly activeItem = signal<TItem | null>(null);
|
||||||
readonly searchText = signal('');
|
readonly searchText = signal('');
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
|
readonly showingPreview = signal(false);
|
||||||
readonly formDisabled = signal(false);
|
readonly formDisabled = signal(false);
|
||||||
readonly panelWidth = signal(0);
|
readonly panelWidth = signal(0);
|
||||||
|
|
||||||
@@ -136,21 +147,24 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
readonly message = computed(() => {
|
readonly message = computed(() => {
|
||||||
if (this.loading()) return this.loadingText();
|
if (this.loading()) return this.loadingText();
|
||||||
if (this.error()) return this.errorText();
|
if (this.error()) return this.errorText();
|
||||||
|
if (this.showingPreview()) return '';
|
||||||
if (this.searchText().trim().length < this.minSearchLength()) {
|
if (this.searchText().trim().length < this.minSearchLength()) {
|
||||||
return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`;
|
return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`;
|
||||||
}
|
}
|
||||||
return this.options().length ? '' : this.emptyText();
|
return this.options().length ? '' : this.emptyText();
|
||||||
});
|
});
|
||||||
readonly resolvedInputClass = computed(() => {
|
readonly resolvedInputClass = computed(() => {
|
||||||
|
this.controlStateVersion();
|
||||||
const control = this.control();
|
const control = this.control();
|
||||||
const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted()));
|
const invalid = !!(control?.invalid && this.submitAttempted());
|
||||||
return [
|
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',
|
'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70',
|
||||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
'placeholder:text-textmuted placeholder:opacity-100 dark:placeholder:text-white/50',
|
||||||
'pe-16',
|
'focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none',
|
||||||
|
'pe-10 transition-colors',
|
||||||
invalid ? 'is-invalid border-danger' : '',
|
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()
|
this.inputClass()
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
});
|
});
|
||||||
@@ -161,24 +175,49 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
];
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.inputTerms$.pipe(
|
effect((onCleanup) => {
|
||||||
map(term => term.trim()),
|
const control = this.control();
|
||||||
debounce(() => timer(Math.max(0, this.debounceTime()))),
|
if (!control) return;
|
||||||
distinctUntilChanged(),
|
|
||||||
switchMap(term => {
|
const statusSubscription = control.statusChanges.subscribe(() => {
|
||||||
if (term.length < this.minSearchLength()) {
|
this.controlStateVersion.update(value => value + 1);
|
||||||
return of<SearchResult<TItem>>({ term, options: [], failed: false });
|
});
|
||||||
|
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.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
return this.searchFn()(term, this.limit()).pipe(
|
const limit = request.preview ? this.previewLimit() : this.limit();
|
||||||
map(options => ({ term, options, failed: false })),
|
return this.searchFn()(request.term, limit).pipe(
|
||||||
catchError(() => of<SearchResult<TItem>>({ term, options: [], failed: true }))
|
map(options => ({ ...request, options, failed: false })),
|
||||||
|
catchError(() => of<SearchResult<TItem>>({ ...request, options: [], failed: true }))
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
takeUntilDestroyed()
|
takeUntilDestroyed()
|
||||||
).subscribe(result => {
|
).subscribe(result => {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.showingPreview.set(result.preview && !result.failed);
|
||||||
this.options.set(result.options);
|
this.options.set(result.options);
|
||||||
this.activeIndex.set(-1);
|
this.activeIndex.set(-1);
|
||||||
this.error.set(result.failed ? this.errorText() : null);
|
this.error.set(result.failed ? this.errorText() : null);
|
||||||
@@ -203,7 +242,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
writeValue(value: TValue | null): void {
|
writeValue(value: TValue | null): void {
|
||||||
this.formValue = value ?? null;
|
this.formValue = value ?? null;
|
||||||
this.labelEdited = false;
|
this.labelEdited = false;
|
||||||
if (this.formValue === null) {
|
if (!this.hasResolvableValue(this.formValue)) {
|
||||||
this.applyResolvedItem(null);
|
this.applyResolvedItem(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,17 +267,21 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
const text = event.target.value;
|
const text = event.target.value;
|
||||||
const previousText = this.searchText();
|
const previousText = this.searchText();
|
||||||
this.searchText.set(text);
|
this.searchText.set(text);
|
||||||
|
this.activeItem.set(null);
|
||||||
|
this.showingPreview.set(false);
|
||||||
|
this.options.set([]);
|
||||||
this.labelEdited = this.formValue !== null && text !== previousText;
|
this.labelEdited = this.formValue !== null && text !== previousText;
|
||||||
this.searchChanged.emit(text.trim());
|
this.searchChanged.emit(text.trim());
|
||||||
this.open();
|
this.open();
|
||||||
this.inputTerms$.next(text);
|
this.searchRequests$.next({ term: text, preview: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
onFocus(): void {
|
onFocus(): void {
|
||||||
if (this.showDropdownOnFocus()) {
|
if (this.showDropdownOnFocus()) this.openPreview();
|
||||||
this.open();
|
}
|
||||||
this.inputTerms$.next(this.searchText());
|
|
||||||
}
|
onClick(): void {
|
||||||
|
if (this.showDropdownOnFocus()) this.openPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
onBlur(): void {
|
onBlur(): void {
|
||||||
@@ -305,6 +348,19 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
|
|
||||||
optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; }
|
optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; }
|
||||||
optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? 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 {
|
private setActive(index: number): void {
|
||||||
if (index < 0 || index >= this.options().length) return;
|
if (index < 0 || index >= this.options().length) return;
|
||||||
@@ -319,6 +375,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
|||||||
this.options.set([]);
|
this.options.set([]);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.showingPreview.set(false);
|
||||||
this.labelEdited = false;
|
this.labelEdited = false;
|
||||||
this.onChange(null);
|
this.onChange(null);
|
||||||
this.onTouched();
|
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 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()">
|
<div [class]="resolvedWrapperClass()">
|
||||||
@if (showLabel()) {
|
@if (showLabel()) {
|
||||||
<label [for]="inputId()" [class]="resolvedLabelClass()" >
|
<label [for]="inputId()" [class]="resolvedLabelClass()">
|
||||||
{{ label() }}
|
{{ label() }}
|
||||||
@if (required()) {
|
@if (required()) {
|
||||||
<span [class]="requiredClass()" aria-hidden="true" >
|
<span [class]="requiredClass()" aria-hidden="true">
|
||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="sr-only">
|
<span class="sr-only">
|
||||||
Required
|
Required
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</label>
|
|
||||||
|
@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()">
|
<div [class]="resolvedContentClass()">
|
||||||
@if (description()) {
|
@if (description()) {
|
||||||
<p [id]="descriptionId()" [class]="descriptionClass()" >
|
<p [id]="descriptionId()" [class]="descriptionClass()">
|
||||||
{{ description() }}
|
{{ description() }}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<ng-content />
|
<ng-content />
|
||||||
|
|
||||||
@if (showHint()) {
|
@if (showHint()) {
|
||||||
<p
|
<p [id]="hintId()" [class]="hintClass()">
|
||||||
[id]="hintId()"
|
{{ hint() }}
|
||||||
[class]="hintClass()"
|
</p>
|
||||||
>
|
|
||||||
{{ hint() }}
|
|
||||||
</p>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!hideValidation()) {
|
@if (!hideValidation()) {
|
||||||
<app-form-validation-message
|
<app-form-validation-message [id]="validationId()" [fieldName]="label()" [control]="control()"
|
||||||
[id]="validationId()"
|
[messages]="validationMessages()" [showWhenDirty]="showValidationWhenDirty()"
|
||||||
[fieldName]="label()"
|
[submitAttempted]="submitAttempted()" />
|
||||||
[control]="control()"
|
|
||||||
[messages]="validationMessages()"
|
|
||||||
[showWhenDirty]="showValidationWhenDirty()"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -12,13 +12,14 @@ import {
|
|||||||
FormValidationMessage,
|
FormValidationMessage,
|
||||||
ValidationMessageMap
|
ValidationMessageMap
|
||||||
} from '../form-validation-message/form-validation-message';
|
} from '../form-validation-message/form-validation-message';
|
||||||
|
import { TooltipDirective } from '../../../directives/tooltip/tooltip.directive';
|
||||||
|
|
||||||
export type FormLabelPosition = 'top' | 'left' | 'hidden';
|
export type FormLabelPosition = 'top' | 'left' | 'hidden';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-form-field',
|
selector: 'app-form-field',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormValidationMessage],
|
imports: [FormValidationMessage, TooltipDirective],
|
||||||
templateUrl: './form-field.html',
|
templateUrl: './form-field.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
@@ -36,6 +37,15 @@ export class FormField {
|
|||||||
readonly description = input<string | null>(null);
|
readonly description = input<string | null>(null);
|
||||||
|
|
||||||
readonly hint = 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');
|
readonly labelPosition = input<FormLabelPosition>('top');
|
||||||
|
|
||||||
@@ -120,9 +130,8 @@ export class FormField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
control.touched ||
|
this.submitAttempted() ||
|
||||||
control.dirty ||
|
(this.showValidationWhenDirty() && control.dirty)
|
||||||
this.submitAttempted()
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
[disabled]="isDisabled()"
|
[disabled]="isDisabled()"
|
||||||
[description]="description()"
|
[description]="description()"
|
||||||
[hint]="hint()"
|
[hint]="hint()"
|
||||||
|
[help]="help()"
|
||||||
[hideValidation]="hideValidation()"
|
[hideValidation]="hideValidation()"
|
||||||
[showValidationWhenDirty]="showValidationWhenDirty()"
|
[showValidationWhenDirty]="showValidationWhenDirty()"
|
||||||
[submitAttempted]="submitAttempted()"
|
[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 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';
|
export type FormInputIconPosition = 'left' | 'right';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export type FormInputIconPosition = 'left' | 'right';
|
|||||||
export class FormInput implements ControlValueAccessor {
|
export class FormInput implements ControlValueAccessor {
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
|
||||||
|
|
||||||
readonly inputId = input.required<string>();
|
readonly inputId = input.required<string>();
|
||||||
readonly label = input.required<string>();
|
readonly label = input.required<string>();
|
||||||
|
|
||||||
@@ -54,14 +54,15 @@ export class FormInput implements ControlValueAccessor {
|
|||||||
|
|
||||||
readonly pattern = input<string | null>(null);
|
readonly pattern = input<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
readonly min = input<number | null>(null);
|
readonly min = input<number | null>(null);
|
||||||
readonly max = input<number | null>(null);
|
readonly max = input<number | null>(null);
|
||||||
readonly step = input<number | string | null>(null);
|
readonly step = input<number | string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
readonly description = input<string | null>(null);
|
readonly description = input<string | null>(null);
|
||||||
readonly hint = input<string | null>(null);
|
readonly hint = input<string | null>(null);
|
||||||
|
readonly help = input<string | null>(null);
|
||||||
|
|
||||||
readonly hideValidation = input(false);
|
readonly hideValidation = input(false);
|
||||||
readonly showValidationWhenDirty = input(false);
|
readonly showValidationWhenDirty = input(false);
|
||||||
@@ -78,25 +79,25 @@ export class FormInput implements ControlValueAccessor {
|
|||||||
readonly showPasswordToggle = input(true);
|
readonly showPasswordToggle = input(true);
|
||||||
readonly loading = input(false);
|
readonly loading = input(false);
|
||||||
|
|
||||||
|
|
||||||
readonly wrapperClass = input('');
|
readonly wrapperClass = input('');
|
||||||
readonly fieldContentClass = input('');
|
readonly fieldContentClass = input('');
|
||||||
readonly labelClass = input('');
|
readonly labelClass = input('');
|
||||||
readonly inputClass = input('');
|
readonly inputClass = input('');
|
||||||
|
|
||||||
|
|
||||||
readonly ariaLabel = input<string | null>(null);
|
readonly ariaLabel = input<string | null>(null);
|
||||||
readonly ariaDescription = input<string | null>(null);
|
readonly ariaDescription = input<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
readonly value = signal<string | number | null>(null);
|
readonly value = signal<string | number | null>(null);
|
||||||
readonly formDisabled = signal(false);
|
readonly formDisabled = signal(false);
|
||||||
readonly passwordVisible = signal(false);
|
readonly passwordVisible = signal(false);
|
||||||
private readonly controlStateVersion = signal(0);
|
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() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -192,9 +193,8 @@ export class FormInput implements ControlValueAccessor {
|
|||||||
const showInvalidState = !!(
|
const showInvalidState = !!(
|
||||||
control?.invalid &&
|
control?.invalid &&
|
||||||
(
|
(
|
||||||
control.touched ||
|
this.submitAttempted() ||
|
||||||
control.dirty ||
|
(this.showValidationWhenDirty() && control.dirty)
|
||||||
this.submitAttempted()
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,9 +228,8 @@ export class FormInput implements ControlValueAccessor {
|
|||||||
if (
|
if (
|
||||||
control?.invalid &&
|
control?.invalid &&
|
||||||
(
|
(
|
||||||
control.touched ||
|
this.submitAttempted() ||
|
||||||
control.dirty ||
|
(this.showValidationWhenDirty() && control.dirty)
|
||||||
this.submitAttempted()
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ids.push(`${this.inputId()}-validation`);
|
ids.push(`${this.inputId()}-validation`);
|
||||||
|
|||||||
@@ -367,9 +367,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
|||||||
if (
|
if (
|
||||||
control?.invalid &&
|
control?.invalid &&
|
||||||
(
|
(
|
||||||
control.touched ||
|
this.submitAttempted() ||
|
||||||
control.dirty ||
|
(this.showValidationWhenDirty() && control.dirty)
|
||||||
this.submitAttempted()
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
ids.push(`${this.resolvedInputId()}-validation`);
|
ids.push(`${this.resolvedInputId()}-validation`);
|
||||||
@@ -406,9 +405,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
|||||||
const showInvalidState = !!(
|
const showInvalidState = !!(
|
||||||
control?.invalid &&
|
control?.invalid &&
|
||||||
(
|
(
|
||||||
control.touched ||
|
this.submitAttempted() ||
|
||||||
control.dirty ||
|
(this.showValidationWhenDirty() && control.dirty)
|
||||||
this.submitAttempted()
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -71,9 +71,8 @@ export class FormValidationMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
control.touched ||
|
this.submitAttempted() ||
|
||||||
control.dirty ||
|
(this.showWhenDirty() && control.dirty)
|
||||||
this.submitAttempted()
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { Directive, ElementRef, HostListener, OnDestroy, inject, input } from '@angular/core';
|
import {
|
||||||
|
ComponentRef,
|
||||||
import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay';
|
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 { ComponentPortal } from '@angular/cdk/portal';
|
||||||
|
|
||||||
import { Tooltip } from './tooltip/tooltip';
|
import {
|
||||||
|
Tooltip
|
||||||
|
} from './tooltip/tooltip';
|
||||||
|
import { TooltipVariant } from './tooltip/tooltip';
|
||||||
|
|
||||||
export type TooltipPosition =
|
export type TooltipPosition =
|
||||||
| 'top'
|
| 'top'
|
||||||
@@ -27,20 +41,42 @@ export class TooltipDirective implements OnDestroy {
|
|||||||
readonly tooltipPosition =
|
readonly tooltipPosition =
|
||||||
input<TooltipPosition>('top');
|
input<TooltipPosition>('top');
|
||||||
|
|
||||||
|
readonly tooltipVariant =
|
||||||
|
input<TooltipVariant>('default');
|
||||||
|
|
||||||
readonly tooltipDisabled = input(false);
|
readonly tooltipDisabled = input(false);
|
||||||
|
|
||||||
readonly tooltipDelay = input(200);
|
readonly tooltipDelay = input(200);
|
||||||
|
|
||||||
private overlayRef: OverlayRef | null = null;
|
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('mouseenter')
|
||||||
@HostListener('focusin')
|
@HostListener('focusin')
|
||||||
show(): void {
|
show(): void {
|
||||||
|
const tooltipText = this.appTooltip().trim();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.tooltipDisabled() ||
|
this.tooltipDisabled() ||
|
||||||
!this.appTooltip()
|
tooltipText.length === 0
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -48,8 +84,9 @@ export class TooltipDirective implements OnDestroy {
|
|||||||
this.clearTimeout();
|
this.clearTimeout();
|
||||||
|
|
||||||
this.showTimeout = setTimeout(() => {
|
this.showTimeout = setTimeout(() => {
|
||||||
|
this.showTimeout = null;
|
||||||
this.openTooltip();
|
this.openTooltip();
|
||||||
}, this.tooltipDelay());
|
}, Math.max(0, this.tooltipDelay()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('mouseleave')
|
@HostListener('mouseleave')
|
||||||
@@ -60,37 +97,49 @@ export class TooltipDirective implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private openTooltip(): void {
|
private openTooltip(): void {
|
||||||
if (this.overlayRef) {
|
if (this.overlayRef?.hasAttached()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const positionStrategy = this.overlay
|
const positionStrategy = this.overlay
|
||||||
.position()
|
.position()
|
||||||
.flexibleConnectedTo(this.elementRef)
|
.flexibleConnectedTo(
|
||||||
.withPositions(this.getPositions());
|
this.elementRef.nativeElement
|
||||||
|
)
|
||||||
|
.withPositions(this.getPositions())
|
||||||
|
.withPush(true);
|
||||||
|
|
||||||
this.overlayRef = this.overlay.create({
|
this.overlayRef = this.overlay.create({
|
||||||
positionStrategy,
|
positionStrategy,
|
||||||
scrollStrategy: this.overlay.scrollStrategies.reposition()
|
scrollStrategy:
|
||||||
|
this.overlay.scrollStrategies.reposition()
|
||||||
});
|
});
|
||||||
|
|
||||||
const portal = new ComponentPortal(Tooltip);
|
const portal = new ComponentPortal(Tooltip);
|
||||||
|
|
||||||
const componentRef = this.overlayRef.attach(portal);
|
this.tooltipComponentRef =
|
||||||
|
this.overlayRef.attach(portal);
|
||||||
|
|
||||||
componentRef.setInput(
|
this.tooltipComponentRef.setInput(
|
||||||
'text',
|
'text',
|
||||||
this.appTooltip()
|
this.appTooltip().trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.tooltipComponentRef.setInput(
|
||||||
|
'variant',
|
||||||
|
this.tooltipVariant()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private closeTooltip(): void {
|
private closeTooltip(): void {
|
||||||
this.overlayRef?.dispose();
|
this.overlayRef?.dispose();
|
||||||
|
|
||||||
this.overlayRef = null;
|
this.overlayRef = null;
|
||||||
|
this.tooltipComponentRef = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearTimeout(): void {
|
private clearTimeout(): void {
|
||||||
if (!this.showTimeout) {
|
if (this.showTimeout === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +159,13 @@ export class TooltipDirective implements OnDestroy {
|
|||||||
overlayX: 'center',
|
overlayX: 'center',
|
||||||
overlayY: 'bottom',
|
overlayY: 'bottom',
|
||||||
offsetY: -8
|
offsetY: -8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originX: 'center',
|
||||||
|
originY: 'bottom',
|
||||||
|
overlayX: 'center',
|
||||||
|
overlayY: 'top',
|
||||||
|
offsetY: 8
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -120,6 +176,13 @@ export class TooltipDirective implements OnDestroy {
|
|||||||
overlayX: 'center',
|
overlayX: 'center',
|
||||||
overlayY: 'top',
|
overlayY: 'top',
|
||||||
offsetY: 8
|
offsetY: 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originX: 'center',
|
||||||
|
originY: 'top',
|
||||||
|
overlayX: 'center',
|
||||||
|
overlayY: 'bottom',
|
||||||
|
offsetY: -8
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -130,6 +193,13 @@ export class TooltipDirective implements OnDestroy {
|
|||||||
overlayX: 'end',
|
overlayX: 'end',
|
||||||
overlayY: 'center',
|
overlayY: 'center',
|
||||||
offsetX: -8
|
offsetX: -8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originX: 'end',
|
||||||
|
originY: 'center',
|
||||||
|
overlayX: 'start',
|
||||||
|
overlayY: 'center',
|
||||||
|
offsetX: 8
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -140,6 +210,13 @@ export class TooltipDirective implements OnDestroy {
|
|||||||
overlayX: 'start',
|
overlayX: 'start',
|
||||||
overlayY: 'center',
|
overlayY: 'center',
|
||||||
offsetX: 8
|
offsetX: 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originX: 'start',
|
||||||
|
originY: 'center',
|
||||||
|
overlayX: 'end',
|
||||||
|
overlayY: 'center',
|
||||||
|
offsetX: -8
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div
|
<!-- <div
|
||||||
class="
|
class="
|
||||||
pointer-events-none
|
pointer-events-none
|
||||||
max-w-[250px]
|
max-w-xs
|
||||||
whitespace-normal
|
whitespace-normal
|
||||||
rounded-sm
|
rounded-sm
|
||||||
bg-primary
|
bg-primary
|
||||||
@@ -16,4 +16,11 @@
|
|||||||
role="tooltip"
|
role="tooltip"
|
||||||
>
|
>
|
||||||
{{ text() }}
|
{{ text() }}
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div
|
||||||
|
[class]="resolvedTooltipClass()"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
{{ text() }}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
input
|
input,
|
||||||
|
computed
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
|
|
||||||
|
export type TooltipVariant =
|
||||||
|
| 'default'
|
||||||
|
| 'info';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tooltip',
|
selector: 'app-tooltip',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -12,4 +18,42 @@ import {
|
|||||||
})
|
})
|
||||||
export class Tooltip {
|
export class Tooltip {
|
||||||
readonly text = input.required<string>();
|
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(' ');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user