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 { API_CONFIG } from '../config/api.config';
|
||||
|
||||
const backendErrorMessage = (body: unknown): string | null => {
|
||||
if (typeof body === 'string') return body.trim() || null;
|
||||
if (typeof body !== 'object' || body === null) return null;
|
||||
|
||||
const record = body as Record<string, unknown>;
|
||||
for (const key of ['detail', 'message', 'title']) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const httpErrorMessage = (error: HttpErrorResponse): string => {
|
||||
const backendMessage = backendErrorMessage(error.error);
|
||||
if (backendMessage) return backendMessage;
|
||||
|
||||
if (error.status > 0) {
|
||||
const statusText = error.statusText.trim();
|
||||
const meaningfulStatusText = statusText && statusText.toUpperCase() !== 'OK';
|
||||
return `HTTP ${error.status}${meaningfulStatusText ? ` ${statusText}` : ''}`;
|
||||
}
|
||||
|
||||
return error.message || 'Request failed';
|
||||
};
|
||||
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const toastr = inject(ToastrService);
|
||||
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
|
||||
@@ -13,7 +38,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (!isLoginRequest && !isRefreshRequest && error.status !== 401) {
|
||||
const message = error.error?.detail ?? error.error?.message ?? error.message ?? 'Request failed';
|
||||
const message = httpErrorMessage(error);
|
||||
toastr.error(message, 'Request failed');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface CurrencyDto {
|
||||
id: string;
|
||||
code: string;
|
||||
iso2: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
numericCode: number;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DataTableRecord } from '../../../shared/components/data-table/data-table.types';
|
||||
|
||||
|
||||
export interface TenantCurrencyDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
currencyId: string;
|
||||
currencyName: string;
|
||||
isBaseCurrency: boolean;
|
||||
isReporting: boolean;
|
||||
isActive: boolean;
|
||||
createdOn?: string;
|
||||
modifiedOn?: string | null;
|
||||
}
|
||||
|
||||
export interface TenantCurrencyLookupDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
currencyId: string;
|
||||
isBaseCurrency: boolean;
|
||||
isReporting: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTenantCurrencyRequest {
|
||||
tenantId: string;
|
||||
currencyId: string;
|
||||
isBaseCurrency: boolean;
|
||||
isReporting: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateTenantCurrencyRequest {
|
||||
isBaseCurrency: boolean;
|
||||
isReporting: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface TenantCurrencyTableRow extends DataTableRecord {
|
||||
readonly id: string;
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly currencyId: string;
|
||||
readonly currencyName: string;
|
||||
readonly isBaseCurrency: boolean;
|
||||
readonly isReporting: boolean;
|
||||
readonly isActive: boolean;
|
||||
readonly serialNumber: number;
|
||||
readonly createdOn?: string;
|
||||
readonly modifiedOn?: string | null;
|
||||
}
|
||||
|
||||
export type TenantCurrencyModalMode = 'create' | 'edit';
|
||||
@@ -0,0 +1,68 @@
|
||||
import { DataTableRecord } from '../../../shared/components/data-table/data-table.types';
|
||||
|
||||
export enum TenantStatus { Trial = 0, Active = 1, Suspended = 2, Cancelled = 3 }
|
||||
|
||||
export interface TenantDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: TenantStatus;
|
||||
defaultLanguageId: string;
|
||||
defaultLanguageName: string | null;
|
||||
defaultDbConnectionId: string | null;
|
||||
defaultDbConnectionName: string | null;
|
||||
defaultCurrencyId: string;
|
||||
defaultCurrencyName: string | null;
|
||||
defaultTimezoneId: string;
|
||||
defaultTimezoneName: string | null;
|
||||
dataRegion: string;
|
||||
isActive: boolean;
|
||||
createdOn?: string;
|
||||
modifiedOn?: string | null;
|
||||
}
|
||||
|
||||
export interface TenantLookupDto {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface CreateTenantRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
status: TenantStatus;
|
||||
defaultLanguageId: string;
|
||||
defaultCurrencyId: string;
|
||||
defaultTimezoneId: string;
|
||||
dataRegion: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenantRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
status: TenantStatus;
|
||||
defaultLanguageId: string;
|
||||
defaultCurrencyId: string;
|
||||
defaultTimezoneId: string;
|
||||
defaultDbConnectionId: string | null;
|
||||
dataRegion: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface TenantTableRow extends DataTableRecord {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly status: TenantStatus;
|
||||
readonly dataRegion: string;
|
||||
readonly isActive: boolean;
|
||||
readonly serialNumber: number;
|
||||
readonly createdOn?: string;
|
||||
readonly modifiedOn?: string | null;
|
||||
readonly defaultLanguageName: string | null;
|
||||
readonly defaultCurrencyName: string | null;
|
||||
readonly defaultTimezoneName: string | null;
|
||||
}
|
||||
|
||||
export type TenantModalMode = 'create' | 'edit';
|
||||
@@ -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,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
|
||||
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
|
||||
{
|
||||
title: 'Global Master',
|
||||
type: 'sub',
|
||||
@@ -41,6 +39,18 @@ export const SAAS_MENU_DATA: MenuContext = {
|
||||
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tenant Master',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
|
||||
{ path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false }
|
||||
],
|
||||
},
|
||||
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
|
||||
{
|
||||
title: 'Configuration',
|
||||
type: 'sub',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { TokenStorageService } from '../auth/token-storage.service';
|
||||
import { MenuService } from './menu.service';
|
||||
|
||||
describe('MenuService dependency and authorization', () => {
|
||||
let menuService: MenuService;
|
||||
let tokenStorage: TokenStorageService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideRouter([])],
|
||||
});
|
||||
menuService = TestBed.inject(MenuService);
|
||||
tokenStorage = TestBed.inject(TokenStorageService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('constructs MenuService and AuthService without a circular dependency', () => {
|
||||
expect(menuService).toBeTruthy();
|
||||
expect(TestBed.inject(AuthService)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the Users menu when the stored user is not a super administrator', async () => {
|
||||
tokenStorage.saveAuth(
|
||||
{ accessToken: 'token', user: { id: '1', email: 'user@example.com', roles: ['admin'] } },
|
||||
false,
|
||||
);
|
||||
|
||||
const context = await firstValueFrom(menuService.loadMenu());
|
||||
|
||||
expect(hasPath(context.items, '/users')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the Users menu when the stored user is a super administrator', async () => {
|
||||
tokenStorage.saveAuth(
|
||||
{
|
||||
accessToken: 'token',
|
||||
user: { id: '1', email: 'super@example.com', roles: ['super_admin'] },
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const context = await firstValueFrom(menuService.loadMenu());
|
||||
|
||||
expect(hasPath(context.items, '/users')).toBe(true);
|
||||
});
|
||||
|
||||
it('clears menu state during context cleanup', async () => {
|
||||
await firstValueFrom(menuService.loadMenu());
|
||||
|
||||
menuService.clear();
|
||||
|
||||
expect(menuService.menuContext()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function hasPath(items: ReturnType<MenuService['getNavigationMenu']>, path: string): boolean {
|
||||
return items.some(
|
||||
(item) =>
|
||||
item.path === path ||
|
||||
(item.children ? hasPath(item.children, path) : false) ||
|
||||
(item.children2 ? hasPath(item.children2, path) : false),
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { Observable, of, tap } from 'rxjs';
|
||||
import { MenuContext } from '../../models/context/context.model';
|
||||
import { SAAS_MENU_DATA } from './menu.data';
|
||||
import { Menu } from '../../../core/services/common/nav.service';
|
||||
import { TokenStorageService } from '../auth/token-storage.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MenuService {
|
||||
private readonly tokenStorage = inject(TokenStorageService);
|
||||
readonly menuContext = signal<MenuContext | null>(null);
|
||||
|
||||
loadMenu(): Observable<MenuContext> {
|
||||
const context = this.cloneMenuContext(SAAS_MENU_DATA);
|
||||
context.items = this.filterAuthorizedItems(context.items);
|
||||
return of(context).pipe(
|
||||
tap((menuContext) => {
|
||||
this.menuContext.set(menuContext);
|
||||
@@ -39,4 +42,15 @@ export class MenuService {
|
||||
private cloneMenuItems(items: Menu[]): Menu[] {
|
||||
return JSON.parse(JSON.stringify(items)) as Menu[];
|
||||
}
|
||||
}
|
||||
|
||||
private filterAuthorizedItems(items: Menu[]): Menu[] {
|
||||
const isSuperAdmin = (this.tokenStorage.getUser()?.roles ?? []).includes('super_admin');
|
||||
return items
|
||||
.filter(item => isSuperAdmin || item.path !== '/users')
|
||||
.map(item => ({
|
||||
...item,
|
||||
children: item.children ? this.filterAuthorizedItems(item.children) : item.children,
|
||||
children2: item.children2 ? this.filterAuthorizedItems(item.children2) : item.children2
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { TENANT_CURRENCIES_ENDPOINTS } from '../../end-points/tenant/tenant-currencies.endpoints';
|
||||
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
|
||||
import {
|
||||
CreateTenantCurrencyRequest,
|
||||
TenantCurrencyDto,
|
||||
TenantCurrencyDto as TenantCurrencyDtoAlias,
|
||||
TenantCurrencyLookupDto,
|
||||
UpdateTenantCurrencyRequest
|
||||
} from '../../models/tenant/tenant-currencies.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TenantCurrenciesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getTenantDataTable(query: DataTableQuery): Observable<DataTableResult<TenantCurrencyDto>> {
|
||||
return this.http.post<DataTableResult<TenantCurrencyDto>>(TENANT_CURRENCIES_ENDPOINTS.dataTable, query);
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantCurrencyRequest): Observable<TenantCurrencyDto> {
|
||||
return this.http.post<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.create, request);
|
||||
}
|
||||
|
||||
updateTenant(id: string, request: UpdateTenantCurrencyRequest): Observable<TenantCurrencyDto> {
|
||||
return this.http.put<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
getTenantById(id: string): Observable<TenantCurrencyDto> {
|
||||
return this.http.get<TenantCurrencyDto>(TENANT_CURRENCIES_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
autocomplete(term?: string, limit = 10): Observable<readonly TenantCurrencyLookupDto[]> {
|
||||
let params = new HttpParams().set('limit', limit);
|
||||
const normalizedTerm = term?.trim();
|
||||
|
||||
if (normalizedTerm) {
|
||||
params = params.set('term', normalizedTerm);
|
||||
}
|
||||
|
||||
return this.http.get<readonly TenantCurrencyLookupDto[]>(TENANT_CURRENCIES_ENDPOINTS.autocomplete, { params });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { TENANT_ENDPOINTS } from '../../end-points/tenant/tenant.endpoints';
|
||||
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
|
||||
import {
|
||||
CreateTenantRequest,
|
||||
TenantDto,
|
||||
TenantLookupDto,
|
||||
UpdateTenantRequest
|
||||
} from '../../models/tenant/tenant.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TenantService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getTenantDataTable(query: DataTableQuery): Observable<DataTableResult<TenantDto>> {
|
||||
return this.http.post<DataTableResult<TenantDto>>(TENANT_ENDPOINTS.dataTable, query);
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantRequest): Observable<TenantDto> {
|
||||
return this.http.post<TenantDto>(TENANT_ENDPOINTS.create, request);
|
||||
}
|
||||
|
||||
updateTenant(id: string, request: UpdateTenantRequest): Observable<TenantDto> {
|
||||
return this.http.put<TenantDto>(TENANT_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
getTenantById(id: string): Observable<TenantDto> {
|
||||
return this.http.get<TenantDto>(TENANT_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
autocomplete(term?: string, limit = 10): Observable<readonly TenantLookupDto[]> {
|
||||
let params = new HttpParams().set('limit', limit);
|
||||
const normalizedTerm = term?.trim();
|
||||
|
||||
if (normalizedTerm) {
|
||||
params = params.set('term', normalizedTerm);
|
||||
}
|
||||
|
||||
return this.http.get<readonly TenantLookupDto[]>(TENANT_ENDPOINTS.autocomplete, { params });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { CreateUserRequest, UpdateUserRequest, UserDto, UserLookupDto } from '../../models/user/user.model';
|
||||
import { USER_ENDPOINTS } from '../../end-points/user/user.endpoints';
|
||||
import { DataTableQuery, DataTableResult } from '../../../shared/components/data-table/data-table.types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UserService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
createUser(request: CreateUserRequest): Observable<UserDto> {
|
||||
return this.http.post<UserDto>(USER_ENDPOINTS.create, request);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<UserDto> {
|
||||
return this.http.get<UserDto>(USER_ENDPOINTS.getById(id));
|
||||
}
|
||||
|
||||
getDataTable(query: DataTableQuery): Observable<DataTableResult<UserDto>> {
|
||||
return this.http.post<DataTableResult<UserDto>>(USER_ENDPOINTS.dataTable, query);
|
||||
}
|
||||
|
||||
update(id: string, request: UpdateUserRequest): Observable<UserDto> {
|
||||
return this.http.put<UserDto>(USER_ENDPOINTS.update(id), request);
|
||||
}
|
||||
|
||||
autocomplete(term: string | null, limit = 10): Observable<readonly UserLookupDto[]> {
|
||||
const normalizedTerm = term?.trim() || null;
|
||||
let params = new HttpParams().set('limit', limit);
|
||||
if (normalizedTerm !== null) {
|
||||
params = params.set('term', normalizedTerm);
|
||||
}
|
||||
return this.http.get<readonly UserLookupDto[]>(USER_ENDPOINTS.autocomplete, { params });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
<!-- Start::row-1 -->
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
<div class="box custom-box">
|
||||
<div class="box-body p-4">
|
||||
<app-filter-card title="Location Selection" bodyClass="p-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex flex-wrap gap-1 newproject">
|
||||
<div class="box-title mb-0">Location Selection</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="filterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
@@ -55,8 +51,7 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-filter-card>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End::row-1 -->
|
||||
@@ -64,7 +59,7 @@
|
||||
<app-data-table [columns]="columns()" [rows]="cities()" [actions]="actions()"
|
||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Cities" buttonTitle="Add" [showSearch]="true"
|
||||
[showAddButton]="canAddCity()" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||
[showAddButton]="true" [emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||
searchPlaceholder="Search cities..." [searchDebounceTime]="300" (addClicked)="onAddCity()" (searchChanged)="onSearch($event)"
|
||||
(pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)" toolTip="Add City" />
|
||||
@@ -80,15 +75,16 @@
|
||||
formControlName="countryId"
|
||||
inputId="city-country"
|
||||
label="Country"
|
||||
placeholder="Search country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[resolveValueFn]="resolveCountry"
|
||||
[selectedItem]="selectedFormCountry()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="false"
|
||||
[clearable]="true"
|
||||
[required]="true"
|
||||
[readonly]="modalMode() !== 'create'"
|
||||
[validationMessages]="{ required: 'Country is required.' }"
|
||||
@@ -105,14 +101,16 @@
|
||||
inputId="city-state"
|
||||
label="State"
|
||||
[placeholder]="formStatePlaceholder()"
|
||||
[help]="'Select a country first'"
|
||||
[searchFn]="searchFormStates"
|
||||
[displayWith]="displayState"
|
||||
[valueWith]="stateValue"
|
||||
[resolveValueFn]="resolveState"
|
||||
[selectedItem]="selectedFormState()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="false"
|
||||
[clearable]="true"
|
||||
[required]="true"
|
||||
[disabled]="!cityForm.controls.countryId.value"
|
||||
[readonly]="modalMode() !== 'create'"
|
||||
@@ -126,7 +124,7 @@
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="name" inputId="city-name" label="City Name"
|
||||
placeholder="Enter city name" autocomplete="off" [required]="true"
|
||||
placeholder="Name" autocomplete="off" [required]="true"
|
||||
[maxLength]="150" [validationMessages]="{
|
||||
required: 'City Name is required.',
|
||||
maxlength: 'City Name cannot exceed 150 characters.'
|
||||
@@ -135,7 +133,7 @@
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="code" inputId="city-code" label="City Code"
|
||||
placeholder="Enter city code" autocomplete="off" [required]="true"
|
||||
placeholder="Code" autocomplete="off" [required]="true"
|
||||
[maxLength]="16" [validationMessages]="{
|
||||
required: 'City Code is required.',
|
||||
maxlength: 'City Code cannot exceed 16 characters.',
|
||||
@@ -148,7 +146,7 @@
|
||||
formControlName="timezoneId"
|
||||
inputId="city-timezone"
|
||||
label="Timezone"
|
||||
placeholder="Search timezone"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchTimezones"
|
||||
[displayWith]="displayTimezone"
|
||||
[valueWith]="timezoneValue"
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||
|
||||
interface CityTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
@@ -65,7 +66,7 @@ interface CityTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'city-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete],
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard],
|
||||
templateUrl: './city-list.html',
|
||||
styleUrl: './city-list.scss'
|
||||
})
|
||||
@@ -148,8 +149,16 @@ export class CityList {
|
||||
};
|
||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||
value => this.countryApi.getCountryById(value).pipe(
|
||||
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||
);
|
||||
readonly displayState: AutocompleteDisplayFn<StateLookupDto> = state => state.name;
|
||||
readonly stateValue: AutocompleteValueFn<StateLookupDto, string> = state => state.id;
|
||||
readonly resolveState: AutocompleteResolveValueFn<StateLookupDto, string> =
|
||||
value => this.stateApi.getStateById(value).pipe(
|
||||
map(state => ({ id: state.id, name: state.name, code: state.code ?? '' }))
|
||||
);
|
||||
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> =
|
||||
timezone => `${timezone.ianaId} — ${timezone.displayName}`;
|
||||
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> =
|
||||
@@ -157,12 +166,11 @@ export class CityList {
|
||||
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> =
|
||||
value => this.timezoneApi.getById(value).pipe(map(timezone => this.toTimezoneLookup(timezone)));
|
||||
readonly filterStatePlaceholder = computed(() =>
|
||||
this.selectedCountryId() ? 'Search state' : 'Select a country first'
|
||||
this.selectedCountryId() ? 'Search' : 'Select a country first'
|
||||
);
|
||||
readonly formStatePlaceholder = computed(() =>
|
||||
this.cityForm.controls.countryId.value ? 'Search state' : 'Select a country first'
|
||||
this.cityForm.controls.countryId.value ? 'Search' : 'Search'
|
||||
);
|
||||
readonly canAddCity = computed(() => !!this.selectedStateId());
|
||||
readonly emptyMessage = computed(() =>
|
||||
this.selectedCountryId() && this.selectedStateId()
|
||||
? 'No cities found'
|
||||
@@ -179,10 +187,10 @@ export class CityList {
|
||||
return mode === 'create' ? 'Add City' : mode === 'edit' ? 'Edit City' : 'View City';
|
||||
});
|
||||
readonly submitLabel = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Save City' : 'Update City'
|
||||
this.modalMode() === 'create' ? 'Save' : 'Update'
|
||||
);
|
||||
readonly loadingLabel = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Saving City...' : 'Updating City...'
|
||||
this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<CityTableRow>[]>([
|
||||
@@ -288,11 +296,7 @@ export class CityList {
|
||||
const city = this.toCityDto(event.row);
|
||||
switch (event.action.type) {
|
||||
case 'edit':
|
||||
this.openExistingCity(city, 'edit', {
|
||||
id: event.row.stateId,
|
||||
name: event.row.stateName,
|
||||
code: ''
|
||||
});
|
||||
this.openExistingCity(city, 'edit');
|
||||
break;
|
||||
case 'activate':
|
||||
this.updateCityStatus(city, true);
|
||||
@@ -304,21 +308,14 @@ export class CityList {
|
||||
}
|
||||
|
||||
onAddCity(): void {
|
||||
const countryId = this.selectedCountryId();
|
||||
const stateId = this.selectedStateId();
|
||||
if (!countryId || !stateId) {
|
||||
this.toastr.error('Select a country and state before adding a city.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalMode.set('create');
|
||||
this.selectedCity.set(null);
|
||||
this.submitAttempted.set(false);
|
||||
this.selectedFormCountry.set(this.selectedCountry());
|
||||
this.selectedFormState.set(this.selectedFilterState());
|
||||
this.selectedFormCountry.set(null);
|
||||
this.selectedFormState.set(null);
|
||||
this.cityForm.enable({ emitEvent: false });
|
||||
this.cityForm.reset({ countryId, stateId, name: '', code: '', timezoneId: null }, { emitEvent: false });
|
||||
this.cityForm.controls.stateId.enable({ emitEvent: false });
|
||||
this.cityForm.reset({ countryId: '', stateId: '', name: '', code: '', timezoneId: null }, { emitEvent: false });
|
||||
this.cityForm.controls.stateId.disable({ emitEvent: false });
|
||||
this.resetFormState();
|
||||
this.showCityModal.set(true);
|
||||
}
|
||||
@@ -456,21 +453,21 @@ export class CityList {
|
||||
});
|
||||
}
|
||||
|
||||
private openExistingCity(city: CityDto, mode: 'edit', stateSeed?: StateLookupDto): void {
|
||||
private openExistingCity(city: CityDto, mode: 'edit'): void {
|
||||
this.cityApi.getCityById(city.id).pipe(
|
||||
switchMap(details => this.stateApi.getStateById(details.stateId).pipe(
|
||||
map(stateDetails => ({ details, countryId: stateDetails.countryId }))
|
||||
)),
|
||||
take(1)
|
||||
).subscribe(details => {
|
||||
).subscribe(({ details, countryId }) => {
|
||||
this.selectedCity.set(details);
|
||||
this.modalMode.set(mode);
|
||||
this.submitAttempted.set(false);
|
||||
this.selectedFormCountry.set(this.selectedCountry());
|
||||
this.selectedFormState.set(
|
||||
stateSeed
|
||||
?? (this.selectedFilterState()?.id === details.stateId ? this.selectedFilterState() : null)
|
||||
);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.selectedFormState.set(null);
|
||||
this.cityForm.enable({ emitEvent: false });
|
||||
this.cityForm.reset({
|
||||
countryId: this.selectedCountryId() ?? '',
|
||||
countryId,
|
||||
stateId: details.stateId,
|
||||
name: details.name ?? '',
|
||||
code: details.code ?? '',
|
||||
|
||||
@@ -135,14 +135,14 @@ export class CountryList {
|
||||
|
||||
readonly countrySubmitLabel = computed(() =>
|
||||
this.countryModalMode() === 'create'
|
||||
? 'Save Country'
|
||||
: 'Update Country'
|
||||
? 'Save'
|
||||
: 'Update'
|
||||
);
|
||||
|
||||
readonly countryLoadingLabel = computed(() =>
|
||||
this.countryModalMode() === 'create'
|
||||
? 'Saving Country...'
|
||||
: 'Updating Country...'
|
||||
? 'Saving...'
|
||||
: 'Updating...'
|
||||
);
|
||||
|
||||
readonly countrySubmitAction = computed<'save' | 'update'>(() =>
|
||||
@@ -157,6 +157,7 @@ export class CountryList {
|
||||
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
|
||||
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
|
||||
{ key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true },
|
||||
{ key: 'currencyName', label: 'Default Currency', header: 'Default Currency', sortable: true, align: 'left' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
|
||||
@@ -20,18 +20,17 @@
|
||||
(actionClicked)="onActionClick($event)"
|
||||
>
|
||||
<ng-template appDataTableCell="name" let-row let-value="value">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex min-w-8 justify-center rounded-sm bg-light px-2 py-1 text-[0.75rem] font-semibold text-primary dark:bg-black/20"
|
||||
>
|
||||
{{ row.symbol }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (getFlagUrl(row.iso2); as flagUrl) {
|
||||
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
|
||||
(error)="onFlagError($event)" />
|
||||
}
|
||||
|
||||
<span class="font-semibold">
|
||||
{{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<span class="font-semibold">
|
||||
{{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template appDataTableCell="code" let-value="value">
|
||||
<span class="badge bg-primary/10 text-primary">
|
||||
@@ -75,6 +74,7 @@
|
||||
required: 'Currency Name is required.',
|
||||
maxlength: 'Currency Name cannot exceed 100 characters.'
|
||||
}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
inputId="currency-code"
|
||||
label="Currency Code"
|
||||
placeholder="e.g.: AED"
|
||||
inputClass="uppercase"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[minLength]="3"
|
||||
@@ -95,6 +94,7 @@
|
||||
maxlength: 'Currency Code must contain exactly 3 letters.',
|
||||
pattern: 'Currency Code can contain letters only.'
|
||||
}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
required: 'Currency Symbol is required.',
|
||||
maxlength: 'Currency Symbol cannot exceed 8 characters.'
|
||||
}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +124,7 @@
|
||||
inputMode="numeric"
|
||||
placeholder="e.g.: 784"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[required]="false"
|
||||
[min]="1"
|
||||
[max]="999"
|
||||
[step]="1"
|
||||
@@ -132,6 +133,7 @@
|
||||
min: 'Numeric Code must be at least 1.',
|
||||
max: 'Numeric Code cannot exceed 999.'
|
||||
}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,6 +155,7 @@
|
||||
min: 'Decimal Digits cannot be less than 0.',
|
||||
max: 'Decimal Digits cannot exceed 4.'
|
||||
}"
|
||||
[submitAttempted]="currencySubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/c
|
||||
interface CurrencyTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
iso2: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
numericCode: number;
|
||||
@@ -124,14 +125,14 @@ export class CurrencyList {
|
||||
|
||||
readonly currencySubmitLabel = computed(() =>
|
||||
this.currencyModalMode() === 'create'
|
||||
? 'Save Currency'
|
||||
: 'Update Currency'
|
||||
? 'Save'
|
||||
: 'Update'
|
||||
);
|
||||
|
||||
readonly currencyLoadingLabel = computed(() =>
|
||||
this.currencyModalMode() === 'create'
|
||||
? 'Saving Currency...'
|
||||
: 'Updating Currency...'
|
||||
? 'Saving...'
|
||||
: 'Updating...'
|
||||
);
|
||||
|
||||
readonly currencySubmitAction = computed<'save' | 'update'>(() =>
|
||||
@@ -143,6 +144,7 @@ export class CurrencyList {
|
||||
readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
||||
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
|
||||
{ key: 'iso2', label: 'Iso2 Code', header: 'Iso2 Code', sortable: true },
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
||||
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: true },
|
||||
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true },
|
||||
@@ -495,6 +497,7 @@ export class CurrencyList {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
iso2: row.iso2?.toLowerCase() ?? '',
|
||||
name: row.name,
|
||||
symbol: row.symbol,
|
||||
numericCode: row.numericCode,
|
||||
@@ -504,4 +507,16 @@ export class CurrencyList {
|
||||
modifiedOn: row.modifiedOn
|
||||
};
|
||||
}
|
||||
|
||||
getFlagUrl(iso2: string | null | undefined): string {
|
||||
const code = iso2?.trim().toLowerCase();
|
||||
|
||||
return code && /^[a-z]{2}$/.test(code)
|
||||
? `https://flagcdn.com/24x18/${code}.png`
|
||||
: '';
|
||||
}
|
||||
onFlagError(event: Event): void {
|
||||
const image = event.target as HTMLImageElement;
|
||||
image.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,10 @@ export class LanguageList {
|
||||
this.modalMode() === 'create' ? 'Add Language' : 'Edit Language'
|
||||
);
|
||||
readonly submitLabel = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Save Language' : 'Update Language'
|
||||
this.modalMode() === 'create' ? 'Save' : 'Update'
|
||||
);
|
||||
readonly loadingLabel = computed(() =>
|
||||
this.modalMode() === 'create' ? 'Saving Language...' : 'Updating Language...'
|
||||
this.modalMode() === 'create' ? 'Saving...' : 'Updating...'
|
||||
);
|
||||
readonly submitAction = computed<'save' | 'update'>(() =>
|
||||
this.modalMode() === 'create' ? 'save' : 'update'
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
<!-- Start::row-1 -->
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
<div class="box custom-box">
|
||||
<div class="box-body p-4">
|
||||
<app-filter-card title="Country Selection" bodyClass="p-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex flex-wrap gap-1 newproject">
|
||||
<div class="box-title mb-0">Country Selection</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="countryFilterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="state-country-filter"
|
||||
label="Country"
|
||||
placeholder="Search country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
@@ -31,16 +25,14 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-filter-card>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End::row-1 -->
|
||||
|
||||
<app-data-table [columns]="columns()" [rows]="states()" [actions]="actions()"
|
||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="States" buttonTitle="Add" [showSearch]="true"
|
||||
[showAddButton]="showStateAddButton()" searchPlaceholder="Search states..." [searchDebounceTime]="300"
|
||||
[showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
|
||||
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||
(addClicked)="onAddState()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
||||
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add State" />
|
||||
@@ -59,6 +51,32 @@
|
||||
(closed)="closeStateModal()" (submitted)="saveState()">
|
||||
<form [formGroup]="stateForm" (ngSubmit)="saveState()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="state-country"
|
||||
label="Country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[resolveValueFn]="resolveCountry"
|
||||
[selectedItem]="selectedFormCountry()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="stateModalMode() === 'create'"
|
||||
[required]="true"
|
||||
[clearable]="true"
|
||||
[readonly]="stateModalMode() !== 'create'"
|
||||
[validationMessages]="{ required: 'Country is required.' }"
|
||||
[submitAttempted]="stateSubmitAttempted()"
|
||||
wrapperClass="w-full"
|
||||
(itemSelected)="onFormCountrySelected($event)"
|
||||
(cleared)="onFormCountryCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input formControlName="name" inputId="state-name" label="State Name" placeholder="Name"
|
||||
autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, catchError, distinctUntilChanged, finalize, of, switchMap } from 'rxjs';
|
||||
import { Subject, catchError, distinctUntilChanged, finalize, map, of, switchMap } from 'rxjs';
|
||||
|
||||
import {
|
||||
CountryLookupDto
|
||||
@@ -33,12 +33,14 @@ import { DataTable } from '../../../../../shared/components/data-table/data-tabl
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteResolveValueFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||
|
||||
interface StateTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
@@ -54,12 +56,12 @@ interface StateTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'state-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog],
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard],
|
||||
templateUrl: './state-list.html',
|
||||
styleUrl: './state-list.scss',
|
||||
})
|
||||
export class StateList {
|
||||
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly stateApi = inject(StateService);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
@@ -73,6 +75,7 @@ export class StateList {
|
||||
|
||||
readonly states = signal<StateTableRow[]>([]);
|
||||
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedCountryId = signal<string | null>(null);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly filteredRecords = signal(0);
|
||||
@@ -94,6 +97,10 @@ export class StateList {
|
||||
(term, limit) => this.countryApi.autocomplete(term, limit);
|
||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||
value => this.countryApi.getCountryById(value).pipe(
|
||||
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||
);
|
||||
|
||||
readonly stateForm = this.formBuilder.nonNullable.group({
|
||||
countryId: [
|
||||
@@ -119,10 +126,6 @@ export class StateList {
|
||||
]
|
||||
});
|
||||
|
||||
readonly showStateAddButton = computed(() =>
|
||||
!!this.selectedCountryId()
|
||||
);
|
||||
|
||||
readonly emptyMessage = computed(() =>
|
||||
this.selectedCountryId()
|
||||
? 'No states found'
|
||||
@@ -143,14 +146,14 @@ export class StateList {
|
||||
|
||||
readonly stateSubmitLabel = computed(() =>
|
||||
this.stateModalMode() === 'create'
|
||||
? 'Save State'
|
||||
: 'Update State'
|
||||
? 'Save'
|
||||
: 'Update'
|
||||
);
|
||||
|
||||
readonly stateLoadingLabel = computed(() =>
|
||||
this.stateModalMode() === 'create'
|
||||
? 'Saving State...'
|
||||
: 'Updating State...'
|
||||
? 'Saving...'
|
||||
: 'Updating...'
|
||||
);
|
||||
|
||||
readonly stateSubmitAction = computed<'save' | 'update'>(() =>
|
||||
@@ -161,7 +164,7 @@ export class StateList {
|
||||
|
||||
readonly columns = signal<DataTableColumn<StateTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '60px' },
|
||||
{ key: 'name', header: 'Name', label: 'Name', sortable: true ,align: 'left'},
|
||||
{ key: 'name', header: 'Name', label: 'Name', sortable: true, align: 'left' },
|
||||
{ key: 'code', header: 'Code', label: 'Code', sortable: true },
|
||||
{
|
||||
key: 'isActive',
|
||||
@@ -269,6 +272,14 @@ export class StateList {
|
||||
this.selectedCountryLookup.set(country);
|
||||
}
|
||||
|
||||
onFormCountrySelected(country: CountryLookupDto): void {
|
||||
this.selectedFormCountry.set(country);
|
||||
}
|
||||
|
||||
onFormCountryCleared(): void {
|
||||
this.selectedFormCountry.set(null);
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
const query = this.queryState.setSearch(value.trim());
|
||||
this.loadStates(query);
|
||||
@@ -336,20 +347,14 @@ export class StateList {
|
||||
}
|
||||
|
||||
onAddState(): void {
|
||||
const countryId = this.selectedCountryId();
|
||||
|
||||
if (!countryId) {
|
||||
this.toastr.error('Select a country before adding a state.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateModalMode.set('create');
|
||||
this.selectedStateId.set(null);
|
||||
this.selectedState.set(null);
|
||||
this.stateSubmitAttempted.set(false);
|
||||
|
||||
this.selectedFormCountry.set(null);
|
||||
this.stateForm.reset({
|
||||
countryId,
|
||||
countryId: '',
|
||||
name: '',
|
||||
code: ''
|
||||
});
|
||||
@@ -366,6 +371,7 @@ export class StateList {
|
||||
this.showStateModal.set(false);
|
||||
this.selectedStateId.set(null);
|
||||
this.selectedState.set(null);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.stateSubmitAttempted.set(false);
|
||||
}
|
||||
|
||||
@@ -486,8 +492,9 @@ export class StateList {
|
||||
.subscribe({
|
||||
next: stateDetails => {
|
||||
this.selectedState.set(stateDetails);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.stateForm.reset({
|
||||
countryId: stateDetails.countryId ?? this.selectedCountryId() ?? '',
|
||||
countryId: stateDetails.countryId ?? '',
|
||||
name: stateDetails.name ?? '',
|
||||
code: stateDetails.code ?? ''
|
||||
});
|
||||
@@ -566,6 +573,7 @@ export class StateList {
|
||||
this.showStateModal.set(false);
|
||||
this.selectedStateId.set(null);
|
||||
this.selectedState.set(null);
|
||||
this.selectedFormCountry.set(null);
|
||||
this.stateSubmitAttempted.set(false);
|
||||
this.loadStates(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
formControlName="ianaId"
|
||||
inputId="timezone-iana-id"
|
||||
label="IANA Timezone ID"
|
||||
placeholder="e.g.: Asia/Kolkata"
|
||||
placeholder="Id"
|
||||
help="e.g.: Asia/Kolkata"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
@@ -80,7 +81,8 @@
|
||||
formControlName="displayName"
|
||||
inputId="timezone-display-name"
|
||||
label="Display Name"
|
||||
placeholder="e.g.: India Standard Time"
|
||||
placeholder="Name"
|
||||
help="e.g.: India Standard Time"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
@@ -100,13 +102,13 @@
|
||||
label="UTC Offset (minutes)"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="For example: 330"
|
||||
placeholder="Minutes"
|
||||
help="Enter an offset from -720 (-12:00) to 840 (+14:00)."
|
||||
[required]="true"
|
||||
[readonly]="isViewMode()"
|
||||
[min]="-720"
|
||||
[max]="840"
|
||||
[step]="1"
|
||||
hint="e.g.: Enter an offset from -720 (-12:00) to 840 (+14:00)."
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'UTC offset is required.',
|
||||
|
||||
@@ -86,8 +86,8 @@ export class TimezoneList implements OnInit {
|
||||
case 'view': return 'View Timezone';
|
||||
}
|
||||
});
|
||||
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save Timezone' : 'Update Timezone');
|
||||
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving Timezone...' : 'Updating Timezone...');
|
||||
readonly submitLabel = computed(() => this.modalMode() === 'create' ? 'Save' : 'Update');
|
||||
readonly loadingLabel = computed(() => this.modalMode() === 'create' ? 'Saving...' : 'Updating...');
|
||||
readonly submitAction = computed<'save' | 'update'>(() => this.modalMode() === 'create' ? 'save' : 'update');
|
||||
|
||||
readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
<app-filter-card title="Tenant Selection" bodyClass="p-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<form [formGroup]="tenantCurrencyFilterForm" autocomplete="off" class="grid w-full grid-cols-12 gap-4">
|
||||
<div class="col-span-12 md:col-span-6 lg:col-span-4 xl:col-span-3">
|
||||
<app-autocomplete
|
||||
formControlName="tenantId"
|
||||
inputId="tenant-filter"
|
||||
label="Tenant"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchTenants"
|
||||
[displayWith]="displayTenant"
|
||||
[valueWith]="tenantValue"
|
||||
[selectedItem]="selectedTenantLookup()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideLabel]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onTenantLookupSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</app-filter-card>
|
||||
</div>
|
||||
</div>
|
||||
<app-data-table
|
||||
[columns]="columns()"
|
||||
[rows]="tenantCurrencies()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="totalRecords()"
|
||||
[pageIndex]="queryState.pageIndex()"
|
||||
[pageSize]="queryState.pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]"
|
||||
tableTitle="Tenant Management"
|
||||
buttonTitle="Add Tenant"
|
||||
[showSearch]="true"
|
||||
[showAddButton]="true"
|
||||
searchPlaceholder="Search tenants..."
|
||||
[searchDebounceTime]="300"
|
||||
actionHeaderClass="!text-center"
|
||||
actionCellClass="!text-center"
|
||||
emptyMessage="No tenants found"
|
||||
emptyDescription="There is currently no data to display."
|
||||
(addClicked)="onAddTenantCurrency()"
|
||||
(searchChanged)="onSearch($event)"
|
||||
(pageChanged)="onPageChange($event)"
|
||||
(sortChanged)="onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
/>
|
||||
@@ -0,0 +1,410 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs';
|
||||
import { TenantCurrenciesService } from '../../../../core/services/tenant/tenant-currencies.service';
|
||||
import { LanguageService } from '../../../../core/services/language/language.service';
|
||||
import { CurrencyService } from '../../../../core/services/currency/currency.service';
|
||||
import { TimezoneService } from '../../../../core/services/timezone/timezone.service';
|
||||
import { DataTable } from '../../../../shared/components/data-table/data-table';
|
||||
import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
|
||||
import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state';
|
||||
|
||||
import { CreateTenantCurrencyRequest, TenantCurrencyDto, TenantCurrencyModalMode, TenantCurrencyTableRow, UpdateTenantCurrencyRequest } from '../../../../core/models/tenant/tenant-currencies.model';
|
||||
import { LanguageLookupDto } from '../../../../core/models/language/language.model';
|
||||
import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { CurrencyLookupDto } from '../../../../core/models/currency/currency.model';
|
||||
import { TenantLookupDto } from '../../../../core/models/tenant/tenant.model';
|
||||
import { TenantService } from '../../../../core/services/tenant/tenant.service';
|
||||
import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import { FormSelect } from '../../../../shared/components/form/form-select/form-select';
|
||||
import { FormInput } from '../../../../shared/components/form/form-input/form-input';
|
||||
import { Modal } from '../../../../shared/components/modal/modal';
|
||||
import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { FilterCard } from '../../../../shared/components/filter-card/filter-card';
|
||||
|
||||
@Component({
|
||||
selector: 'tenant-currencies',
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete, ConfirmDialog, FilterCard],
|
||||
templateUrl: './tenant-currencies.html',
|
||||
styleUrl: './tenant-currencies.scss',
|
||||
})
|
||||
export class TenantCurrencies {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly tenantCurrencyApi = inject(TenantCurrenciesService);
|
||||
private readonly tenantApi = inject(TenantService);
|
||||
private readonly languageApi = inject(LanguageService);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly queryRequests$ = new Subject<{ tenantId: string; query: DataTableQuery; }>();
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
readonly tenantCurrencies = signal<TenantCurrencyTableRow[]>([]);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly filteredRecords = signal(0);
|
||||
|
||||
readonly saving = signal(false);
|
||||
readonly showTenantCurrencyModal = signal(false);
|
||||
readonly tenantCurrencyModalMode = signal<TenantCurrencyModalMode>('create');
|
||||
readonly selectedTenantId = signal<string | null>(null);
|
||||
readonly selectedTenantLookup = signal<TenantLookupDto | null>(null);
|
||||
readonly tenantSubmitAttempted = signal(false);
|
||||
|
||||
|
||||
|
||||
readonly tenantCurrencyForm = this.formBuilder.group({
|
||||
tenantId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
currencyId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
isActive: this.formBuilder.control<number | null>(1, [Validators.required]),
|
||||
isBaseCurrency: this.formBuilder.control<boolean>(false, [Validators.required]),
|
||||
isReporting: this.formBuilder.control<boolean>(false, [Validators.required]),
|
||||
});
|
||||
readonly tenantCurrencyFilterForm = this.formBuilder.nonNullable.group({
|
||||
tenantId: ['']
|
||||
});
|
||||
|
||||
readonly searchTenants:
|
||||
AutocompleteSearchFn<TenantLookupDto> =
|
||||
(term, limit) =>
|
||||
this.tenantApi.autocomplete(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error(
|
||||
'Unable to load tenants.'
|
||||
);
|
||||
|
||||
return of<
|
||||
readonly TenantLookupDto[]
|
||||
>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly displayTenant:
|
||||
AutocompleteDisplayFn<TenantLookupDto> =
|
||||
tenant =>
|
||||
[tenant.code, tenant.name]
|
||||
.filter(Boolean)
|
||||
.join(' - ');
|
||||
|
||||
readonly tenantValue:
|
||||
AutocompleteValueFn<
|
||||
TenantLookupDto,
|
||||
string
|
||||
> =
|
||||
tenant => tenant.id;
|
||||
|
||||
readonly resolveTenant:
|
||||
AutocompleteResolveValueFn<
|
||||
TenantLookupDto,
|
||||
string
|
||||
> =
|
||||
tenantId =>
|
||||
this.tenantApi
|
||||
.getTenantById(tenantId)
|
||||
.pipe(
|
||||
map(tenant => ({
|
||||
id: tenant.id,
|
||||
code: tenant.code,
|
||||
name: tenant.name
|
||||
}))
|
||||
);
|
||||
|
||||
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
|
||||
this.currencyApi.autocomplete(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load currencies.');
|
||||
return of<readonly CurrencyLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
|
||||
|
||||
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = value =>
|
||||
this.currencyApi.getCurrencyById(value).pipe(
|
||||
map(currency => ({
|
||||
id: currency.id,
|
||||
code: currency.code,
|
||||
name: currency.name,
|
||||
symbol: currency.symbol
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
readonly modalTitle = computed(() =>
|
||||
this.tenantCurrencyModalMode() === 'create' ? 'Add Tenant' : 'Edit Tenant'
|
||||
);
|
||||
|
||||
readonly submitLabel = computed(() =>
|
||||
this.tenantCurrencyModalMode() === 'create' ? 'Save' : 'Update'
|
||||
);
|
||||
|
||||
readonly loadingLabel = computed(() =>
|
||||
this.tenantCurrencyModalMode() === 'create' ? 'Saving...' : 'Updating...'
|
||||
);
|
||||
|
||||
readonly submitAction = computed<'save' | 'update'>(() =>
|
||||
this.tenantCurrencyModalMode() === 'create' ? 'save' : 'update'
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<TenantCurrencyTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true, align: 'left' },
|
||||
{ key: 'name', label: 'Tenant Name', header: 'Tenant Name', sortable: true, align: 'left' },
|
||||
{
|
||||
key: 'isBaseCurrency',
|
||||
label: 'Base Currency',
|
||||
header: 'Base Currency',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
formatter: value => value === true ? 'Yes' : 'No'
|
||||
},
|
||||
{
|
||||
key: 'isReporting',
|
||||
label: 'Reporting Currency',
|
||||
header: 'Reporting Currency',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
formatter: value => value === true ? 'Yes' : 'No'
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Active',
|
||||
header: 'Active',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
badgeClass: value => value === true
|
||||
? 'badge bg-success/10 text-success'
|
||||
: 'badge bg-danger/10 text-danger',
|
||||
formatter: value => value === true ? 'Active' : 'Inactive'
|
||||
},
|
||||
{
|
||||
key: 'createdOn',
|
||||
label: 'Created On',
|
||||
header: 'Created On',
|
||||
sortable: true,
|
||||
formatter: value => this.formatDateTime(value)
|
||||
}
|
||||
]);
|
||||
|
||||
readonly actions = signal<DataTableAction<TenantCurrencyTableRow>[]>([
|
||||
{
|
||||
type: 'edit',
|
||||
label: 'Edit',
|
||||
icon: 'ti ti-edit',
|
||||
className: 'text-primary'
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ti ti-trash',
|
||||
className: 'text-danger',
|
||||
visible: row => row.isActive
|
||||
},
|
||||
{
|
||||
type: 'activate',
|
||||
label: 'Activate',
|
||||
icon: 'ti ti-check',
|
||||
className: 'text-success',
|
||||
visible: row => !row.isActive
|
||||
}
|
||||
]);
|
||||
|
||||
private formatDateTime(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.queryRequests$
|
||||
.pipe(
|
||||
switchMap(({ tenantId, query }) =>
|
||||
this.tenantCurrencyApi
|
||||
.getTenantDataTable(this.buildTenantCurrencyQuery(query))
|
||||
.pipe(
|
||||
map(response => ({
|
||||
response,
|
||||
tenantId,
|
||||
query
|
||||
})),
|
||||
catchError(() => {
|
||||
this.toastr.error(
|
||||
'Unable to load tenant currencies.'
|
||||
);
|
||||
|
||||
this.clearTenantCurrencyGrid();
|
||||
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(result => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
response,
|
||||
tenantId,
|
||||
query
|
||||
} = result;
|
||||
|
||||
if (this.selectedTenantId() !== tenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.draw !== query.draw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows: TenantCurrencyTableRow[] =
|
||||
response.rows.map(
|
||||
(tenantCurrency, index) => ({
|
||||
...tenantCurrency,
|
||||
serialNumber:
|
||||
(query.page - 1) *
|
||||
query.pageSize +
|
||||
index +
|
||||
1
|
||||
})
|
||||
);
|
||||
|
||||
this.tenantCurrencies.set(rows);
|
||||
this.totalRecords.set(response.total);
|
||||
this.filteredRecords.set(
|
||||
response.filtered
|
||||
);
|
||||
});
|
||||
}
|
||||
ngOnInit(): void {
|
||||
this.clearTenantCurrencyGrid();
|
||||
}
|
||||
|
||||
loadTenantCurrencies(query: DataTableQuery): void {
|
||||
const tenantId = this.selectedTenantId();
|
||||
|
||||
if (!tenantId) {
|
||||
this.clearTenantCurrencyGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
this.queryRequests$.next({
|
||||
tenantId,
|
||||
query
|
||||
});
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
this.loadTenantCurrencies(this.queryState.setSearch(value.trim()));
|
||||
}
|
||||
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
this.loadTenantCurrencies(this.queryState.setPage(event));
|
||||
}
|
||||
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
this.loadTenantCurrencies(this.queryState.setSort(event));
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<TenantCurrencyTableRow>): void {
|
||||
if (event.action.type === 'edit') {
|
||||
this.openEditTenantCurrency(event.row.id);
|
||||
}
|
||||
}
|
||||
private buildTenantCurrencyQuery(query: DataTableQuery): DataTableQuery {
|
||||
return {
|
||||
...query,
|
||||
sortBy: this.resolveSortField(query.sortBy ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSortField(sortBy: string | null | undefined): string | null {
|
||||
if (!sortBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (sortBy) {
|
||||
case 'code':
|
||||
case 'name':
|
||||
case 'status':
|
||||
case 'dataRegion':
|
||||
case 'isActive':
|
||||
case 'createdOn':
|
||||
return sortBy;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
onTenantLookupSelected(tenant: TenantLookupDto | null): void {
|
||||
this.selectedTenantLookup.set(tenant);
|
||||
|
||||
const tenantId = tenant?.id ?? null;
|
||||
|
||||
this.tenantCurrencyFilterForm.controls.tenantId
|
||||
.setValue(tenantId ?? '');
|
||||
|
||||
this.onTenantSelected(tenantId);
|
||||
}
|
||||
|
||||
onAddTenantCurrency(): void {
|
||||
this.tenantCurrencyModalMode.set('create');
|
||||
|
||||
this.showTenantCurrencyModal.set(true);
|
||||
}
|
||||
|
||||
closeTenantCurrencyModal(): void {
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showTenantCurrencyModal.set(false);
|
||||
}
|
||||
|
||||
private openEditTenantCurrency(id: string): void {
|
||||
this.tenantCurrencyModalMode.set('edit');
|
||||
|
||||
}
|
||||
|
||||
private onTenantSelected(tenantId: string | null): void {
|
||||
this.selectedTenantId.set(tenantId);
|
||||
|
||||
|
||||
this.clearTenantCurrencyGrid();
|
||||
|
||||
const query = this.queryState.reset();
|
||||
|
||||
if (!tenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadTenantCurrencies(query);
|
||||
}
|
||||
|
||||
|
||||
private clearTenantCurrencyGrid(): void {
|
||||
this.tenantCurrencies.set([]);
|
||||
this.totalRecords.set(0);
|
||||
this.filteredRecords.set(0);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,62 +1,185 @@
|
||||
<app-data-table
|
||||
title="Tenant List"
|
||||
|
||||
[columns]="columns"
|
||||
[rows]="tenants"
|
||||
[actions]="tableActions"
|
||||
|
||||
[loading]="loading"
|
||||
emptyMessage="No tenants found"
|
||||
|
||||
[columns]="columns()"
|
||||
[rows]="tenants()"
|
||||
[actions]="actions()"
|
||||
[totalRecords]="totalRecords()"
|
||||
[pageIndex]="queryState.pageIndex()"
|
||||
[pageSize]="queryState.pageSize()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]"
|
||||
tableTitle="Tenant Management"
|
||||
buttonTitle="Add Tenant"
|
||||
[showSearch]="true"
|
||||
[showAddButton]="true"
|
||||
searchPlaceholder="Search tenants..."
|
||||
[searchDebounceTime]="300"
|
||||
|
||||
[totalRecords]="totalRecords"
|
||||
[pageIndex]="pageIndex"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]"
|
||||
[showPaginator]="true"
|
||||
|
||||
[allowedPermissions]="allowedPermissions"
|
||||
|
||||
tableHeadClass=""
|
||||
tableBodyClass=""
|
||||
trHeadClass="border-b border-defaultborder"
|
||||
trBodyClass="border-b border-defaultborder hover:bg-light cursor-pointer"
|
||||
|
||||
defaultThClass="text-start"
|
||||
defaultTdClass=""
|
||||
actionHeaderClass="text-center"
|
||||
actionCellClass="text-center"
|
||||
|
||||
actionHeaderClass="!text-center"
|
||||
actionCellClass="!text-center"
|
||||
emptyMessage="No tenants found"
|
||||
emptyDescription="There is currently no data to display."
|
||||
(addClicked)="onAddTenant()"
|
||||
(searchChanged)="onSearch($event)"
|
||||
(pageChanged)="onPageChange($event)"
|
||||
(sortChanged)="onSortChange($event)"
|
||||
(actionClicked)="onTableAction($event)"
|
||||
(rowClicked)="onRowClick($event)"
|
||||
(actionClicked)="onActionClick($event)"
|
||||
/>
|
||||
|
||||
<modal
|
||||
[open]="showTenantModal()"
|
||||
[title]="modalTitle()"
|
||||
size="lg"
|
||||
[submitAction]="submitAction()"
|
||||
[submitLabel]="submitLabel()"
|
||||
[loadingLabel]="loadingLabel()"
|
||||
[loading]="saving()"
|
||||
(closed)="closeTenantModal()"
|
||||
(submitted)="saveTenant()"
|
||||
>
|
||||
<form [formGroup]="tenantForm" (ngSubmit)="saveTenant()" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="code"
|
||||
inputId="tenant-code"
|
||||
label="Tenant Code"
|
||||
placeholder="Code"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="50"
|
||||
[validationMessages]="{
|
||||
required: 'Tenant Code is required.',
|
||||
maxlength: 'Tenant Code cannot exceed 50 characters.'
|
||||
}"
|
||||
[submitAttempted]="tenantSubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ng-template appDataTableCell="tenantName" let-row let-value="value">
|
||||
<div class="flex items-center">
|
||||
<span class="avatar avatar-xs me-2 avatar-rounded">
|
||||
<img [src]="row.logo" [alt]="value">
|
||||
</span>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="name"
|
||||
inputId="tenant-name"
|
||||
label="Tenant Name"
|
||||
placeholder="Name"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="150"
|
||||
[validationMessages]="{
|
||||
required: 'Tenant Name is required.',
|
||||
maxlength: 'Tenant Name cannot exceed 150 characters.'
|
||||
}"
|
||||
[submitAttempted]="tenantSubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="font-semibold">
|
||||
{{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-select
|
||||
formControlName="status"
|
||||
inputId="tenant-status"
|
||||
label="Tenant Status"
|
||||
placeholder="Select"
|
||||
[options]="tenantStatusOptions()"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[submitAttempted]="tenantSubmitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Tenant Status is required.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</app-data-table>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="defaultLanguageId"
|
||||
inputId="tenant-default-language-id"
|
||||
label="Default Language"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchLanguages"
|
||||
[displayWith]="displayLanguage"
|
||||
[valueWith]="languageValue"
|
||||
[resolveValueFn]="resolveLanguage"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="10"
|
||||
[clearable]="true"
|
||||
emptyText="No languages found"
|
||||
typeToSearchText="Type to search languages"
|
||||
[required]="true"
|
||||
[submitAttempted]="tenantSubmitAttempted()"
|
||||
wrapperClass="w-full"
|
||||
[validationMessages]="{
|
||||
required: 'Default Language is required.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<app-confirm-dialog
|
||||
title="Delete Tenant"
|
||||
text="Do you really want to delete this tenant?"
|
||||
confirmButtonText="Delete"
|
||||
cancelButtonText="Cancel"
|
||||
(confirmed)="onDeleteConfirmed()"
|
||||
(cancelled)="onDeleteCancelled()"
|
||||
></app-confirm-dialog>
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="defaultCurrencyId"
|
||||
inputId="tenant-default-currency-id"
|
||||
label="Default Currency"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCurrencies"
|
||||
[displayWith]="displayCurrency"
|
||||
[valueWith]="currencyValue"
|
||||
[resolveValueFn]="resolveCurrency"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="10"
|
||||
[clearable]="true"
|
||||
emptyText="No currencies found"
|
||||
typeToSearchText="Type to search currencies"
|
||||
[required]="true"
|
||||
[submitAttempted]="tenantSubmitAttempted()"
|
||||
wrapperClass="w-full"
|
||||
[validationMessages]="{
|
||||
required: 'Default Currency is required.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-autocomplete
|
||||
formControlName="defaultTimezoneId"
|
||||
inputId="tenant-default-timezone-id"
|
||||
label="Default Timezone"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchTimezones"
|
||||
[displayWith]="displayTimezone"
|
||||
[valueWith]="timezoneValue"
|
||||
[resolveValueFn]="resolveTimezone"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="10"
|
||||
[clearable]="true"
|
||||
emptyText="No timezones found"
|
||||
typeToSearchText="Type to search timezones"
|
||||
[required]="true"
|
||||
[submitAttempted]="tenantSubmitAttempted()"
|
||||
wrapperClass="w-full"
|
||||
[validationMessages]="{
|
||||
required: 'Default Timezone is required.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="dataRegion"
|
||||
inputId="tenant-data-region"
|
||||
label="Data Region"
|
||||
placeholder="Region"
|
||||
help="e.g.: IN"
|
||||
autocomplete="off"
|
||||
[required]="true"
|
||||
[maxLength]="100"
|
||||
[validationMessages]="{
|
||||
required: 'Data Region is required.',
|
||||
maxlength: 'Data Region cannot exceed 100 characters.'
|
||||
}"
|
||||
[submitAttempted]="tenantSubmitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modal>
|
||||
@@ -0,0 +1 @@
|
||||
/* Intentionally empty: tenant screen reuses the shared Ynex layout classes. */
|
||||
|
||||
@@ -1,195 +1,579 @@
|
||||
import { Component, signal, viewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject, catchError, finalize, map, of, switchMap } from 'rxjs';
|
||||
|
||||
import { CreateTenantRequest, TenantDto, TenantModalMode, TenantStatus, TenantTableRow, UpdateTenantRequest } from '../../../../core/models/tenant/tenant.model';
|
||||
import { CurrencyLookupDto } from '../../../../core/models/currency/currency.model';
|
||||
import { LanguageLookupDto } from '../../../../core/models/language/language.model';
|
||||
import { TimezoneLookupDto } from '../../../../core/models/timezone/timezone.model';
|
||||
import { CurrencyService } from '../../../../core/services/currency/currency.service';
|
||||
import { LanguageService } from '../../../../core/services/language/language.service';
|
||||
import { TenantService } from '../../../../core/services/tenant/tenant.service';
|
||||
import { TimezoneService } from '../../../../core/services/timezone/timezone.service';
|
||||
import { DataTable } from '../../../../shared/components/data-table/data-table';
|
||||
import { DataTableColumn, DataTableAction } from '../../../../shared/components/data-table/data-table.types';
|
||||
import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state';
|
||||
import { DataTablePageEvent, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
|
||||
import { DataTableCellDirective } from '../../../../shared/directives/data-table-cell.directive';
|
||||
import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableQuery, DataTableRecord, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
|
||||
import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import { AutocompleteDisplayFn, AutocompleteResolveValueFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormInput } from '../../../../shared/components/form/form-input/form-input';
|
||||
import { FormSelect } from '../../../../shared/components/form/form-select/form-select';
|
||||
import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models';
|
||||
import { Modal } from '../../../../shared/components/modal/modal';
|
||||
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'tenant-list',
|
||||
imports: [CommonModule, DataTable, DataTableCellDirective, ConfirmDialog],
|
||||
templateUrl: './tenant-list.html',
|
||||
styleUrl: './tenant-list.scss',
|
||||
selector: 'tenant-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete],
|
||||
templateUrl: './tenant-list.html',
|
||||
styleUrl: './tenant-list.scss'
|
||||
})
|
||||
export class TenantList {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly tenantApi = inject(TenantService);
|
||||
private readonly languageApi = inject(LanguageService);
|
||||
private readonly currencyApi = inject(CurrencyService);
|
||||
private readonly timezoneApi = inject(TimezoneService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly queryRequests$ = new Subject<DataTableQuery>();
|
||||
|
||||
tableQuery = new DataTableQueryState();
|
||||
readonly queryState = new DataTableQueryState();
|
||||
readonly tenants = signal<TenantTableRow[]>([]);
|
||||
readonly totalRecords = signal(0);
|
||||
readonly filteredRecords = signal(0);
|
||||
readonly saving = signal(false);
|
||||
readonly showTenantModal = signal(false);
|
||||
readonly tenantModalMode = signal<TenantModalMode>('create');
|
||||
readonly selectedTenantId = signal<string | null>(null);
|
||||
readonly selectedTenant = signal<TenantDto | null>(null);
|
||||
readonly tenantSubmitAttempted = signal(false);
|
||||
|
||||
loading = false;
|
||||
pageIndex = 1;
|
||||
pageSize = 10;
|
||||
totalRecords = 3;
|
||||
searchText = '';
|
||||
readonly pendingDeleteTenant = signal<{ id: number } | null>(null);
|
||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||
readonly tenantForm = this.formBuilder.group({
|
||||
code: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(50)]),
|
||||
name: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]),
|
||||
status: this.formBuilder.control<TenantStatus | null>(TenantStatus.Trial, [Validators.required]),
|
||||
defaultLanguageId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
defaultCurrencyId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
defaultTimezoneId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
dataRegion: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(100)]),
|
||||
isActive: this.formBuilder.control<number | null>(1, [Validators.required])
|
||||
});
|
||||
|
||||
allowedPermissions: string[] = [
|
||||
'tenant.view',
|
||||
'tenant.edit',
|
||||
'tenant.delete'
|
||||
];
|
||||
readonly tenantStatusOptions = signal<FormSelectOption<TenantStatus>[]>([
|
||||
{ value: TenantStatus.Trial, label: 'Trial' },
|
||||
{ value: TenantStatus.Active, label: 'Active' },
|
||||
{ value: TenantStatus.Suspended, label: 'Suspended' },
|
||||
{ value: TenantStatus.Cancelled, label: 'Cancelled' }
|
||||
]);
|
||||
|
||||
columns: DataTableColumn[] = [
|
||||
{ key: 'id', header: 'Id', label: 'Id', sortable: true, headerClass: 'text-center', cellClass: 'text-center' },
|
||||
{ key: 'tenantName', header: 'Tenant Name', label: 'Tenant Name', sortable: true },
|
||||
{ key: 'companyCode', header: 'Company Code', label: 'Company Code', sortable: true },
|
||||
{ key: 'email', header: 'Email', label: 'Email' },
|
||||
{ key: 'country', header: 'Country', label: 'Country', sortable: true },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
cellClass: 'text-center',
|
||||
badge: true,
|
||||
badgeClass: value =>
|
||||
value === 'Active'
|
||||
? 'badge bg-success/10 text-success'
|
||||
: 'badge bg-danger/10 text-danger'
|
||||
}
|
||||
];
|
||||
readonly activeStatusOptions = signal<FormSelectOption<number>[]>([
|
||||
{ value: 1, label: 'Active' },
|
||||
{ value: 0, label: 'Inactive' }
|
||||
]);
|
||||
|
||||
tenants = [
|
||||
{
|
||||
id: 1,
|
||||
tenantName: 'Syscom Corporation',
|
||||
companyCode: 'SYSCOM',
|
||||
email: 'admin@syscom.com',
|
||||
country: 'UAE',
|
||||
status: 'Active',
|
||||
logo: 'assets/images/brand-logos/erp-logo-icon.png'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tenantName: 'Biz360 Demo',
|
||||
companyCode: 'BIZ360',
|
||||
email: 'demo@biz360.com',
|
||||
country: 'India',
|
||||
status: 'Active',
|
||||
logo:'assets/images/brand-logos/erp-logo-icon.png'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tenantName: 'Test Tenant',
|
||||
companyCode: 'TEST',
|
||||
email: 'test@test.com',
|
||||
country: 'India',
|
||||
status: 'Inactive'
|
||||
}
|
||||
];
|
||||
readonly searchLanguages: AutocompleteSearchFn<LanguageLookupDto> = (term, limit) =>
|
||||
this.languageApi.autocomplete(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load languages.');
|
||||
return of<readonly LanguageLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
tenants1 = [
|
||||
{
|
||||
id: 1,
|
||||
tenantName: 'Syscom Corporation',
|
||||
companyCode: 'SYSCOM',
|
||||
email: 'admin@syscom.com',
|
||||
country: 'UAE',
|
||||
status: 'Active'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tenantName: 'Biz360 Demo',
|
||||
companyCode: 'BIZ360',
|
||||
email: 'demo@biz360.com',
|
||||
country: 'India',
|
||||
status: 'Active'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tenantName: 'Test Tenant',
|
||||
companyCode: 'TEST',
|
||||
email: 'test@test.com',
|
||||
country: 'India',
|
||||
status: 'Inactive'
|
||||
}
|
||||
];
|
||||
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
|
||||
this.currencyApi.autocomplete(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load currencies.');
|
||||
return of<readonly CurrencyLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
tableActions: DataTableAction[] = [
|
||||
{
|
||||
type: 'download',
|
||||
label: 'Download',
|
||||
icon: 'ri-download-2-line !mb-0',
|
||||
className: 'ti-btn ti-btn-sm ti-btn-success !rounded-full',
|
||||
permission: 'tenant.view'
|
||||
},
|
||||
{
|
||||
type: 'edit',
|
||||
label: 'Edit',
|
||||
icon: 'ri-edit-line !mb-0',
|
||||
className: 'ti-btn ti-btn-sm ti-btn-info !rounded-full',
|
||||
permission: 'tenant.edit'
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ri-delete-bin-line',
|
||||
className: 'ti-btn ti-btn-sm ti-btn-danger !rounded-full',
|
||||
permission: 'tenant.delete',
|
||||
//visible: row => row.status !== 'Active'
|
||||
}
|
||||
];
|
||||
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> = (term, limit) =>
|
||||
this.timezoneApi.autocomplete(term, limit).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load timezones.');
|
||||
return of<readonly TimezoneLookupDto[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
readonly displayLanguage: AutocompleteDisplayFn<LanguageLookupDto> = language => [language.code, language.name].filter(Boolean).join(' - ');
|
||||
|
||||
onSearch(searchText: string): void {
|
||||
this.searchText = searchText;
|
||||
this.pageIndex = 1;
|
||||
readonly languageValue: AutocompleteValueFn<LanguageLookupDto, string> = language => language.id;
|
||||
|
||||
if (searchText.trim() === '') {
|
||||
this.tenants = [...this.tenants1];
|
||||
} else {
|
||||
this.tenants = this.tenants.filter(tenant =>
|
||||
tenant.tenantName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
tenant.companyCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
tenant.email.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
tenant.country.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
tenant.status.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
readonly resolveLanguage: AutocompleteResolveValueFn<LanguageLookupDto, string> = value =>
|
||||
this.languageApi.getById(value).pipe(
|
||||
map(language => ({
|
||||
id: language.id,
|
||||
code: language.code,
|
||||
name: language.name,
|
||||
nativeName: language.nativeName,
|
||||
isRightToLeft: language.isRightToLeft
|
||||
}))
|
||||
);
|
||||
|
||||
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = currency => [currency.code, currency.name, currency.symbol ? `(${currency.symbol})` : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
|
||||
|
||||
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = value =>
|
||||
this.currencyApi.getCurrencyById(value).pipe(
|
||||
map(currency => ({
|
||||
id: currency.id,
|
||||
code: currency.code,
|
||||
name: currency.name,
|
||||
symbol: currency.symbol
|
||||
}))
|
||||
);
|
||||
|
||||
readonly displayTimezone: AutocompleteDisplayFn<TimezoneLookupDto> = timezone => [timezone.ianaId, timezone.displayName].filter(Boolean).join(' — ');
|
||||
|
||||
readonly timezoneValue: AutocompleteValueFn<TimezoneLookupDto, string> = timezone => timezone.id;
|
||||
|
||||
readonly resolveTimezone: AutocompleteResolveValueFn<TimezoneLookupDto, string> = value =>
|
||||
this.timezoneApi.getById(value).pipe(
|
||||
map(timezone => ({
|
||||
id: timezone.id,
|
||||
ianaId: timezone.ianaId,
|
||||
displayName: timezone.displayName
|
||||
}))
|
||||
);
|
||||
|
||||
readonly modalTitle = computed(() =>
|
||||
this.tenantModalMode() === 'create' ? 'Add Tenant' : 'Edit Tenant'
|
||||
);
|
||||
|
||||
readonly submitLabel = computed(() =>
|
||||
this.tenantModalMode() === 'create' ? 'Save' : 'Update'
|
||||
);
|
||||
|
||||
readonly loadingLabel = computed(() =>
|
||||
this.tenantModalMode() === 'create' ? 'Saving...' : 'Updating...'
|
||||
);
|
||||
|
||||
readonly submitAction = computed<'save' | 'update'>(() =>
|
||||
this.tenantModalMode() === 'create' ? 'save' : 'update'
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<TenantTableRow>[]>([
|
||||
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' },
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true, align: 'left' },
|
||||
{ key: 'name', label: 'Tenant Name', header: 'Tenant Name', sortable: true, align: 'left' },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
badgeClass: value => this.getTenantStatusBadgeClass(value as TenantStatus),
|
||||
formatter: value => this.formatTenantStatus(value as TenantStatus)
|
||||
},
|
||||
{ key: 'dataRegion', label: 'Data Region', header: 'Data Region', sortable: true, align: 'left' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Active',
|
||||
header: 'Active',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
badgeClass: value => value === true
|
||||
? 'badge bg-success/10 text-success'
|
||||
: 'badge bg-danger/10 text-danger',
|
||||
formatter: value => value === true ? 'Active' : 'Inactive'
|
||||
},
|
||||
{
|
||||
key: 'createdOn',
|
||||
label: 'Created On',
|
||||
header: 'Created On',
|
||||
sortable: true,
|
||||
formatter: value => this.formatDateTime(value)
|
||||
}
|
||||
]);
|
||||
|
||||
readonly actions = signal<DataTableAction<TenantTableRow>[]>([
|
||||
{
|
||||
type: 'edit',
|
||||
label: 'Edit',
|
||||
icon: 'ti ti-edit',
|
||||
className: 'text-primary'
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: 'ti ti-trash',
|
||||
className: 'text-danger',
|
||||
visible: row => row.isActive
|
||||
},
|
||||
{
|
||||
type: 'activate',
|
||||
label: 'Activate',
|
||||
icon: 'ti ti-check',
|
||||
className: 'text-success',
|
||||
visible: row => !row.isActive
|
||||
}
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.queryRequests$
|
||||
.pipe(
|
||||
switchMap(query =>
|
||||
this.tenantApi.getTenantDataTable(this.buildTenantQuery(query)).pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load tenants.');
|
||||
this.clearTenantGrid();
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.queryState.getQuery();
|
||||
|
||||
if (response.draw !== query.draw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantsWithSerialNumbers: TenantTableRow[] = response.rows.map((tenant, index) => ({
|
||||
...tenant,
|
||||
serialNumber: (query.page - 1) * query.pageSize + index + 1
|
||||
}));
|
||||
|
||||
this.tenants.set(tenantsWithSerialNumbers);
|
||||
this.totalRecords.set(response.total);
|
||||
this.filteredRecords.set(response.filtered);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
const query = this.tableQuery.setPage(event);
|
||||
//this.loadTenants(query);
|
||||
}
|
||||
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
const query = this.tableQuery.setSort(event);
|
||||
//this.loadTenants(query);
|
||||
}
|
||||
|
||||
onTableAction(event: any): void {
|
||||
if (event?.action?.type === 'delete') {
|
||||
this.pendingDeleteTenant.set(event.row as { id: number });
|
||||
this.deleteConfirmDialog()?.open();
|
||||
return;
|
||||
ngOnInit(): void {
|
||||
this.loadTenants(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
console.log('Action:', event.action.type, event.row);
|
||||
}
|
||||
|
||||
onDeleteConfirmed(): void {
|
||||
const tenant = this.pendingDeleteTenant();
|
||||
|
||||
if (!tenant) {
|
||||
return;
|
||||
loadTenants(query: DataTableQuery): void {
|
||||
this.queryRequests$.next(query);
|
||||
}
|
||||
|
||||
this.pendingDeleteTenant.set(null);
|
||||
this.tenants = this.tenants.filter(currentTenant => currentTenant.id !== tenant.id);
|
||||
this.tenants1 = this.tenants1.filter(currentTenant => currentTenant.id !== tenant.id);
|
||||
this.totalRecords = this.tenants.length;
|
||||
console.log('Deleted tenant:', tenant);
|
||||
}
|
||||
onSearch(value: string): void {
|
||||
this.loadTenants(this.queryState.setSearch(value.trim()));
|
||||
}
|
||||
|
||||
onDeleteCancelled(): void {
|
||||
this.pendingDeleteTenant.set(null);
|
||||
}
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
this.loadTenants(this.queryState.setPage(event));
|
||||
}
|
||||
|
||||
onRowClick(row: any): void {
|
||||
console.log('Row clicked:', row);
|
||||
}
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
this.loadTenants(this.queryState.setSort(event));
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<TenantTableRow>): void {
|
||||
if (event.action.type === 'edit') {
|
||||
this.openEditTenant(event.row.id);
|
||||
}
|
||||
}
|
||||
|
||||
onAddTenant(): void {
|
||||
this.tenantModalMode.set('create');
|
||||
this.selectedTenantId.set(null);
|
||||
this.selectedTenant.set(null);
|
||||
this.tenantSubmitAttempted.set(false);
|
||||
this.resetTenantForm({
|
||||
code: '',
|
||||
name: '',
|
||||
status: TenantStatus.Trial,
|
||||
defaultLanguageId: null,
|
||||
defaultCurrencyId: null,
|
||||
defaultTimezoneId: null,
|
||||
dataRegion: '',
|
||||
isActive: 1
|
||||
});
|
||||
this.showTenantModal.set(true);
|
||||
}
|
||||
|
||||
closeTenantModal(): void {
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showTenantModal.set(false);
|
||||
this.selectedTenantId.set(null);
|
||||
this.selectedTenant.set(null);
|
||||
this.tenantSubmitAttempted.set(false);
|
||||
this.resetTenantForm({
|
||||
code: '',
|
||||
name: '',
|
||||
status: TenantStatus.Trial,
|
||||
defaultLanguageId: null,
|
||||
defaultCurrencyId: null,
|
||||
defaultTimezoneId: null,
|
||||
dataRegion: '',
|
||||
isActive: 1
|
||||
});
|
||||
}
|
||||
|
||||
saveTenant(): void {
|
||||
if (this.tenantForm.invalid) {
|
||||
this.tenantSubmitAttempted.set(true);
|
||||
this.tenantForm.markAllAsTouched();
|
||||
this.focusFirstInvalidControl();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
|
||||
if (this.tenantModalMode() === 'create') {
|
||||
this.tenantApi
|
||||
.createTenant(this.buildCreateTenantRequest())
|
||||
.pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Tenant saved successfully.');
|
||||
this.finishTenantSave();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => this.handleSaveError(error)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantId = this.selectedTenantId();
|
||||
|
||||
if (!tenantId) {
|
||||
this.saving.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tenantApi
|
||||
.updateTenant(tenantId, this.buildUpdateTenantRequest())
|
||||
.pipe(
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('Tenant updated successfully.');
|
||||
this.finishTenantSave();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => this.handleSaveError(error)
|
||||
});
|
||||
}
|
||||
|
||||
openEditTenant(id: string): void {
|
||||
this.tenantModalMode.set('edit');
|
||||
this.selectedTenantId.set(id);
|
||||
this.selectedTenant.set(null);
|
||||
this.tenantSubmitAttempted.set(false);
|
||||
this.resetTenantForm({
|
||||
code: '',
|
||||
name: '',
|
||||
status: TenantStatus.Trial,
|
||||
defaultLanguageId: null,
|
||||
defaultCurrencyId: null,
|
||||
defaultTimezoneId: null,
|
||||
dataRegion: '',
|
||||
isActive: 1
|
||||
});
|
||||
|
||||
this.tenantApi
|
||||
.getTenantById(id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: tenant => {
|
||||
if (this.selectedTenantId() !== tenant.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedTenant.set(tenant);
|
||||
this.resetTenantForm({
|
||||
code: tenant.code ?? '',
|
||||
name: tenant.name ?? '',
|
||||
status: tenant.status ?? TenantStatus.Trial,
|
||||
defaultLanguageId: tenant.defaultLanguageId ?? null,
|
||||
defaultCurrencyId: tenant.defaultCurrencyId ?? null,
|
||||
defaultTimezoneId: tenant.defaultTimezoneId ?? null,
|
||||
dataRegion: tenant.dataRegion ?? '',
|
||||
isActive: tenant.isActive ? 1 : 0
|
||||
});
|
||||
this.showTenantModal.set(true);
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
if (error.status === 404) {
|
||||
this.toastr.error('The tenant is no longer available.');
|
||||
}
|
||||
|
||||
this.selectedTenantId.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildTenantQuery(query: DataTableQuery): DataTableQuery {
|
||||
return {
|
||||
...query,
|
||||
sortBy: this.resolveSortField(query.sortBy ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSortField(sortBy: string | null | undefined): string | null {
|
||||
if (!sortBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (sortBy) {
|
||||
case 'code':
|
||||
case 'name':
|
||||
case 'status':
|
||||
case 'dataRegion':
|
||||
case 'isActive':
|
||||
case 'createdOn':
|
||||
return sortBy;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildCreateTenantRequest(): CreateTenantRequest {
|
||||
const value = this.tenantForm.getRawValue();
|
||||
|
||||
return {
|
||||
code: value.code.trim(),
|
||||
name: value.name.trim(),
|
||||
status: value.status ?? TenantStatus.Trial,
|
||||
defaultLanguageId: value.defaultLanguageId ?? '',
|
||||
defaultCurrencyId: value.defaultCurrencyId ?? '',
|
||||
defaultTimezoneId: value.defaultTimezoneId ?? '',
|
||||
dataRegion: value.dataRegion.trim()
|
||||
};
|
||||
}
|
||||
|
||||
private buildUpdateTenantRequest(): UpdateTenantRequest {
|
||||
const value = this.tenantForm.getRawValue();
|
||||
|
||||
return {
|
||||
code: value.code.trim(),
|
||||
name: value.name.trim(),
|
||||
status: value.status ?? TenantStatus.Trial,
|
||||
defaultLanguageId: value.defaultLanguageId ?? '',
|
||||
defaultCurrencyId: value.defaultCurrencyId ?? '',
|
||||
defaultTimezoneId: value.defaultTimezoneId ?? '',
|
||||
defaultDbConnectionId: this.selectedTenant()?.defaultDbConnectionId ?? null,
|
||||
dataRegion: value.dataRegion.trim(),
|
||||
isActive: value.isActive === 1
|
||||
};
|
||||
}
|
||||
|
||||
private finishTenantSave(): void {
|
||||
this.showTenantModal.set(false);
|
||||
this.selectedTenantId.set(null);
|
||||
this.selectedTenant.set(null);
|
||||
this.tenantSubmitAttempted.set(false);
|
||||
this.resetTenantForm({
|
||||
code: '',
|
||||
name: '',
|
||||
status: TenantStatus.Trial,
|
||||
defaultLanguageId: null,
|
||||
defaultCurrencyId: null,
|
||||
defaultTimezoneId: null,
|
||||
dataRegion: '',
|
||||
isActive: 1
|
||||
});
|
||||
this.loadTenants(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
private resetTenantForm(value: {
|
||||
code: string;
|
||||
name: string;
|
||||
status: TenantStatus;
|
||||
defaultLanguageId: string | null;
|
||||
defaultCurrencyId: string | null;
|
||||
defaultTimezoneId: string | null;
|
||||
dataRegion: string;
|
||||
isActive: number | null;
|
||||
}): void {
|
||||
this.tenantForm.reset(value);
|
||||
this.tenantForm.markAsPristine();
|
||||
this.tenantForm.markAsUntouched();
|
||||
this.tenantForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
private formatTenantStatus(status: TenantStatus): string {
|
||||
switch (status) {
|
||||
case TenantStatus.Trial:
|
||||
return 'Trial';
|
||||
case TenantStatus.Active:
|
||||
return 'Active';
|
||||
case TenantStatus.Suspended:
|
||||
return 'Suspended';
|
||||
case TenantStatus.Cancelled:
|
||||
return 'Cancelled';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private getTenantStatusBadgeClass(status: TenantStatus): string {
|
||||
switch (status) {
|
||||
case TenantStatus.Trial:
|
||||
return 'badge bg-warning/10 text-warning';
|
||||
case TenantStatus.Active:
|
||||
return 'badge bg-success/10 text-success';
|
||||
case TenantStatus.Suspended:
|
||||
return 'badge bg-info/10 text-info';
|
||||
case TenantStatus.Cancelled:
|
||||
return 'badge bg-danger/10 text-danger';
|
||||
default:
|
||||
return 'badge bg-secondary/10 text-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
private formatDateTime(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
private handleSaveError(error: HttpErrorResponse): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error('A tenant with this code already exists.', 'Duplicate tenant code');
|
||||
}
|
||||
}
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
const control = this.elementRef.nativeElement.querySelector<HTMLElement>(
|
||||
'modal .form-control.is-invalid, modal .ti-form-select.is-invalid, modal [aria-invalid="true"]'
|
||||
);
|
||||
|
||||
control?.focus();
|
||||
control?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
|
||||
private clearTenantGrid(): void {
|
||||
this.tenants.set([]);
|
||||
this.totalRecords.set(0);
|
||||
this.filteredRecords.set(0);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ export const tenantsRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./pages/tenant-list/tenant-list').then((m) => m.TenantList),
|
||||
data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' },
|
||||
data: { childTitle: 'Tenant Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
|
||||
},
|
||||
{
|
||||
path: 'tenant-currencies',
|
||||
loadComponent: () => import('./pages/tenant-currencies/tenant-currencies').then((m) => m.TenantCurrencies),
|
||||
data: { childTitle: 'Tenant Currencies', parentTitle: 'Platform', subParentTitle: 'Configuration' },
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,19 @@
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform users</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Users, roles, assignment, deactivation, and password reset will be implemented here.</p>
|
||||
</div>
|
||||
<app-data-table [columns]="columns()" [rows]="users()" [actions]="actions()"
|
||||
(addClicked)="onAddUser()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
|
||||
[pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Users" buttonTitle="Add"
|
||||
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search users..." [searchDebounceTime]="300"
|
||||
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)" toolTip="Add User">
|
||||
<!-- <ng-template appDataTableCell="name" let-row let-value="value">
|
||||
<div class="flex items-center gap-2">
|
||||
@if (getFlagUrl(row.iso2); as flagUrl) {
|
||||
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
|
||||
(error)="onFlagError($event)" />
|
||||
}
|
||||
|
||||
<span class="font-semibold">
|
||||
{{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template> -->
|
||||
</app-data-table>
|
||||
|
||||
@@ -1,11 +1,521 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
OnInit,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
catchError,
|
||||
finalize,
|
||||
of,
|
||||
Subject,
|
||||
switchMap
|
||||
} from 'rxjs';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
import {
|
||||
CreateUserRequest,
|
||||
UserModalMode,
|
||||
UserStatus
|
||||
} from '../../../../core/models/user/user.model';
|
||||
import { UserService } from '../../../../core/services/user/user.service';
|
||||
|
||||
import { DataTable } from '../../../../shared/components/data-table/data-table';
|
||||
import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTablePageEvent,
|
||||
DataTableQuery,
|
||||
DataTableRecord,
|
||||
DataTableSortEvent
|
||||
} from '../../../../shared/components/data-table/data-table.types';
|
||||
import { Modal } from '../../../../shared/components/modal/modal';
|
||||
|
||||
interface UserTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
status: UserStatus;
|
||||
roles: string[];
|
||||
isActive: boolean;
|
||||
serialNumber: number;
|
||||
createdOn: string;
|
||||
lastLoginOn: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
DataTable,
|
||||
Modal
|
||||
],
|
||||
templateUrl: './users-list.html',
|
||||
styleUrl: './users-list.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class UsersList {}
|
||||
export class UsersList implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly usersApi = inject(UserService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
private readonly usersQueryRequests$ =
|
||||
new Subject<DataTableQuery>();
|
||||
|
||||
readonly users = signal<UserTableRow[]>([]);
|
||||
readonly queryState = new DataTableQueryState();
|
||||
|
||||
readonly totalRecords = signal(0);
|
||||
readonly filteredRecords = signal(0);
|
||||
|
||||
readonly userModalMode = signal<UserModalMode>('create');
|
||||
readonly showUserModal = signal(false);
|
||||
|
||||
readonly saving = signal(false);
|
||||
readonly userSubmitAttempted = signal(false);
|
||||
|
||||
readonly userForm = this.formBuilder.nonNullable.group({
|
||||
email: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.email,
|
||||
Validators.maxLength(256)
|
||||
]
|
||||
],
|
||||
password: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(8),
|
||||
Validators.maxLength(128)
|
||||
]
|
||||
],
|
||||
roleCodes: this.formBuilder.nonNullable.control<string[]>(
|
||||
[],
|
||||
[
|
||||
Validators.required,
|
||||
control =>
|
||||
control.value.length > 0
|
||||
? null
|
||||
: { required: true }
|
||||
]
|
||||
)
|
||||
});
|
||||
|
||||
readonly columns = signal<DataTableColumn<UserTableRow>[]>([
|
||||
{
|
||||
key: 'serialNumber',
|
||||
label: 'Sr. No.',
|
||||
header: 'Sr. No.',
|
||||
sortable: false,
|
||||
width: '100px'
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
header: 'Email',
|
||||
sortable: true,
|
||||
align: 'left'
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'User Status',
|
||||
header: 'User Status',
|
||||
sortable: true,
|
||||
formatter: value =>
|
||||
this.formatUserStatus(value as UserStatus)
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
label: 'Roles',
|
||||
header: 'Roles',
|
||||
sortable: false,
|
||||
align: 'left',
|
||||
formatter: value =>
|
||||
this.formatRoles(
|
||||
Array.isArray(value)
|
||||
? value.filter(
|
||||
(item): item is string =>
|
||||
typeof item === 'string'
|
||||
)
|
||||
: []
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'createdOn',
|
||||
label: 'Created On',
|
||||
header: 'Created On',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'lastLoginOn',
|
||||
label: 'Last Login On',
|
||||
header: 'Last Login On',
|
||||
sortable: true,
|
||||
formatter: value =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
? value
|
||||
: 'Never'
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Active',
|
||||
header: 'Active',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
badgeClass: value =>
|
||||
value === true
|
||||
? 'badge bg-success/10 text-success'
|
||||
: 'badge bg-danger/10 text-danger',
|
||||
formatter: value =>
|
||||
value === true ? 'Active' : 'Inactive'
|
||||
}
|
||||
]);
|
||||
|
||||
onActionClick(event: DataTableActionEvent<UserTableRow>): void {
|
||||
// const user = this.toUserDto(event.row);
|
||||
|
||||
// switch (event.action.type) {
|
||||
// case 'view':
|
||||
// this.viewUser(user);
|
||||
// break;
|
||||
// case 'edit':
|
||||
// this.openEditUser(user);
|
||||
// break;
|
||||
// case 'delete':
|
||||
// this.requestDeleteUser(user);
|
||||
// break;
|
||||
// case 'activate':
|
||||
// this.activateUser(user);
|
||||
// break;
|
||||
// }
|
||||
}
|
||||
readonly actions = signal<DataTableAction<UserTableRow>[]>([]);
|
||||
|
||||
constructor() {
|
||||
this.initializeUserQueryStream();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadUsers(this.queryState.getQuery());
|
||||
}
|
||||
|
||||
loadUsers(query: DataTableQuery): void {
|
||||
this.usersQueryRequests$.next(query);
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
const query = this.queryState.setSearch(value.trim());
|
||||
this.loadUsers(query);
|
||||
}
|
||||
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
const query = this.queryState.setPage(event);
|
||||
this.loadUsers(query);
|
||||
}
|
||||
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
const query = this.queryState.setSort(event);
|
||||
this.loadUsers(query);
|
||||
}
|
||||
|
||||
onRefresh(): void {
|
||||
const query = this.queryState.reset();
|
||||
this.loadUsers(query);
|
||||
}
|
||||
|
||||
onAddUser(): void {
|
||||
this.userModalMode.set('create');
|
||||
this.resetUserForm();
|
||||
this.showUserModal.set(true);
|
||||
}
|
||||
|
||||
closeUserModal(): void {
|
||||
if (this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showUserModal.set(false);
|
||||
this.resetUserForm();
|
||||
}
|
||||
|
||||
saveUser(): void {
|
||||
this.userSubmitAttempted.set(true);
|
||||
|
||||
if (this.userForm.invalid || this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = this.buildCreateUserRequest();
|
||||
|
||||
if (request.roleCodes.length === 0) {
|
||||
this.userForm.controls.roleCodes.setErrors({
|
||||
required: true
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
|
||||
this.usersApi
|
||||
.createUser(request)
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.saving.set(false);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastr.success('User created successfully.');
|
||||
|
||||
this.showUserModal.set(false);
|
||||
this.resetUserForm();
|
||||
this.refreshUsersAfterSave();
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.handleCreateUserError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isControlInvalid(
|
||||
controlName: keyof typeof this.userForm.controls
|
||||
): boolean {
|
||||
const control = this.userForm.controls[controlName];
|
||||
|
||||
return this.userSubmitAttempted() && control.invalid;
|
||||
}
|
||||
|
||||
hasControlError(
|
||||
controlName: keyof typeof this.userForm.controls,
|
||||
errorName: string
|
||||
): boolean {
|
||||
const control = this.userForm.controls[controlName];
|
||||
|
||||
return (
|
||||
this.userSubmitAttempted() &&
|
||||
control.hasError(errorName)
|
||||
);
|
||||
}
|
||||
|
||||
private initializeUserQueryStream(): void {
|
||||
this.usersQueryRequests$
|
||||
.pipe(
|
||||
switchMap(requestedQuery =>
|
||||
this.usersApi.getDataTable(requestedQuery).pipe(
|
||||
catchError(() => {
|
||||
this.clearUsersGrid();
|
||||
this.toastr.error('Unable to load users.');
|
||||
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(response => {
|
||||
if (response === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentQuery = this.queryState.getQuery();
|
||||
|
||||
if (response.draw !== currentQuery.draw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usersWithSerialNumbers: UserTableRow[] =
|
||||
response.rows.map((user, index) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
roles: [...user.roles],
|
||||
isActive: user.isActive,
|
||||
createdOn: user.createdOn,
|
||||
lastLoginOn: user.lastLoginOn,
|
||||
serialNumber:
|
||||
(currentQuery.page - 1) *
|
||||
currentQuery.pageSize +
|
||||
index +
|
||||
1
|
||||
}));
|
||||
|
||||
this.users.set(usersWithSerialNumbers);
|
||||
this.totalRecords.set(response.total);
|
||||
this.filteredRecords.set(response.filtered);
|
||||
});
|
||||
}
|
||||
|
||||
private buildCreateUserRequest(): CreateUserRequest {
|
||||
const value = this.userForm.getRawValue();
|
||||
|
||||
const normalizedRoleCodes = Array.from(
|
||||
new Set(
|
||||
value.roleCodes
|
||||
.map(roleCode => roleCode.trim())
|
||||
.filter(roleCode => roleCode.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
roleCodes: normalizedRoleCodes
|
||||
};
|
||||
}
|
||||
|
||||
private refreshUsersAfterSave(): void {
|
||||
const query = this.queryState.reset();
|
||||
this.loadUsers(query);
|
||||
}
|
||||
|
||||
private resetUserForm(): void {
|
||||
this.userForm.reset({
|
||||
email: '',
|
||||
password: '',
|
||||
roleCodes: []
|
||||
});
|
||||
|
||||
this.userSubmitAttempted.set(false);
|
||||
|
||||
this.userForm.markAsPristine();
|
||||
this.userForm.markAsUntouched();
|
||||
this.userForm.updateValueAndValidity({
|
||||
emitEvent: false
|
||||
});
|
||||
}
|
||||
|
||||
private clearUsersGrid(): void {
|
||||
this.users.set([]);
|
||||
this.totalRecords.set(0);
|
||||
this.filteredRecords.set(0);
|
||||
}
|
||||
|
||||
private handleCreateUserError(
|
||||
error: HttpErrorResponse
|
||||
): void {
|
||||
if (error.status === 409) {
|
||||
this.toastr.error(
|
||||
'A user with this email address already exists.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.status === 400) {
|
||||
this.toastr.error(
|
||||
this.extractApiErrorMessage(error) ??
|
||||
'The user information is invalid.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Authentication/session errors should normally be handled
|
||||
* by the global authentication interceptor.
|
||||
*/
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastr.error(
|
||||
'Unable to create the user. Please try again.'
|
||||
);
|
||||
}
|
||||
|
||||
private extractApiErrorMessage(
|
||||
error: HttpErrorResponse
|
||||
): string | null {
|
||||
const responseBody: unknown = error.error;
|
||||
|
||||
if (
|
||||
typeof responseBody !== 'object' ||
|
||||
responseBody === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiError = responseBody as {
|
||||
detail?: unknown;
|
||||
message?: unknown;
|
||||
title?: unknown;
|
||||
};
|
||||
|
||||
if (typeof apiError.detail === 'string') {
|
||||
return apiError.detail;
|
||||
}
|
||||
|
||||
if (typeof apiError.message === 'string') {
|
||||
return apiError.message;
|
||||
}
|
||||
|
||||
if (typeof apiError.title === 'string') {
|
||||
return apiError.title;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private formatRoles(roles: readonly string[]): string {
|
||||
if (roles.length === 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return roles
|
||||
.map(role => this.formatRoleCode(role))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
private formatRoleCode(roleCode: string): string {
|
||||
return roleCode
|
||||
.split('_')
|
||||
.filter(part => part.length > 0)
|
||||
.map(
|
||||
part =>
|
||||
`${part.charAt(0).toUpperCase()}${part
|
||||
.slice(1)
|
||||
.toLowerCase()}`
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
private formatUserStatus(status: UserStatus): string {
|
||||
switch (status) {
|
||||
case UserStatus.Pending:
|
||||
return 'Pending';
|
||||
|
||||
case UserStatus.Active:
|
||||
return 'Active';
|
||||
|
||||
case UserStatus.Suspended:
|
||||
return 'Suspended';
|
||||
|
||||
case UserStatus.Locked:
|
||||
return 'Locked';
|
||||
|
||||
case UserStatus.Disabled:
|
||||
return 'Disabled';
|
||||
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { superAdminGuard } from '../../core/guards/auth/super-admin.guard';
|
||||
|
||||
export const usersRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
canActivate: [superAdminGuard],
|
||||
loadComponent: () => import('./pages/users-list/users-list').then((m) => m.UsersList),
|
||||
data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' },
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="box custom-box" [class]="'box custom-box ' + cardClass()">
|
||||
<div class="box-header justify-between" [class]="'box-header justify-between ' + headerClass()">
|
||||
<div class="box-title">{{ title() }}</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-content select="[filterCardActions]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-sm ti-btn-light"
|
||||
[class]="'ti-btn ti-btn-sm ti-btn-light ' + toggleButtonClass()"
|
||||
[class.hidden]="!showToggleButton()"
|
||||
[disabled]="!collapsible()"
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
[attr.aria-controls]="resolvedContentId()"
|
||||
[attr.aria-label]="currentTooltip()"
|
||||
[appTooltip]="currentTooltip()"
|
||||
(click)="toggleCollapse()"
|
||||
>
|
||||
<i [class]="currentIcon()" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="filter-card-body-grid"
|
||||
[class.is-collapsed]="collapsed()"
|
||||
[attr.aria-hidden]="collapsed()"
|
||||
[attr.inert]="collapsed() ? '' : null"
|
||||
>
|
||||
<div class="filter-card-body-content">
|
||||
<div
|
||||
class="box-body"
|
||||
[class]="'box-body ' + bodyClass()"
|
||||
[id]="resolvedContentId()"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,142 @@
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FilterCard } from './filter-card';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [FilterCard],
|
||||
template: `
|
||||
<app-filter-card
|
||||
title="Country Selection"
|
||||
[defaultCollapsed]="defaultCollapsed()"
|
||||
[collapsible]="collapsible()"
|
||||
[collapsedIcon]="collapsedIcon"
|
||||
[expandedIcon]="expandedIcon"
|
||||
[contentId]="contentId()"
|
||||
(collapseChanged)="changes.push($event)"
|
||||
>
|
||||
<button filterCardActions type="button">Reset</button>
|
||||
<input data-testid="projected-input" value="preserved" />
|
||||
</app-filter-card>
|
||||
`
|
||||
})
|
||||
class HostComponent {
|
||||
readonly defaultCollapsed = signal(false);
|
||||
readonly collapsible = signal(true);
|
||||
readonly contentId = signal('');
|
||||
readonly changes: boolean[] = [];
|
||||
collapsedIcon = 'custom-collapsed';
|
||||
expandedIcon = 'custom-expanded';
|
||||
}
|
||||
|
||||
describe('FilterCard', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let host: HostComponent;
|
||||
let overlayContainer: OverlayContainer;
|
||||
|
||||
const card = (): FilterCard => fixture.debugElement.children[0].componentInstance;
|
||||
const toggle = (): HTMLButtonElement => fixture.nativeElement.querySelector('button[aria-controls]');
|
||||
const icon = (): HTMLElement => toggle().querySelector('i') as HTMLElement;
|
||||
const bodyGrid = (): HTMLElement => fixture.nativeElement.querySelector('.filter-card-body-grid');
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
host = fixture.componentInstance;
|
||||
overlayContainer = TestBed.inject(OverlayContainer);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
overlayContainer.ngOnDestroy();
|
||||
});
|
||||
|
||||
it('starts expanded by default', () => {
|
||||
expect(card().collapsed()).toBe(false);
|
||||
expect(bodyGrid().classList.contains('is-collapsed')).toBe(false);
|
||||
});
|
||||
|
||||
it('reacts to a collapsed default state', () => {
|
||||
host.defaultCollapsed.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(card().collapsed()).toBe(true);
|
||||
expect(bodyGrid().classList.contains('is-collapsed')).toBe(true);
|
||||
});
|
||||
|
||||
it('switches icons and tooltips when toggled', () => {
|
||||
expect(icon().className).toBe('custom-expanded');
|
||||
expect(toggle().getAttribute('aria-label')).toBe('Hide Filters');
|
||||
toggle().click();
|
||||
fixture.detectChanges();
|
||||
expect(icon().className).toBe('custom-collapsed');
|
||||
expect(toggle().getAttribute('aria-label')).toBe('Show Filters');
|
||||
});
|
||||
|
||||
it('updates an open tooltip when toggled without requiring another hover', () => {
|
||||
vi.useFakeTimers();
|
||||
toggle().dispatchEvent(new MouseEvent('mouseenter'));
|
||||
vi.advanceTimersByTime(200);
|
||||
fixture.detectChanges();
|
||||
expect(overlayContainer.getContainerElement().textContent).toContain('Hide Filters');
|
||||
|
||||
toggle().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(overlayContainer.getContainerElement().textContent).toContain('Show Filters');
|
||||
expect(overlayContainer.getContainerElement().textContent).not.toContain('Hide Filters');
|
||||
});
|
||||
|
||||
it('emits the new collapsed state', () => {
|
||||
toggle().click();
|
||||
toggle().click();
|
||||
expect(host.changes).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('sets accessible expanded and controls attributes', () => {
|
||||
const id = card().resolvedContentId();
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true');
|
||||
expect(toggle().getAttribute('aria-controls')).toBe(id);
|
||||
expect(fixture.nativeElement.querySelector(`#${id}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps projected content mounted while collapsed', () => {
|
||||
const input = fixture.nativeElement.querySelector('[data-testid="projected-input"]');
|
||||
toggle().click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="projected-input"]')).toBe(input);
|
||||
expect(fixture.nativeElement.textContent).toContain('Reset');
|
||||
});
|
||||
|
||||
it('uses custom icons', () => {
|
||||
expect(icon().classList.contains('custom-expanded')).toBe(true);
|
||||
toggle().click();
|
||||
fixture.detectChanges();
|
||||
expect(icon().classList.contains('custom-collapsed')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses a custom content id', () => {
|
||||
host.contentId.set('state-filter-content');
|
||||
fixture.detectChanges();
|
||||
expect(toggle().getAttribute('aria-controls')).toBe('state-filter-content');
|
||||
expect(fixture.nativeElement.querySelector('#state-filter-content')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('generates a stable content id', () => {
|
||||
const generatedId = card().resolvedContentId();
|
||||
fixture.detectChanges();
|
||||
expect(generatedId).toMatch(/^filter-card-content-\d+$/);
|
||||
expect(card().resolvedContentId()).toBe(generatedId);
|
||||
});
|
||||
|
||||
it('does not collapse or emit when non-collapsible', () => {
|
||||
host.collapsible.set(false);
|
||||
fixture.detectChanges();
|
||||
toggle().click();
|
||||
expect(card().collapsed()).toBe(false);
|
||||
expect(host.changes).toEqual([]);
|
||||
expect(toggle().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Component, computed, effect, input, output, signal } from '@angular/core';
|
||||
|
||||
import { TooltipDirective } from '../../directives/tooltip/tooltip.directive';
|
||||
|
||||
let nextFilterCardId = 0;
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter-card',
|
||||
standalone: true,
|
||||
imports: [TooltipDirective],
|
||||
templateUrl: './filter-card.html',
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
.filter-card-body-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 200ms ease, visibility 200ms ease;
|
||||
visibility: visible;
|
||||
}
|
||||
.filter-card-body-grid.is-collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
visibility: hidden;
|
||||
}
|
||||
.filter-card-body-content { min-height: 0; overflow: hidden; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.filter-card-body-grid { transition: none; }
|
||||
}
|
||||
`
|
||||
})
|
||||
export class FilterCard {
|
||||
readonly title = input<string>('Filters');
|
||||
readonly defaultCollapsed = input<boolean>(false);
|
||||
readonly collapsible = input<boolean>(true);
|
||||
readonly showToggleButton = input<boolean>(true);
|
||||
readonly collapsedIcon = input<string>('ri-filter-3-line');
|
||||
readonly expandedIcon = input<string>('ri-arrow-up-s-line');
|
||||
readonly collapsedTooltip = input<string>('Show Filters');
|
||||
readonly expandedTooltip = input<string>('Hide Filters');
|
||||
readonly cardClass = input<string>('');
|
||||
readonly headerClass = input<string>('');
|
||||
readonly bodyClass = input<string>('');
|
||||
readonly toggleButtonClass = input<string>('');
|
||||
readonly contentId = input<string>('');
|
||||
|
||||
readonly collapseChanged = output<boolean>();
|
||||
readonly collapsed = signal(false);
|
||||
|
||||
private readonly generatedContentId = `filter-card-content-${++nextFilterCardId}`;
|
||||
|
||||
readonly resolvedContentId = computed(() => this.contentId().trim() || this.generatedContentId);
|
||||
readonly currentIcon = computed(() =>
|
||||
this.collapsed() ? this.collapsedIcon() : this.expandedIcon()
|
||||
);
|
||||
readonly currentTooltip = computed(() =>
|
||||
this.collapsed() ? this.collapsedTooltip() : this.expandedTooltip()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => this.collapsed.set(this.defaultCollapsed()));
|
||||
}
|
||||
|
||||
toggleCollapse(): void {
|
||||
if (!this.collapsible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collapsed = !this.collapsed();
|
||||
this.collapsed.set(collapsed);
|
||||
this.collapseChanged.emit(collapsed);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
[disabled]="isDisabled()"
|
||||
[description]="description()"
|
||||
[hint]="hint()"
|
||||
[help]="help()"
|
||||
[labelPosition]="labelPosition()"
|
||||
[hideLabel]="hideLabel()"
|
||||
[hideValidation]="hideValidation()"
|
||||
@@ -35,18 +36,19 @@
|
||||
[attr.aria-readonly]="readonly()"
|
||||
(input)="onInput($event)"
|
||||
(focus)="onFocus()"
|
||||
(click)="onClick()"
|
||||
(blur)="onBlur()"
|
||||
(keydown)="onKeydown($event)"
|
||||
/>
|
||||
|
||||
@if (loading()) {
|
||||
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2" aria-hidden="true">
|
||||
<span class="ti-spinner h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></span>
|
||||
<span class="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-primary" aria-hidden="true">
|
||||
<span class="ti-spinner block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
</span>
|
||||
} @else if (clearable() && (hasSelectedItem() || searchText())) {
|
||||
<button
|
||||
type="button"
|
||||
class="absolute end-3 top-1/2 -translate-y-1/2 text-textmuted hover:text-danger"
|
||||
class="absolute end-3 top-1/2 inline-flex -translate-y-1/2 items-center justify-center rounded-sm text-textmuted transition-colors hover:bg-light hover:text-danger focus:outline-none focus:ring-1 focus:ring-primary dark:hover:bg-black/20"
|
||||
aria-label="Clear selection"
|
||||
[disabled]="isDisabled() || readonly()"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
@@ -75,11 +77,11 @@
|
||||
<div
|
||||
[id]="panelId()"
|
||||
role="listbox"
|
||||
class="max-h-64 overflow-y-auto rounded-sm border border-defaultborder bg-white py-1 text-defaulttextcolor shadow-lg dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70"
|
||||
class="w-full max-h-64 overflow-y-auto rounded-sm border border-defaultborder bg-white py-1 text-defaulttextcolor shadow-lg dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70"
|
||||
[class]="panelClass()"
|
||||
>
|
||||
@if (message()) {
|
||||
<div class="px-3 py-2 text-[0.8125rem] text-textmuted" [class.text-danger]="error()">
|
||||
<div class="bg-white px-3 py-2 text-[0.8125rem] text-textmuted dark:bg-bodybg dark:text-white/50" [class.text-danger]="error()">
|
||||
{{ message() }}
|
||||
</div>
|
||||
} @else {
|
||||
@@ -88,10 +90,8 @@
|
||||
type="button"
|
||||
role="option"
|
||||
[id]="optionId(index)"
|
||||
[class]="activeIndex() === index
|
||||
? 'block w-full px-3 py-2 text-start text-[0.8125rem] bg-light text-primary dark:bg-black/20 dark:text-white/70'
|
||||
: 'block w-full px-3 py-2 text-start text-[0.8125rem] hover:bg-light dark:hover:bg-black/20'"
|
||||
[attr.aria-selected]="activeIndex() === index"
|
||||
[class]="optionClass(item, index)"
|
||||
[attr.aria-selected]="isOptionSelected(item)"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(mouseenter)="activeIndex.set(index)"
|
||||
(click)="select(item)"
|
||||
@@ -99,6 +99,15 @@
|
||||
{{ displayWith()(item) }}
|
||||
</button>
|
||||
}
|
||||
@if (showingPreview()) {
|
||||
<div
|
||||
role="option"
|
||||
aria-disabled="true"
|
||||
class="cursor-default border-t border-defaultborder bg-light/50 px-3 py-2 text-[0.8125rem] italic text-textmuted dark:border-defaultborder/10 dark:bg-black/10 dark:text-white/50"
|
||||
>
|
||||
{{ previewText() }}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Observable, Subject, of, throwError } from 'rxjs';
|
||||
|
||||
import { Autocomplete } from './autocomplete';
|
||||
import { AutocompleteSearchFn } from './autocomplete.types';
|
||||
import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types';
|
||||
|
||||
interface LookupItem {
|
||||
readonly code: string;
|
||||
@@ -26,24 +26,29 @@ const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };
|
||||
[searchFn]="searchFn"
|
||||
[displayWith]="displayWith"
|
||||
[valueWith]="valueWith"
|
||||
placeholder="e.g.: USD"
|
||||
[selectedItem]="selectedItem()"
|
||||
[resolveValueFn]="resolveValueFn()"
|
||||
[minSearchLength]="minLength"
|
||||
[debounceTime]="delay()"
|
||||
[readonly]="readonly()"
|
||||
[showDropdownOnFocus]="openOnFocus"
|
||||
[showDropdownOnFocus]="openOnFocus()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
/>
|
||||
`
|
||||
})
|
||||
class HostComponent {
|
||||
readonly control = new FormControl<string | null>(null);
|
||||
readonly selectedItem = signal<LookupItem | null>(null);
|
||||
readonly resolveValueFn = signal<AutocompleteResolveValueFn<LookupItem, string> | null>(null);
|
||||
searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]);
|
||||
readonly displayWith = (item: LookupItem): string => item.title;
|
||||
readonly valueWith = (item: LookupItem): string => item.code;
|
||||
minLength = 2;
|
||||
readonly delay = signal(300);
|
||||
readonly readonly = signal(false);
|
||||
openOnFocus = false;
|
||||
readonly openOnFocus = signal(false);
|
||||
readonly submitAttempted = signal(false);
|
||||
}
|
||||
|
||||
describe('Autocomplete', () => {
|
||||
@@ -73,6 +78,11 @@ describe('Autocomplete', () => {
|
||||
it('initializes with an empty selection', () => {
|
||||
expect(component.searchText()).toBe('');
|
||||
expect(component.options()).toEqual([]);
|
||||
expect(input().value).toBe('');
|
||||
expect(input().placeholder).toBe('e.g.: USD');
|
||||
expect(input().className).not.toContain('ti-form-control');
|
||||
expect(input().className).toContain('placeholder:text-textmuted');
|
||||
expect(input().className).toContain('dark:placeholder:text-white/50');
|
||||
});
|
||||
|
||||
it('integrates with a reactive form control', () => {
|
||||
@@ -80,6 +90,14 @@ describe('Autocomplete', () => {
|
||||
expect(host.control.value).toBe('IN');
|
||||
});
|
||||
|
||||
it('treats edited selected text as search text rather than a selected item', () => {
|
||||
component.select(INDIA);
|
||||
expect(component.activeItem()).toEqual(INDIA);
|
||||
type('Ind');
|
||||
expect(component.searchText()).toBe('Ind');
|
||||
expect(component.activeItem()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not emit onChange from writeValue', () => {
|
||||
const change = vi.fn();
|
||||
component.registerOnChange(change);
|
||||
@@ -87,6 +105,28 @@ describe('Autocomplete', () => {
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show invalid styling after touch before submit', () => {
|
||||
host.control.setValidators(Validators.required);
|
||||
host.control.updateValueAndValidity();
|
||||
host.control.markAsTouched();
|
||||
fixture.detectChanges();
|
||||
expect(input().classList.contains('is-invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows invalid styling after submit and removes it when valid', () => {
|
||||
host.control.setValidators(Validators.required);
|
||||
host.control.updateValueAndValidity();
|
||||
host.submitAttempted.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(input().classList.contains('is-invalid')).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain('Country is required.');
|
||||
|
||||
host.control.setValue('IN');
|
||||
fixture.detectChanges();
|
||||
expect(input().classList.contains('is-invalid')).toBe(false);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Country is required.');
|
||||
});
|
||||
|
||||
it('applies a disabled form state', () => {
|
||||
host.control.disable();
|
||||
fixture.detectChanges();
|
||||
@@ -117,6 +157,40 @@ describe('Autocomplete', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('loads five preview records on focus before the user types', () => {
|
||||
vi.useFakeTimers();
|
||||
const search = vi.fn(() => of([INDIA, INDONESIA]));
|
||||
host.openOnFocus.set(true);
|
||||
host.searchFn = search;
|
||||
fixture.detectChanges();
|
||||
input().focus();
|
||||
vi.advanceTimersByTime(0);
|
||||
fixture.detectChanges();
|
||||
expect(search).toHaveBeenCalledWith('', 5);
|
||||
expect(component.options()).toEqual([INDIA, INDONESIA]);
|
||||
expect(component.showingPreview()).toBe(true);
|
||||
expect(overlayContainer.getContainerElement().textContent).toContain('Type to search more...');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('replaces preview records with typed search results', () => {
|
||||
vi.useFakeTimers();
|
||||
host.openOnFocus.set(true);
|
||||
host.delay.set(0);
|
||||
host.searchFn = (term, _limit) => of(term ? [INDONESIA] : [INDIA]);
|
||||
fixture.detectChanges();
|
||||
input().focus();
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(component.options()).toEqual([INDIA]);
|
||||
type('in');
|
||||
vi.advanceTimersByTime(0);
|
||||
fixture.detectChanges();
|
||||
expect(component.options()).toEqual([INDONESIA]);
|
||||
expect(component.showingPreview()).toBe(false);
|
||||
expect(overlayContainer.getContainerElement().textContent).not.toContain('Type to search more...');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancels stale search requests', () => {
|
||||
vi.useFakeTimers();
|
||||
const first = new Subject<readonly LookupItem[]>();
|
||||
@@ -227,6 +301,36 @@ describe('Autocomplete', () => {
|
||||
expect(component.searchText()).toBe('India');
|
||||
});
|
||||
|
||||
it('does not resolve an empty string form value', () => {
|
||||
const resolve = vi.fn(() => of(INDIA));
|
||||
host.resolveValueFn.set(resolve);
|
||||
fixture.detectChanges();
|
||||
host.control.setValue('');
|
||||
fixture.detectChanges();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the matching selected item without resolving it again', () => {
|
||||
const resolve = vi.fn(() => of(INDIA));
|
||||
host.resolveValueFn.set(resolve);
|
||||
host.selectedItem.set(INDIA);
|
||||
fixture.detectChanges();
|
||||
host.control.setValue('IN');
|
||||
fixture.detectChanges();
|
||||
expect(component.searchText()).toBe('India');
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a non-empty value when no matching selected item is supplied', () => {
|
||||
const resolve = vi.fn(() => of(INDIA));
|
||||
host.resolveValueFn.set(resolve);
|
||||
fixture.detectChanges();
|
||||
host.control.setValue('IN');
|
||||
fixture.detectChanges();
|
||||
expect(resolve).toHaveBeenCalledWith('IN');
|
||||
expect(component.searchText()).toBe('India');
|
||||
});
|
||||
|
||||
it('does not retain a stale label when edit values change', () => {
|
||||
host.selectedItem.set(INDIA);
|
||||
host.control.setValue('IN');
|
||||
|
||||
@@ -40,6 +40,12 @@ interface SearchResult<TItem> {
|
||||
readonly term: string;
|
||||
readonly options: readonly TItem[];
|
||||
readonly failed: boolean;
|
||||
readonly preview: boolean;
|
||||
}
|
||||
|
||||
interface SearchRequest {
|
||||
readonly term: string;
|
||||
readonly preview: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -59,9 +65,10 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly generatedId = `autocomplete-${Autocomplete.nextId++}`;
|
||||
private readonly inputTerms$ = new Subject<string>();
|
||||
private readonly searchRequests$ = new Subject<SearchRequest>();
|
||||
private readonly valuesToResolve$ = new Subject<TValue>();
|
||||
private formValue: TValue | null = null;
|
||||
private readonly controlStateVersion = signal(0);
|
||||
private labelEdited = false;
|
||||
private onChange: (value: TValue | null) => void = () => {};
|
||||
private onTouched: () => void = () => {};
|
||||
@@ -81,6 +88,8 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
readonly minSearchLength = input(1);
|
||||
readonly debounceTime = input(300);
|
||||
readonly limit = input(10);
|
||||
readonly previewLimit = input(5);
|
||||
readonly previewText = input('Type to search more...');
|
||||
readonly disabled = input(false);
|
||||
readonly readonly = input(false);
|
||||
readonly clearable = input(true);
|
||||
@@ -89,7 +98,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
readonly emptyText = input('No results found');
|
||||
readonly typeToSearchText = input('Type to search');
|
||||
readonly errorText = input('Unable to load results');
|
||||
readonly showDropdownOnFocus = input(false);
|
||||
readonly showDropdownOnFocus = input(true);
|
||||
readonly closeOnSelect = input(true);
|
||||
readonly autocomplete = input('off');
|
||||
readonly ariaLabel = input<string | null>(null);
|
||||
@@ -102,6 +111,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
readonly validationMessages = input<ValidationMessageMap>({});
|
||||
readonly description = input<string | null>(null);
|
||||
readonly hint = input<string | null>(null);
|
||||
readonly help = input<string | null>(null);
|
||||
readonly labelPosition = input<FormLabelPosition>('top');
|
||||
|
||||
readonly itemSelected = output<TItem>();
|
||||
@@ -118,6 +128,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
readonly activeItem = signal<TItem | null>(null);
|
||||
readonly searchText = signal('');
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly showingPreview = signal(false);
|
||||
readonly formDisabled = signal(false);
|
||||
readonly panelWidth = signal(0);
|
||||
|
||||
@@ -136,21 +147,24 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
readonly message = computed(() => {
|
||||
if (this.loading()) return this.loadingText();
|
||||
if (this.error()) return this.errorText();
|
||||
if (this.showingPreview()) return '';
|
||||
if (this.searchText().trim().length < this.minSearchLength()) {
|
||||
return `${this.typeToSearchText()} (at least ${this.minSearchLength()} ${this.minSearchLength() === 1 ? 'character' : 'characters'})`;
|
||||
}
|
||||
return this.options().length ? '' : this.emptyText();
|
||||
});
|
||||
readonly resolvedInputClass = computed(() => {
|
||||
this.controlStateVersion();
|
||||
const control = this.control();
|
||||
const invalid = !!(control?.invalid && (control.touched || control.dirty || this.submitAttempted()));
|
||||
const invalid = !!(control?.invalid && this.submitAttempted());
|
||||
return [
|
||||
'form-control w-full rounded-sm border-defaultborder text-defaulttextcolor',
|
||||
'ti-form-select w-full rounded-sm border border-defaultborder bg-white text-defaulttextcolor',
|
||||
'dark:border-defaultborder/10 dark:bg-bodybg dark:text-white/70',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
'pe-16',
|
||||
'placeholder:text-textmuted placeholder:opacity-100 dark:placeholder:text-white/50',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none',
|
||||
'pe-10 transition-colors',
|
||||
invalid ? 'is-invalid border-danger' : '',
|
||||
this.isDisabled() ? 'cursor-not-allowed opacity-60' : '',
|
||||
this.isDisabled() ? 'cursor-not-allowed bg-light opacity-60 dark:bg-black/20' : '',
|
||||
this.inputClass()
|
||||
].filter(Boolean).join(' ');
|
||||
});
|
||||
@@ -161,24 +175,49 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.inputTerms$.pipe(
|
||||
map(term => term.trim()),
|
||||
debounce(() => timer(Math.max(0, this.debounceTime()))),
|
||||
distinctUntilChanged(),
|
||||
switchMap(term => {
|
||||
if (term.length < this.minSearchLength()) {
|
||||
return of<SearchResult<TItem>>({ term, options: [], failed: false });
|
||||
effect((onCleanup) => {
|
||||
const control = this.control();
|
||||
if (!control) return;
|
||||
|
||||
const statusSubscription = control.statusChanges.subscribe(() => {
|
||||
this.controlStateVersion.update(value => value + 1);
|
||||
});
|
||||
const valueSubscription = control.valueChanges.subscribe(() => {
|
||||
this.controlStateVersion.update(value => value + 1);
|
||||
});
|
||||
const eventsSubscription = control.events?.subscribe(() => {
|
||||
this.controlStateVersion.update(value => value + 1);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
statusSubscription.unsubscribe();
|
||||
valueSubscription.unsubscribe();
|
||||
eventsSubscription?.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
this.searchRequests$.pipe(
|
||||
map(request => ({ ...request, term: request.term.trim() })),
|
||||
debounce(request => timer(request.preview ? 0 : Math.max(0, this.debounceTime()))),
|
||||
distinctUntilChanged((previous, current) =>
|
||||
previous.term === current.term && previous.preview === current.preview
|
||||
),
|
||||
switchMap(request => {
|
||||
if (!request.preview && request.term.length < this.minSearchLength()) {
|
||||
return of<SearchResult<TItem>>({ ...request, options: [], failed: false });
|
||||
}
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
return this.searchFn()(term, this.limit()).pipe(
|
||||
map(options => ({ term, options, failed: false })),
|
||||
catchError(() => of<SearchResult<TItem>>({ term, options: [], failed: true }))
|
||||
const limit = request.preview ? this.previewLimit() : this.limit();
|
||||
return this.searchFn()(request.term, limit).pipe(
|
||||
map(options => ({ ...request, options, failed: false })),
|
||||
catchError(() => of<SearchResult<TItem>>({ ...request, options: [], failed: true }))
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed()
|
||||
).subscribe(result => {
|
||||
this.loading.set(false);
|
||||
this.showingPreview.set(result.preview && !result.failed);
|
||||
this.options.set(result.options);
|
||||
this.activeIndex.set(-1);
|
||||
this.error.set(result.failed ? this.errorText() : null);
|
||||
@@ -203,7 +242,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
writeValue(value: TValue | null): void {
|
||||
this.formValue = value ?? null;
|
||||
this.labelEdited = false;
|
||||
if (this.formValue === null) {
|
||||
if (!this.hasResolvableValue(this.formValue)) {
|
||||
this.applyResolvedItem(null);
|
||||
return;
|
||||
}
|
||||
@@ -228,17 +267,21 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
const text = event.target.value;
|
||||
const previousText = this.searchText();
|
||||
this.searchText.set(text);
|
||||
this.activeItem.set(null);
|
||||
this.showingPreview.set(false);
|
||||
this.options.set([]);
|
||||
this.labelEdited = this.formValue !== null && text !== previousText;
|
||||
this.searchChanged.emit(text.trim());
|
||||
this.open();
|
||||
this.inputTerms$.next(text);
|
||||
this.searchRequests$.next({ term: text, preview: false });
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
if (this.showDropdownOnFocus()) {
|
||||
this.open();
|
||||
this.inputTerms$.next(this.searchText());
|
||||
}
|
||||
if (this.showDropdownOnFocus()) this.openPreview();
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
if (this.showDropdownOnFocus()) this.openPreview();
|
||||
}
|
||||
|
||||
onBlur(): void {
|
||||
@@ -305,6 +348,19 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
|
||||
optionId(index: number): string { return `${this.resolvedInputId()}-option-${index}`; }
|
||||
optionKey(item: TItem, index: number): string | number { return this.trackBy()?.(item) ?? index; }
|
||||
isOptionSelected(item: TItem): boolean {
|
||||
return this.formValue !== null && this.valuesEqual(this.valueWith()(item), this.formValue);
|
||||
}
|
||||
optionClass(item: TItem, index: number): string {
|
||||
const highlighted = this.activeIndex() === index;
|
||||
const selected = this.isOptionSelected(item);
|
||||
return [
|
||||
'block w-full px-3 py-2 text-start text-[0.8125rem] transition-colors',
|
||||
highlighted || selected
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white text-defaulttextcolor hover:bg-primary hover:text-white dark:bg-bodybg dark:text-white/70 dark:hover:bg-primary dark:hover:text-white'
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
private setActive(index: number): void {
|
||||
if (index < 0 || index >= this.options().length) return;
|
||||
@@ -319,6 +375,7 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
this.options.set([]);
|
||||
this.error.set(null);
|
||||
this.loading.set(false);
|
||||
this.showingPreview.set(false);
|
||||
this.labelEdited = false;
|
||||
this.onChange(null);
|
||||
this.onTouched();
|
||||
@@ -335,4 +392,16 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
}
|
||||
|
||||
private valuesEqual(left: TValue, right: TValue | null): boolean { return Object.is(left, right); }
|
||||
|
||||
private hasResolvableValue(value: TValue | null): value is TValue {
|
||||
return value !== null && (typeof value !== 'string' || value.trim().length > 0);
|
||||
}
|
||||
|
||||
private openPreview(): void {
|
||||
if (this.isDisabled() || this.readonly() || this.isOpen()) return;
|
||||
this.open();
|
||||
if (!this.searchText().trim()) {
|
||||
this.searchRequests$.next({ term: '', preview: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,49 @@
|
||||
<div [class]="resolvedWrapperClass()">
|
||||
@if (showLabel()) {
|
||||
<label [for]="inputId()" [class]="resolvedLabelClass()" >
|
||||
{{ label() }}
|
||||
@if (required()) {
|
||||
<span [class]="requiredClass()" aria-hidden="true" >
|
||||
*
|
||||
</span>
|
||||
<label [for]="inputId()" [class]="resolvedLabelClass()">
|
||||
{{ label() }}
|
||||
@if (required()) {
|
||||
<span [class]="requiredClass()" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
|
||||
<span class="sr-only">
|
||||
Required
|
||||
</span>
|
||||
}
|
||||
</label>
|
||||
<span class="sr-only">
|
||||
Required
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (showHelpIcon()) {
|
||||
|
||||
<button type="button" class="text-muted hover:text-primary" [attr.aria-label]="help()" tooltipVariant="info" [appTooltip]="resolvedHelp()"
|
||||
aria-label="Field help">
|
||||
|
||||
<i class="ti ti-info-circle"></i>
|
||||
|
||||
</button>
|
||||
|
||||
}
|
||||
</label>
|
||||
}
|
||||
|
||||
<div [class]="resolvedContentClass()">
|
||||
@if (description()) {
|
||||
<p [id]="descriptionId()" [class]="descriptionClass()" >
|
||||
{{ description() }}
|
||||
</p>
|
||||
<p [id]="descriptionId()" [class]="descriptionClass()">
|
||||
{{ description() }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<ng-content />
|
||||
|
||||
@if (showHint()) {
|
||||
<p
|
||||
[id]="hintId()"
|
||||
[class]="hintClass()"
|
||||
>
|
||||
{{ hint() }}
|
||||
</p>
|
||||
<p [id]="hintId()" [class]="hintClass()">
|
||||
{{ hint() }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (!hideValidation()) {
|
||||
<app-form-validation-message
|
||||
[id]="validationId()"
|
||||
[fieldName]="label()"
|
||||
[control]="control()"
|
||||
[messages]="validationMessages()"
|
||||
[showWhenDirty]="showValidationWhenDirty()"
|
||||
/>
|
||||
<app-form-validation-message [id]="validationId()" [fieldName]="label()" [control]="control()"
|
||||
[messages]="validationMessages()" [showWhenDirty]="showValidationWhenDirty()"
|
||||
[submitAttempted]="submitAttempted()" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
FormValidationMessage,
|
||||
ValidationMessageMap
|
||||
} from '../form-validation-message/form-validation-message';
|
||||
import { TooltipDirective } from '../../../directives/tooltip/tooltip.directive';
|
||||
|
||||
export type FormLabelPosition = 'top' | 'left' | 'hidden';
|
||||
|
||||
@Component({
|
||||
selector: 'app-form-field',
|
||||
standalone: true,
|
||||
imports: [FormValidationMessage],
|
||||
imports: [FormValidationMessage, TooltipDirective],
|
||||
templateUrl: './form-field.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
@@ -36,6 +37,15 @@ export class FormField {
|
||||
readonly description = input<string | null>(null);
|
||||
|
||||
readonly hint = input<string | null>(null);
|
||||
readonly help = input<string | null>(null);
|
||||
|
||||
readonly showHelpIcon = computed(() => {
|
||||
return !!this.help()?.trim();
|
||||
});
|
||||
|
||||
readonly resolvedHelp = computed(() => {
|
||||
return this.help()?.trim() ?? '';
|
||||
});
|
||||
|
||||
readonly labelPosition = input<FormLabelPosition>('top');
|
||||
|
||||
@@ -120,9 +130,8 @@ export class FormField {
|
||||
}
|
||||
|
||||
return (
|
||||
control.touched ||
|
||||
control.dirty ||
|
||||
this.submitAttempted()
|
||||
this.submitAttempted() ||
|
||||
(this.showValidationWhenDirty() && control.dirty)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[disabled]="isDisabled()"
|
||||
[description]="description()"
|
||||
[hint]="hint()"
|
||||
[help]="help()"
|
||||
[hideValidation]="hideValidation()"
|
||||
[showValidationWhenDirty]="showValidationWhenDirty()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ValidationMessageMap } from '../form-validation-message/form-validation
|
||||
|
||||
export type FormInputType = | 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search';
|
||||
|
||||
export type FormInputMode = | 'none'| 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
||||
export type FormInputMode = | 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
||||
|
||||
export type FormInputIconPosition = 'left' | 'right';
|
||||
|
||||
@@ -28,7 +28,7 @@ export type FormInputIconPosition = 'left' | 'right';
|
||||
export class FormInput implements ControlValueAccessor {
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
|
||||
|
||||
readonly inputId = input.required<string>();
|
||||
readonly label = input.required<string>();
|
||||
|
||||
@@ -54,14 +54,15 @@ export class FormInput implements ControlValueAccessor {
|
||||
|
||||
readonly pattern = input<string | null>(null);
|
||||
|
||||
|
||||
|
||||
readonly min = input<number | null>(null);
|
||||
readonly max = input<number | null>(null);
|
||||
readonly step = input<number | string | null>(null);
|
||||
|
||||
|
||||
|
||||
readonly description = input<string | null>(null);
|
||||
readonly hint = input<string | null>(null);
|
||||
readonly help = input<string | null>(null);
|
||||
|
||||
readonly hideValidation = input(false);
|
||||
readonly showValidationWhenDirty = input(false);
|
||||
@@ -78,25 +79,25 @@ export class FormInput implements ControlValueAccessor {
|
||||
readonly showPasswordToggle = input(true);
|
||||
readonly loading = input(false);
|
||||
|
||||
|
||||
|
||||
readonly wrapperClass = input('');
|
||||
readonly fieldContentClass = input('');
|
||||
readonly labelClass = input('');
|
||||
readonly inputClass = input('');
|
||||
|
||||
|
||||
|
||||
readonly ariaLabel = input<string | null>(null);
|
||||
readonly ariaDescription = input<string | null>(null);
|
||||
|
||||
|
||||
|
||||
readonly value = signal<string | number | null>(null);
|
||||
readonly formDisabled = signal(false);
|
||||
readonly passwordVisible = signal(false);
|
||||
private readonly controlStateVersion = signal(0);
|
||||
|
||||
private onChange: (value: string | number | null) => void = () => {};
|
||||
private onChange: (value: string | number | null) => void = () => { };
|
||||
|
||||
private onTouched: () => void = () => {};
|
||||
private onTouched: () => void = () => { };
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -192,9 +193,8 @@ export class FormInput implements ControlValueAccessor {
|
||||
const showInvalidState = !!(
|
||||
control?.invalid &&
|
||||
(
|
||||
control.touched ||
|
||||
control.dirty ||
|
||||
this.submitAttempted()
|
||||
this.submitAttempted() ||
|
||||
(this.showValidationWhenDirty() && control.dirty)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -228,9 +228,8 @@ export class FormInput implements ControlValueAccessor {
|
||||
if (
|
||||
control?.invalid &&
|
||||
(
|
||||
control.touched ||
|
||||
control.dirty ||
|
||||
this.submitAttempted()
|
||||
this.submitAttempted() ||
|
||||
(this.showValidationWhenDirty() && control.dirty)
|
||||
)
|
||||
) {
|
||||
ids.push(`${this.inputId()}-validation`);
|
||||
|
||||
@@ -367,9 +367,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
if (
|
||||
control?.invalid &&
|
||||
(
|
||||
control.touched ||
|
||||
control.dirty ||
|
||||
this.submitAttempted()
|
||||
this.submitAttempted() ||
|
||||
(this.showValidationWhenDirty() && control.dirty)
|
||||
)
|
||||
) {
|
||||
ids.push(`${this.resolvedInputId()}-validation`);
|
||||
@@ -406,9 +405,8 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
const showInvalidState = !!(
|
||||
control?.invalid &&
|
||||
(
|
||||
control.touched ||
|
||||
control.dirty ||
|
||||
this.submitAttempted()
|
||||
this.submitAttempted() ||
|
||||
(this.showValidationWhenDirty() && control.dirty)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -71,9 +71,8 @@ export class FormValidationMessage {
|
||||
}
|
||||
|
||||
return (
|
||||
control.touched ||
|
||||
control.dirty ||
|
||||
this.submitAttempted()
|
||||
this.submitAttempted() ||
|
||||
(this.showWhenDirty() && control.dirty)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { Directive, ElementRef, HostListener, OnDestroy, inject, input } from '@angular/core';
|
||||
|
||||
import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||
|
||||
import {
|
||||
ComponentRef,
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ConnectedPosition,
|
||||
Overlay,
|
||||
OverlayRef
|
||||
} from '@angular/cdk/overlay';
|
||||
import { ComponentPortal } from '@angular/cdk/portal';
|
||||
|
||||
import { Tooltip } from './tooltip/tooltip';
|
||||
import {
|
||||
Tooltip
|
||||
} from './tooltip/tooltip';
|
||||
import { TooltipVariant } from './tooltip/tooltip';
|
||||
|
||||
export type TooltipPosition =
|
||||
| 'top'
|
||||
@@ -27,20 +41,42 @@ export class TooltipDirective implements OnDestroy {
|
||||
readonly tooltipPosition =
|
||||
input<TooltipPosition>('top');
|
||||
|
||||
readonly tooltipVariant =
|
||||
input<TooltipVariant>('default');
|
||||
|
||||
readonly tooltipDisabled = input(false);
|
||||
|
||||
readonly tooltipDelay = input(200);
|
||||
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
|
||||
private showTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private tooltipComponentRef:
|
||||
ComponentRef<Tooltip> | null = null;
|
||||
|
||||
private showTimeout:
|
||||
ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const text = this.appTooltip();
|
||||
const variant = this.tooltipVariant();
|
||||
|
||||
this.tooltipComponentRef?.setInput('text', text);
|
||||
this.tooltipComponentRef?.setInput(
|
||||
'variant',
|
||||
variant
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('mouseenter')
|
||||
@HostListener('focusin')
|
||||
show(): void {
|
||||
const tooltipText = this.appTooltip().trim();
|
||||
|
||||
if (
|
||||
this.tooltipDisabled() ||
|
||||
!this.appTooltip()
|
||||
tooltipText.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -48,8 +84,9 @@ export class TooltipDirective implements OnDestroy {
|
||||
this.clearTimeout();
|
||||
|
||||
this.showTimeout = setTimeout(() => {
|
||||
this.showTimeout = null;
|
||||
this.openTooltip();
|
||||
}, this.tooltipDelay());
|
||||
}, Math.max(0, this.tooltipDelay()));
|
||||
}
|
||||
|
||||
@HostListener('mouseleave')
|
||||
@@ -60,37 +97,49 @@ export class TooltipDirective implements OnDestroy {
|
||||
}
|
||||
|
||||
private openTooltip(): void {
|
||||
if (this.overlayRef) {
|
||||
if (this.overlayRef?.hasAttached()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const positionStrategy = this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(this.elementRef)
|
||||
.withPositions(this.getPositions());
|
||||
.flexibleConnectedTo(
|
||||
this.elementRef.nativeElement
|
||||
)
|
||||
.withPositions(this.getPositions())
|
||||
.withPush(true);
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
scrollStrategy: this.overlay.scrollStrategies.reposition()
|
||||
scrollStrategy:
|
||||
this.overlay.scrollStrategies.reposition()
|
||||
});
|
||||
|
||||
const portal = new ComponentPortal(Tooltip);
|
||||
|
||||
const componentRef = this.overlayRef.attach(portal);
|
||||
this.tooltipComponentRef =
|
||||
this.overlayRef.attach(portal);
|
||||
|
||||
componentRef.setInput(
|
||||
this.tooltipComponentRef.setInput(
|
||||
'text',
|
||||
this.appTooltip()
|
||||
this.appTooltip().trim()
|
||||
);
|
||||
|
||||
this.tooltipComponentRef.setInput(
|
||||
'variant',
|
||||
this.tooltipVariant()
|
||||
);
|
||||
}
|
||||
|
||||
private closeTooltip(): void {
|
||||
this.overlayRef?.dispose();
|
||||
|
||||
this.overlayRef = null;
|
||||
this.tooltipComponentRef = null;
|
||||
}
|
||||
|
||||
private clearTimeout(): void {
|
||||
if (!this.showTimeout) {
|
||||
if (this.showTimeout === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,6 +159,13 @@ export class TooltipDirective implements OnDestroy {
|
||||
overlayX: 'center',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -8
|
||||
},
|
||||
{
|
||||
originX: 'center',
|
||||
originY: 'bottom',
|
||||
overlayX: 'center',
|
||||
overlayY: 'top',
|
||||
offsetY: 8
|
||||
}
|
||||
],
|
||||
|
||||
@@ -120,6 +176,13 @@ export class TooltipDirective implements OnDestroy {
|
||||
overlayX: 'center',
|
||||
overlayY: 'top',
|
||||
offsetY: 8
|
||||
},
|
||||
{
|
||||
originX: 'center',
|
||||
originY: 'top',
|
||||
overlayX: 'center',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -8
|
||||
}
|
||||
],
|
||||
|
||||
@@ -130,6 +193,13 @@ export class TooltipDirective implements OnDestroy {
|
||||
overlayX: 'end',
|
||||
overlayY: 'center',
|
||||
offsetX: -8
|
||||
},
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'center',
|
||||
overlayX: 'start',
|
||||
overlayY: 'center',
|
||||
offsetX: 8
|
||||
}
|
||||
],
|
||||
|
||||
@@ -140,6 +210,13 @@ export class TooltipDirective implements OnDestroy {
|
||||
overlayX: 'start',
|
||||
overlayY: 'center',
|
||||
offsetX: 8
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'center',
|
||||
overlayX: 'end',
|
||||
overlayY: 'center',
|
||||
offsetX: -8
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div
|
||||
<!-- <div
|
||||
class="
|
||||
pointer-events-none
|
||||
max-w-[250px]
|
||||
max-w-xs
|
||||
whitespace-normal
|
||||
rounded-sm
|
||||
bg-primary
|
||||
@@ -16,4 +16,11 @@
|
||||
role="tooltip"
|
||||
>
|
||||
{{ text() }}
|
||||
</div> -->
|
||||
|
||||
<div
|
||||
[class]="resolvedTooltipClass()"
|
||||
role="tooltip"
|
||||
>
|
||||
{{ text() }}
|
||||
</div>
|
||||
@@ -1,9 +1,15 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input
|
||||
input,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
|
||||
|
||||
export type TooltipVariant =
|
||||
| 'default'
|
||||
| 'info';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tooltip',
|
||||
standalone: true,
|
||||
@@ -12,4 +18,42 @@ import {
|
||||
})
|
||||
export class Tooltip {
|
||||
readonly text = input.required<string>();
|
||||
|
||||
readonly variant =
|
||||
input<TooltipVariant>('default');
|
||||
|
||||
readonly resolvedTooltipClass = computed(() => {
|
||||
const baseClasses = [
|
||||
'pointer-events-none',
|
||||
'max-w-xs',
|
||||
'whitespace-normal',
|
||||
'rounded-sm',
|
||||
'px-2',
|
||||
'py-1',
|
||||
'text-xs',
|
||||
'font-medium',
|
||||
'leading-4'
|
||||
];
|
||||
|
||||
const variantClasses =
|
||||
this.variant() === 'info'
|
||||
? [
|
||||
'border',
|
||||
'border-defaultborder',
|
||||
'bg-white',
|
||||
'text-defaulttextcolor',
|
||||
'shadow-lg',
|
||||
'dark:bg-bodybg'
|
||||
]
|
||||
: [
|
||||
'bg-primary',
|
||||
'text-white',
|
||||
'shadow-sm'
|
||||
];
|
||||
|
||||
return [
|
||||
...baseClasses,
|
||||
...variantClasses
|
||||
].join(' ');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user