Merge pull request 'multi-selection filter enable in all screen, icon added in sidebr sub-menus, remove un-used components, add border on left of row on hover, on click same open menu page reload' (#19) from feature/onboarding-workflow-sc into dev

Reviewed-on: sc/syscom-master-admin-ui#19
This commit is contained in:
2026-08-14 08:19:00 +00:00
69 changed files with 562 additions and 467 deletions
Binary file not shown.
+3 -1
View File
@@ -1,5 +1,5 @@
import { ApplicationConfig, importProvidersFrom } from '@angular/core'; import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideRouter, withRouterConfig } from '@angular/router'; import { provideRouter, withRouterConfig, RouteReuseStrategy } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations'; import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { App_Route } from './app.routes'; import { App_Route } from './app.routes';
@@ -8,10 +8,12 @@ import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
import { authInterceptor } from './core/interceptors/auth.interceptor'; import { authInterceptor } from './core/interceptors/auth.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor'; import { errorInterceptor } from './core/interceptors/error.interceptor';
import { loadingInterceptor } from './core/interceptors/loading-interceptor'; import { loadingInterceptor } from './core/interceptors/loading-interceptor';
import { ReloadRouteReuseStrategy } from './core/strategies/reload-route-reuse.strategy';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideRouter(App_Route, withRouterConfig({ onSameUrlNavigation: 'reload' })), provideRouter(App_Route, withRouterConfig({ onSameUrlNavigation: 'reload' })),
{ provide: RouteReuseStrategy, useClass: ReloadRouteReuseStrategy },
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor,loadingInterceptor])), provideHttpClient(withInterceptors([authInterceptor, errorInterceptor,loadingInterceptor])),
provideAnimations(), provideAnimations(),
provideCharts(withDefaultRegisterables()), provideCharts(withDefaultRegisterables()),
+19 -19
View File
@@ -12,7 +12,7 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false, selected: false,
dirchange: false, dirchange: false,
children: [ children: [
{ path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false }, { path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false, icon: '<i class="bx bx-line-chart side-menu__icon"></i>' },
], ],
}, },
//{ headTitle: 'SAAS ADMIN' }, //{ headTitle: 'SAAS ADMIN' },
@@ -24,15 +24,15 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false, selected: false,
dirchange: false, dirchange: false,
children: [ children: [
{ path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false }, { path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false, icon: '<i class="bx bx-coin-stack side-menu__icon"></i>' },
{ path: '/global-masters/exchange-rates', title: 'Exchange Rates (ROE)', type: 'link', dirchange: false }, { path: '/global-masters/exchange-rates', title: 'Exchange Rates', type: 'link', dirchange: false, icon: '<i class="bx bx-trending-up side-menu__icon"></i>' },
{ path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false }, { path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false, icon: '<i class="bx bx-world side-menu__icon"></i>' },
{ path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false }, { path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false, icon: '<i class="bx bx-time-five side-menu__icon"></i>' },
{ path: '/global-masters/regions', title: 'Region', type: 'link', dirchange: false }, { path: '/global-masters/regions', title: 'Region', type: 'link', dirchange: false, icon: '<i class="bx bx-map-alt side-menu__icon"></i>' },
{ path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false }, { path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false, icon: '<i class="bx bx-flag side-menu__icon"></i>' },
{ path: '/global-masters/states', title: 'State', type: 'link', dirchange: false }, { path: '/global-masters/states', title: 'State', type: 'link', dirchange: false, icon: '<i class="bx bx-map side-menu__icon"></i>' },
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false }, { path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false, icon: '<i class="bx bx-building-house side-menu__icon"></i>' },
{ path: '/global-masters/plans', title: 'Plans', type: 'link', dirchange: false }, { path: '/global-masters/plans', title: 'Plans', type: 'link', dirchange: false, icon: '<i class="bx bx-package side-menu__icon"></i>' },
// { // {
// title: 'Tenant Master', // title: 'Tenant Master',
@@ -55,10 +55,10 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false, selected: false,
dirchange: false, dirchange: false,
children: [ children: [
{ path: '/organizations', title: 'Dashboard', type: 'link', dirchange: false }, { path: '/organizations', title: 'Dashboard', type: 'link', dirchange: false, icon: '<i class="bx bx-grid-alt side-menu__icon"></i>' },
{ path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false }, { path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false, icon: '<i class="bx bx-list-ul side-menu__icon"></i>' },
{ path: '/organizations/awaiting-database', title: 'Assign Database & Activate', type: 'link', dirchange: false }, { path: '/organizations/awaiting-database', title: 'Assign Database', type: 'link', dirchange: false, icon: '<i class="bx bx-data side-menu__icon"></i>' },
{ path: '/organizations/tenant-domains', title: 'Tenant Domains', type: 'link', dirchange: false }, { path: '/organizations/tenant-domains', title: 'Tenant Domains', type: 'link', dirchange: false, icon: '<i class="bx bx-globe-alt side-menu__icon"></i>' },
], ],
}, },
{ {
@@ -69,8 +69,8 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false, selected: false,
dirchange: false, dirchange: false,
children: [ children: [
{ path: '/localization/translation-keys', title: 'Translations Keys', type: 'link', dirchange: false }, { path: '/localization/translation-keys', title: 'Translations Keys', type: 'link', dirchange: false, icon: '<i class="bx bx-key side-menu__icon"></i>' },
{ path: '/localization/translations-manager', title: 'Translations Manager', type: 'link', dirchange: false }, { path: '/localization/translations-manager', title: 'Translations', type: 'link', dirchange: false, icon: '<i class="bx bx-book-content side-menu__icon"></i>' },
], ],
}, },
{ {
@@ -81,7 +81,7 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false, selected: false,
dirchange: false, dirchange: false,
children: [ children: [
{ path: '/billing/plans-subscriptions', title: 'Plans & Subscriptions', type: 'link', dirchange: false }, { path: '/billing/plans-subscriptions', title: 'Subscriptions', type: 'link', dirchange: false, icon: '<i class="bx bx-receipt side-menu__icon"></i>' },
], ],
}, },
{ {
@@ -92,8 +92,8 @@ export const SAAS_MENU_DATA: MenuContext = {
selected: false, selected: false,
dirchange: false, dirchange: false,
children: [ children: [
{ path: '/settings/branding', title: 'Branding', type: 'link', dirchange: false }, { path: '/settings/branding', title: 'Branding', type: 'link', dirchange: false, icon: '<i class="bx bx-palette side-menu__icon"></i>' },
{ path: '/settings/db-connections', title: 'Database Connections', type: 'link', dirchange: false }, { path: '/settings/db-connections', title: 'Database Connections', type: 'link', dirchange: false, icon: '<i class="bx bx-server side-menu__icon"></i>' },
], ],
}, },
], ],
@@ -0,0 +1,9 @@
import { Injectable } from '@angular/core';
import { BaseRouteReuseStrategy } from '@angular/router';
@Injectable()
export class ReloadRouteReuseStrategy extends BaseRouteReuseStrategy {
override shouldReuseRoute(): boolean {
return false;
}
}
@@ -7,6 +7,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Tenant Subscriptions" tableTitle="Tenant Subscriptions"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -117,21 +117,21 @@ export class PlansSubscriptions implements OnInit {
}); });
readonly columns = signal<DataTableColumn<SubscriptionTableRow>[]>([ readonly columns = signal<DataTableColumn<SubscriptionTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '6%' },
{ key: 'tenantName', label: 'Tenant / Organization', header: 'Tenant / Organization', sortable: true, align: 'left' }, { key: 'tenantName', label: 'Tenant / Organization', header: 'Tenant / Organization', sortable: true, align: 'left', width: '24%' , headerAlign: 'left'},
{ {
key: 'planName', label: 'Plan', header: 'Plan', sortable: true, align: 'left', key: 'planName', label: 'Plan', header: 'Plan', sortable: true, align: 'left', headerAlign: 'left', width: '18%',
formatter: (value, row) => `${row.planCode ? row.planCode + ' - ' : ''}${value ?? '—'}` formatter: (value, row) => `${row.planCode ? row.planCode + ' - ' : ''}${value ?? '—'}`
}, },
{ {
key: 'status', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'status', label: 'Status', header: 'Status', sortable: true, badge: true, headerAlign: 'center', align: 'center', width: '12%',
badgeClass: value => subscriptionStatusBadgeClass(value as SubscriptionStatus), badgeClass: value => subscriptionStatusBadgeClass(value as SubscriptionStatus),
formatter: value => SUBSCRIPTION_STATUS_LABELS[value as SubscriptionStatus] ?? '—' formatter: value => SUBSCRIPTION_STATUS_LABELS[value as SubscriptionStatus] ?? '—'
}, },
{ key: 'startsOn', label: 'Starts On', header: 'Starts On', sortable: true, align: 'left', formatter: value => formatDate(value as string) }, { key: 'startsOn', label: 'Starts On', header: 'Starts On', sortable: true,headerAlign: 'center', align: 'center', width: '13%', formatter: value => formatDate(value as string) },
{ key: 'endsOn', label: 'Ends On', header: 'Ends On', sortable: true, align: 'left', formatter: value => formatDate(value as string | null) }, { key: 'endsOn', label: 'Ends On', header: 'Ends On', sortable: true, headerAlign: 'center', align: 'center', width: '13%', formatter: value => formatDate(value as string | null) },
{ {
key: 'isActive', label: 'Active', header: 'Active', sortable: true, badge: true, key: 'isActive', label: 'Active', header: 'Active', sortable: true, badge: true, headerAlign: 'center', align: 'center', width: '10%',
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Yes' : 'No' formatter: value => value ? 'Yes' : 'No'
} }
@@ -154,6 +154,7 @@ export class PlansSubscriptions implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'tenantName' },
fetcher: query => this.subscriptionApi.getSubscriptionDataTable(query), fetcher: query => this.subscriptionApi.getSubscriptionDataTable(query),
mapRow: (sub, serialNumber) => ({ mapRow: (sub, serialNumber) => ({
id: sub.id, id: sub.id,
@@ -20,12 +20,16 @@ export class CityService {
getCityDataTable( getCityDataTable(
query: DataTableQuery, query: DataTableQuery,
countryId: string | null = null, countryIds: readonly string[] = [],
stateId: string | null = null stateIds: readonly string[] = []
): Observable<DataTableResult<CityDto>> { ): Observable<DataTableResult<CityDto>> {
let params = new HttpParams(); let params = new HttpParams();
if (countryId) params = params.set('countryId', countryId); for (const countryId of countryIds) {
if (stateId) params = params.set('stateId', stateId); params = params.append('countryIds', countryId);
}
for (const stateId of stateIds) {
params = params.append('stateIds', stateId);
}
return this.http.post<DataTableResult<CityDto>>( return this.http.post<DataTableResult<CityDto>>(
CITY_ENDPOINTS.dataTable, CITY_ENDPOINTS.dataTable,
query, query,
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Cities" tableTitle="Cities"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -24,46 +26,44 @@
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter($event)" autocomplete="off" <form [formGroup]="filterForm" (ngSubmit)="onApplyFilter($event)" autocomplete="off"
class="flex flex-wrap items-end gap-3 w-full"> class="flex flex-wrap items-end gap-3 w-full">
<div class="w-64 min-w-[200px]"> <div class="w-64 min-w-[200px]">
<app-autocomplete <app-form-select
formControlName="countryId" formControlName="countryId"
inputId="city-country-filter" inputId="city-country-filter"
variant="floating" variant="floating"
size="sm" size="sm"
label="Country" label="Country"
placeholder="Search country" placeholder="All Countries"
[searchFn]="filterCountrySearchFn" mode="multiple"
[valueWith]="filterCountryValueFn" searchMode="server"
[displayWith]="filterCountryDisplayFn" [searchable]="true"
[selectedItem]="selectedCountry()" [options]="countryOptions()"
[minSearchLength]="0" [loading]="countryOptionsLoading()"
[debounceTime]="300"
[limit]="50"
[clearable]="true" [clearable]="true"
[hideValidation]="true" [hideValidation]="true"
appendTo="body"
wrapperClass="!mb-0 w-full" wrapperClass="!mb-0 w-full"
(itemSelected)="onFilterCountryChanged($event)" (searchChanged)="onCountrySearchChanged($event.term)"
/> />
</div> </div>
<div class="w-64 min-w-[200px]"> <div class="w-64 min-w-[200px]">
<app-autocomplete <app-form-select
formControlName="stateId" formControlName="stateId"
inputId="city-state-filter" inputId="city-state-filter"
variant="floating" variant="floating"
size="sm" size="sm"
label="State" label="State"
placeholder="Search state" placeholder="All States"
[searchFn]="filterStateSearchFn" mode="multiple"
[valueWith]="filterStateValueFn" searchMode="server"
[displayWith]="filterStateDisplayFn" [searchable]="true"
[selectedItem]="selectedFilterState()" [options]="stateOptions()"
[minSearchLength]="0" [loading]="stateOptionsLoading()"
[debounceTime]="300"
[limit]="50"
[clearable]="true" [clearable]="true"
[hideValidation]="true" [hideValidation]="true"
appendTo="body"
wrapperClass="!mb-0 w-full" wrapperClass="!mb-0 w-full"
(itemSelected)="onFilterStateChanged($event)" (searchChanged)="onStateSearchChanged($event.term)"
/> />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -1,13 +1,11 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { of } from 'rxjs'; import { Subject, of } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, finalize, map, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service'; import { NotificationService } from '../../../../../core/services/common/notification.service';
import { CityDto, UpdateCityRequest } from '../../models/city.model'; import { CityDto, UpdateCityRequest } from '../../models/city.model';
import { CountryLookupDto } from '../../../countries/models/country.model';
import { StateLookupDto } from '../../../states/models/state.model';
import { CityService } from '../../data-access/city.service'; import { CityService } from '../../data-access/city.service';
import { CountryService } from '../../../countries/data-access/country.service'; import { CountryService } from '../../../countries/data-access/country.service';
import { StateService } from '../../../states/data-access/state.service'; import { StateService } from '../../../states/data-access/state.service';
@@ -19,12 +17,8 @@ import {
DataTableColumn, DataTableColumn,
DataTableRecord DataTableRecord
} from '../../../../../shared/components/data-table/data-table.types'; } from '../../../../../shared/components/data-table/data-table.types';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
import { import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
AutocompleteDisplayFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { Button } from '../../../../../shared/components/button/button'; import { Button } from '../../../../../shared/components/button/button';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
import { CityFormModalComponent } from '../../components/city-form-modal/city-form-modal'; import { CityFormModalComponent } from '../../components/city-form-modal/city-form-modal';
@@ -52,7 +46,7 @@ interface CityTableRow extends DataTableRecord {
DataTable, DataTable,
DataTableToolbarDirective, DataTableToolbarDirective,
ReactiveFormsModule, ReactiveFormsModule,
Autocomplete, FormSelect,
Button, Button,
ConfirmDialog, ConfirmDialog,
CityFormModalComponent CityFormModalComponent
@@ -70,8 +64,6 @@ export class CityList implements OnInit {
private readonly notification = inject(NotificationService); private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<CityDto, CityTableRow>); readonly tableStore = inject(DataTableStore<CityDto, CityTableRow>);
readonly selectedCountry = signal<CountryLookupDto | null>(null);
readonly selectedFilterState = signal<StateLookupDto | null>(null);
readonly statusChangingId = signal<string | null>(null); readonly statusChangingId = signal<string | null>(null);
readonly deletingId = signal<string | null>(null); readonly deletingId = signal<string | null>(null);
readonly pendingDeleteCity = signal<CityTableRow | null>(null); readonly pendingDeleteCity = signal<CityTableRow | null>(null);
@@ -80,18 +72,25 @@ export class CityList implements OnInit {
readonly showFilters = signal(false); readonly showFilters = signal(false);
readonly filterForm = this.formBuilder.nonNullable.group({ readonly filterForm = this.formBuilder.nonNullable.group({
countryId: [''], countryId: [[] as string[]],
stateId: [{ value: '', disabled: true }] stateId: [{ value: [] as string[], disabled: true }]
}); });
readonly countryOptions = signal<FormSelectOption<string>[]>([]);
readonly countryOptionsLoading = signal(false);
readonly stateOptions = signal<FormSelectOption<string>[]>([]);
readonly stateOptionsLoading = signal(false);
private readonly countrySearch$ = new Subject<string>();
private readonly stateSearch$ = new Subject<string>();
readonly columns = signal<DataTableColumn<CityTableRow>[]>([ readonly columns = signal<DataTableColumn<CityTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '8%' },
{ key: 'name', label: 'City Name', header: 'City Name', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'name', label: 'City Name', header: 'City Name', sortable: true, headerAlign: 'left', align: 'left', width: '30%' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' }, //{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' },
{ key: 'stateName', label: 'State', header: 'State', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'stateName', label: 'State', header: 'State', sortable: true, headerAlign: 'left', align: 'left', width: '22%' },
{ key: 'countryName', label: 'Country', header: 'Country', sortable: true, headerAlign: 'center', align: 'center' }, { key: 'countryName', label: 'Country', header: 'Country', sortable: true, headerAlign: 'left', align: 'left', width: '22%' },
{ {
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, headerAlign: 'left', align: 'left', width: '18%',
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive' formatter: value => value ? 'Active' : 'Inactive'
} }
@@ -113,25 +112,57 @@ export class CityList implements OnInit {
} }
]); ]);
readonly filterCountrySearchFn: AutocompleteSearchFn<CountryLookupDto> = (term, page) =>
this.countryApi.autocomplete(term, page);
readonly filterCountryValueFn: AutocompleteValueFn<CountryLookupDto, string> = c => c.id;
readonly filterCountryDisplayFn: AutocompleteDisplayFn<CountryLookupDto> = c => c.name;
readonly filterStateSearchFn: AutocompleteSearchFn<StateLookupDto> = (term, page) => {
const countryId = this.filterForm.controls.countryId.value || this.selectedCountry()?.id || '';
if (!countryId) return of([]);
return this.stateApi.autocomplete(countryId, term || '', page);
};
readonly filterStateValueFn: AutocompleteValueFn<StateLookupDto, string> = s => s.id;
readonly filterStateDisplayFn: AutocompleteDisplayFn<StateLookupDto> = s => s.name;
ngOnInit(): void { ngOnInit(): void {
this.countrySearch$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => {
this.countryOptionsLoading.set(true);
return this.countryApi.autocomplete(term, 50);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(countries => {
this.countryOptionsLoading.set(false);
this.countryOptions.set(countries.map(country => ({ value: country.id, label: country.name })));
});
this.countrySearch$.next('');
this.stateSearch$.pipe(
debounceTime(300),
map(term => ({ term, countryIds: this.filterForm.controls.countryId.value })),
distinctUntilChanged((previous, current) =>
previous.term === current.term && previous.countryIds.join(',') === current.countryIds.join(',')
),
switchMap(({ term, countryIds }) => {
if (!countryIds.length) return of([]);
this.stateOptionsLoading.set(true);
return this.stateApi.autocomplete(countryIds, term, 50);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(states => {
this.stateOptionsLoading.set(false);
this.stateOptions.set(states.map(state => ({ value: state.id, label: state.name })));
});
this.filterForm.controls.countryId.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(countryIds => {
this.filterForm.controls.stateId.setValue([], { emitEvent: false });
this.stateOptions.set([]);
if (countryIds.length) {
this.filterForm.controls.stateId.enable({ emitEvent: false });
this.stateSearch$.next('');
} else {
this.filterForm.controls.stateId.disable({ emitEvent: false });
}
});
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'name' },
fetcher: query => { fetcher: query => {
const countryId = this.filterForm.controls.countryId.value || null; const countryIds = this.filterForm.controls.countryId.value;
const stateId = this.filterForm.controls.stateId.value || null; const stateIds = this.filterForm.controls.stateId.value;
return this.cityApi.getCityDataTable(query, countryId, stateId); return this.cityApi.getCityDataTable(query, countryIds, stateIds);
}, },
mapRow: (city, serialNumber) => { mapRow: (city, serialNumber) => {
const resolvedStateName = city.stateName const resolvedStateName = city.stateName
@@ -160,20 +191,12 @@ export class CityList implements OnInit {
}); });
} }
onFilterCountryChanged(country: CountryLookupDto | null): void { onCountrySearchChanged(term: string): void {
this.selectedCountry.set(country); this.countrySearch$.next(term);
this.filterForm.controls.countryId.setValue(country ? country.id : '');
this.filterForm.controls.stateId.setValue('');
this.selectedFilterState.set(null);
if (country) {
this.filterForm.controls.stateId.enable();
} else {
this.filterForm.controls.stateId.disable();
}
} }
onFilterStateChanged(state: StateLookupDto | null): void { onStateSearchChanged(term: string): void {
this.selectedFilterState.set(state); this.stateSearch$.next(term);
} }
onApplyFilter(event?: Event): void { onApplyFilter(event?: Event): void {
@@ -186,10 +209,9 @@ export class CityList implements OnInit {
} }
onResetFilter(): void { onResetFilter(): void {
this.filterForm.reset({ countryId: '', stateId: '' }); this.filterForm.reset({ countryId: [], stateId: [] });
this.filterForm.controls.stateId.disable(); this.filterForm.controls.stateId.disable();
this.selectedCountry.set(null); this.stateOptions.set([]);
this.selectedFilterState.set(null);
this.tableStore.reset(); this.tableStore.reset();
} }
@@ -21,8 +21,11 @@ import { COUNTRY_ENDPOINTS } from './country.endpoints';
export class CountryService { export class CountryService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
getCountryDataTable(query: DataTableQuery, regionId: string | null = null): Observable<DataTableResult<CountryDto>> { getCountryDataTable(query: DataTableQuery, regionIds: readonly string[] = []): Observable<DataTableResult<CountryDto>> {
const params = regionId ? new HttpParams().set('regionId', regionId) : undefined; let params = new HttpParams();
for (const regionId of regionIds) {
params = params.append('regionIds', regionId);
}
return this.http.post<DataTableResult<CountryDto>>(COUNTRY_ENDPOINTS.dataTable, query, { params }); return this.http.post<DataTableResult<CountryDto>>(COUNTRY_ENDPOINTS.dataTable, query, { params });
} }
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Countries" tableTitle="Countries"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -25,11 +27,10 @@
<form [formGroup]="regionFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off" <form [formGroup]="regionFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off"
class="flex flex-wrap items-end gap-3 w-full"> class="flex flex-wrap items-end gap-3 w-full">
<div class="w-64 min-w-[200px]"> <div class="w-64 min-w-[200px]">
<app-autocomplete formControlName="regionId" inputId="country-region-filter" variant="floating" size="sm" <app-form-select formControlName="regionId" inputId="country-region-filter" variant="floating" size="sm"
label="Region" placeholder="Search region" [searchFn]="searchRegions" [displayWith]="displayRegion" label="Region" placeholder="All Regions" mode="multiple" searchMode="server" [searchable]="true"
[valueWith]="regionValue" [selectedItem]="selectedRegionLookup()" [minSearchLength]="1" [debounceTime]="300" [options]="regionOptions()" [loading]="regionOptionsLoading()" [clearable]="true" [hideValidation]="true"
[limit]="50" [clearable]="true" [hideValidation]="true" wrapperClass="!mb-0 w-full" appendTo="body" wrapperClass="!mb-0 w-full" (searchChanged)="onRegionSearchChanged($event.term)" />
(itemSelected)="onFilterRegionSelected($event)" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<app-button action="custom" label="Apply" icon="ti ti-filter" variant="primary-full" type="submit" size="sm" <app-button action="custom" label="Apply" icon="ti ti-filter" variant="primary-full" type="submit" size="sm"
@@ -49,7 +50,7 @@
(error)="onFlagError($event)" (error)="onFlagError($event)"
/> />
} }
<span class="font-semibold">{{ value }}</span> <span>{{ value }}</span>
</div> </div>
</ng-template> </ng-template>
</app-data-table> </app-data-table>
@@ -1,12 +1,13 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { finalize, map } from 'rxjs/operators'; import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, finalize, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service'; import { NotificationService } from '../../../../../core/services/common/notification.service';
import { CountryDto, UpdateCountryRequest } from '../../models/country.model'; import { CountryDto, UpdateCountryRequest } from '../../models/country.model';
import { CountryService } from '../../data-access/country.service'; import { CountryService } from '../../data-access/country.service';
import { RegionLookupDto, RegionService } from '../../../regions/public-api'; import { RegionService } from '../../../regions/public-api';
import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table'; import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table';
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
import { import {
@@ -16,13 +17,8 @@ import {
DataTableRecord DataTableRecord
} from '../../../../../shared/components/data-table/data-table.types'; } from '../../../../../shared/components/data-table/data-table.types';
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
import { import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
import { Button as AppButton } from '../../../../../shared/components/button/button'; import { Button as AppButton } from '../../../../../shared/components/button/button';
import { CountryFormModalComponent } from '../../components/country-form-modal/country-form-modal'; import { CountryFormModalComponent } from '../../components/country-form-modal/country-form-modal';
@@ -51,7 +47,7 @@ interface CountryTableRow extends DataTableRecord {
DataTableToolbarDirective, DataTableToolbarDirective,
DataTableCellDirective, DataTableCellDirective,
ReactiveFormsModule, ReactiveFormsModule,
Autocomplete, FormSelect,
ConfirmDialog, ConfirmDialog,
AppButton, AppButton,
CountryFormModalComponent CountryFormModalComponent
@@ -74,41 +70,35 @@ export class CountryList implements OnInit {
readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly showFilters = signal(false); readonly showFilters = signal(false);
readonly selectedRegionLookup = signal<RegionLookupDto | null>(null);
readonly regionFilterForm = this.formBuilder.nonNullable.group({ readonly regionFilterForm = this.formBuilder.nonNullable.group({
regionId: [''] regionId: [[] as string[]]
}); });
readonly searchRegions: AutocompleteSearchFn<RegionLookupDto> = (term, limit) => readonly regionOptions = signal<FormSelectOption<string>[]>([]);
this.regionApi.autocomplete(term, limit); readonly regionOptionsLoading = signal(false);
readonly displayRegion: AutocompleteDisplayFn<RegionLookupDto> = region => region.name; private readonly regionSearch$ = new Subject<string>();
readonly regionValue: AutocompleteValueFn<RegionLookupDto, string> = region => region.id;
readonly resolveRegion: AutocompleteResolveValueFn<RegionLookupDto, string> = value =>
this.regionApi.getRegionById(value).pipe(
map(region => ({ id: region.id, code: region.code, name: region.name }))
);
readonly columns = signal<DataTableColumn<CountryTableRow>[]>([ readonly columns = signal<DataTableColumn<CountryTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '6%' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'left', align: 'left', width: '24%' },
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true, { key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true, headerAlign: 'center', align: 'center', width: '10%', badge: true,
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary'
}, },
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true, { key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true, headerAlign: 'center', align: 'center', width: '10%', badge: true,
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary'
}, },
{ {
key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true, headerAlign: 'center', align: 'center', width: '13%', badge: true,
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : '', badgeClass: value => value ? 'badge bg-primary/10 text-primary' : '',
formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—'
}, },
{ {
key: 'regionName', label: 'Region', header: 'Region', sortable: false, headerAlign: 'center', align: 'center', key: 'regionName', label: 'Region', header: 'Region', sortable: false, headerAlign: 'center', align: 'center', width: '18%',
formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—'
}, },
{ {
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, headerAlign: 'center', align: 'center', width: '11%',
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive' formatter: value => value ? 'Active' : 'Inactive'
} }
@@ -131,17 +121,31 @@ export class CountryList implements OnInit {
]); ]);
ngOnInit(): void { ngOnInit(): void {
this.regionSearch$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => {
this.regionOptionsLoading.set(true);
return this.regionApi.autocomplete(term, 50);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(regions => {
this.regionOptionsLoading.set(false);
this.regionOptions.set(regions.map(region => ({ value: region.id, label: region.name })));
});
this.regionSearch$.next('');
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'name' },
fetcher: query => { fetcher: query => {
const regionId = this.regionFilterForm.controls.regionId.value || null; const regionIds = this.regionFilterForm.controls.regionId.value;
return this.countryApi.getCountryDataTable(query, regionId); return this.countryApi.getCountryDataTable(query, regionIds);
} }
}); });
} }
onFilterRegionSelected(region: RegionLookupDto | null): void { onRegionSearchChanged(term: string): void {
this.selectedRegionLookup.set(region); this.regionSearch$.next(term);
this.regionFilterForm.controls.regionId.setValue(region ? region.id : '');
} }
onApplyFilter(): void { onApplyFilter(): void {
@@ -149,8 +153,7 @@ export class CountryList implements OnInit {
} }
onResetFilter(): void { onResetFilter(): void {
this.regionFilterForm.reset({ regionId: '' }); this.regionFilterForm.reset({ regionId: [] });
this.selectedRegionLookup.set(null);
this.tableStore.reset(); this.tableStore.reset();
} }
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Currencies Management" tableTitle="Currencies Management"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -84,14 +84,15 @@ export class CurrencyList implements OnInit {
private iso2TooltipCloseTimer: ReturnType<typeof setTimeout> | null = null; private iso2TooltipCloseTimer: ReturnType<typeof setTimeout> | null = null;
readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([ readonly columns = signal<DataTableColumn<CurrencyTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '6%' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '90px' }, { key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '9%' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'left', align: 'left', width: '28%' },
{ key: 'iso2', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'center', align: 'left' }, { key: 'iso2', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'left', align: 'left', width: '17%' },
{ key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: false, headerAlign: 'center', align: 'center', width: '90px' }, { key: 'symbol', label: 'Symbol', header: 'Symbol', sortable: false, headerAlign: 'center', align: 'center', width: '9%' },
{ key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true, headerAlign: 'center', align: 'center', width: '130px' }, { key: 'numericCode', label: 'Numeric Code', header: 'Numeric Code', sortable: true, headerAlign: 'center', align: 'center', width: '13%' },
{ {
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
headerAlign: 'center', align: 'center', width: '11%',
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive' formatter: value => value ? 'Active' : 'Inactive'
} }
@@ -114,9 +115,12 @@ export class CurrencyList implements OnInit {
]); ]);
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'code' },
fetcher: query => this.currencyApi.getCurrencyDataTable(query) fetcher: query => this.currencyApi.getCurrencyDataTable(query)
}); });
} }
onAddCurrency(): void { onAddCurrency(): void {
@@ -1,6 +1,8 @@
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()" <app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
[totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()" [totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" tableTitle="Exchange Rates (ROE)" buttonTitle="Add" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()" [initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Exchange Rates (ROE)" buttonTitle="Add"
[showSearch]="true" [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()" [showSearch]="true" [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()"
searchPlaceholder="Search pair..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()" searchPlaceholder="Search pair..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()"
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)" (searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
@@ -26,21 +28,21 @@
<!-- Custom From Currency Cell --> <!-- Custom From Currency Cell -->
<ng-template appDataTableCell="fromCurrencyCode" let-row let-value="value"> <ng-template appDataTableCell="fromCurrencyCode" let-row let-value="value">
<span class="font-bold text-gray-800 dark:text-gray-200 tracking-wide"> <span class="text-gray-800 dark:text-gray-200 tracking-wide">
{{ getCurrencyDisplay(row.fromCurrencyId, value) }} {{ getCurrencyDisplay(row.fromCurrencyId, value) }}
</span> </span>
</ng-template> </ng-template>
<!-- Custom To Currency Cell --> <!-- Custom To Currency Cell -->
<ng-template appDataTableCell="toCurrencyCode" let-row let-value="value"> <ng-template appDataTableCell="toCurrencyCode" let-row let-value="value">
<span class="font-bold text-gray-800 dark:text-gray-200 tracking-wide"> <span class="text-gray-800 dark:text-gray-200 tracking-wide">
{{ getCurrencyDisplay(row.toCurrencyId, value) }} {{ getCurrencyDisplay(row.toCurrencyId, value) }}
</span> </span>
</ng-template> </ng-template>
<!-- Custom Rate Cell --> <!-- Custom Rate Cell -->
<ng-template appDataTableCell="rate" let-row let-value="value"> <ng-template appDataTableCell="rate" let-row let-value="value">
<span class="font-mono font-semibold text-primary dark:text-primary-light"> <span class="font-mono text-primary dark:text-primary-light">
{{ value | number:'1.4-4' }} {{ value | number:'1.4-4' }}
</span> </span>
</ng-template> </ng-template>
@@ -87,15 +87,15 @@ export class ExchangeRateList implements OnInit {
}); });
readonly columns = signal<DataTableColumn<ExchangeRateTableRow>[]>([ readonly columns = signal<DataTableColumn<ExchangeRateTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '6%', align: 'center', headerAlign: 'center' },
{ key: 'fromCurrencyCode', label: 'From', header: 'From', sortable: true, headerAlign: 'center', align: 'center', width:'200px'}, { key: 'fromCurrencyCode', label: 'From', header: 'From', sortable: true, headerAlign: 'center', align: 'center', width: '10%' },
{ key: 'toCurrencyCode', label: 'To', header: 'To', sortable: true, headerAlign: 'center', align: 'center', width:'200px'}, { key: 'toCurrencyCode', label: 'To', header: 'To', sortable: true, headerAlign: 'center', align: 'center', width: '10%' },
{ key: 'rate', label: 'Rate', header: 'Rate', sortable: true, headerAlign: 'right', align: 'right' }, { key: 'rate', label: 'Rate', header: 'Rate', sortable: true, headerAlign: 'right', align: 'right', width: '11%' },
{ key: 'rateType', label: 'Type', header: 'Type', sortable: true, headerAlign: 'center', align: 'center' }, { key: 'rateType', label: 'Type', header: 'Type', sortable: true, headerAlign: 'center', align: 'center', width: '11%' },
{ key: 'source', label: 'Source', header: 'Source', sortable: true, headerAlign: 'center', align: 'center' }, { key: 'source', label: 'Source', header: 'Source', sortable: true, headerAlign: 'center', align: 'center', width: '11%' },
{ key: 'effectiveFrom', label: 'Effective From', header: 'Effective From', sortable: true, headerAlign: 'center', align: 'center'}, { key: 'effectiveFrom', label: 'Effective From', header: 'Effective From', sortable: true, headerAlign: 'center', align: 'center', width: '13%' },
{ key: 'effectiveTo', label: 'Effective To', header: 'Effective To', sortable: true, headerAlign: 'center', align: 'center' }, { key: 'effectiveTo', label: 'Effective To', header: 'Effective To', sortable: true, headerAlign: 'center', align: 'center', width: '13%' },
{ key: 'isActive', label: 'Status', header: 'Status', sortable: true, headerAlign: 'center', align: 'center' } { key: 'isActive', label: 'Status', header: 'Status', sortable: true, headerAlign: 'center', align: 'center', width: '11%' }
]); ]);
readonly actions = signal<DataTableAction<ExchangeRateTableRow>[]>([ readonly actions = signal<DataTableAction<ExchangeRateTableRow>[]>([
@@ -114,6 +114,7 @@ export class ExchangeRateList implements OnInit {
this.loadCurrencyLookup(); this.loadCurrencyLookup();
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'fromCurrencyCode' },
fetcher: query => { fetcher: query => {
const rType = this.filterForm.controls.rateType.value; const rType = this.filterForm.controls.rateType.value;
const fullQuery = { const fullQuery = {
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Languages" tableTitle="Languages"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -48,17 +48,17 @@ export class LanguageList implements OnInit {
readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly columns = signal<DataTableColumn<LanguageTableRow>[]>([ readonly columns = signal<DataTableColumn<LanguageTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '6%' },
{ key: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left' }, { key: 'name', label: 'Language Name', header: 'Language Name', sortable: true, align: 'left', headerAlign: 'left', width: '28%' },
{ key: 'code', label: 'Language Code', header: 'Language Code', sortable: true }, { key: 'code', label: 'Language Code', header: 'Language Code', sortable: true, headerAlign: 'center', align: 'center', width: '18%' },
{ key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true }, { key: 'nativeName', label: 'Native Name', header: 'Native Name', sortable: true, headerAlign: 'center', align: 'center', width: '22%' },
{ {
key: 'isRightToLeft', label: 'RTL', header: 'RTL', sortable: false, badge: true, key: 'isRightToLeft', label: 'RTL', header: 'RTL', sortable: false, badge: true, headerAlign: 'center', align: 'center', width: '10%',
badgeClass: value => value === true ? 'badge bg-info/10 text-info' : 'badge bg-light text-defaulttextcolor', badgeClass: value => value === true ? 'badge bg-info/10 text-info' : 'badge bg-light text-defaulttextcolor',
formatter: value => value ? 'Yes' : 'No' formatter: value => value ? 'Yes' : 'No'
}, },
{ {
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, headerAlign: 'center', align: 'center', width: '11%',
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive' formatter: value => value ? 'Active' : 'Inactive'
} }
@@ -82,6 +82,7 @@ export class LanguageList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'name' },
fetcher: query => this.languageApi.getDataTable(query) fetcher: query => this.languageApi.getDataTable(query)
}); });
} }
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Plans" tableTitle="Plans"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -41,23 +41,23 @@ export class PlanList implements OnInit {
readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly columns = signal<DataTableColumn<PlanTableRow>[]>([ readonly columns = signal<DataTableColumn<PlanTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '5%' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'left', align: 'left', width: '15%' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, badgeClass: () => 'badge bg-primary/10 text-primary' }, { key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'left', align: 'left', width: '8%', badge: true, badgeClass: () => 'badge bg-primary/10 text-primary' },
{ {
key: 'price', label: 'Price', header: 'Price', sortable: true, headerAlign: 'center', align: 'right', key: 'price', label: 'Price', header: 'Price', sortable: true, headerAlign: 'center', align: 'center', width: '8%',
formatter: value => Number(value).toFixed(2) formatter: value => Number(value).toFixed(2)
}, },
{ {
key: 'billingCycle', label: 'Billing Cycle', header: 'Billing Cycle', sortable: true, headerAlign: 'center', align: 'center', key: 'billingCycle', label: 'Billing Cycle', header: 'Billing Cycle', sortable: true, headerAlign: 'center', align: 'center', width: '10%',
formatter: value => BILLING_CYCLE_LABELS[value as BillingCycle] ?? '—' formatter: value => BILLING_CYCLE_LABELS[value as BillingCycle] ?? '—'
}, },
{ key: 'maxCompanies', label: 'Max Companies', header: 'Max Companies', sortable: true, headerAlign: 'center', align: 'center' }, { key: 'maxCompanies', label: 'Max Companies', header: 'Max Companies', sortable: true, headerAlign: 'center', align: 'center', width: '9%' },
{ key: 'maxUsers', label: 'Max Users', header: 'Max Users', sortable: true, headerAlign: 'center', align: 'center' }, { key: 'maxUsers', label: 'Max Users', header: 'Max Users', sortable: true, headerAlign: 'center', align: 'center', width: '9%' },
{ key: 'maxStorageGb', label: 'Max Storage (GB)', header: 'Max Storage (GB)', sortable: true, headerAlign: 'center', align: 'center' }, { key: 'maxStorageGb', label: 'Max Storage (GB)', header: 'Max Storage (GB)', sortable: true, headerAlign: 'center', align: 'center', width: '10%' },
{ key: 'defaultTrialDays', label: 'Trial Days', header: 'Trial Days', sortable: true, headerAlign: 'center', align: 'center' }, { key: 'defaultTrialDays', label: 'Trial Days', header: 'Trial Days', sortable: true, headerAlign: 'center', align: 'center', width: '9%' },
{ {
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, headerAlign: 'center', align: 'center', width: '9%',
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive' formatter: value => value ? 'Active' : 'Inactive'
} }
@@ -81,6 +81,7 @@ export class PlanList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'name' },
fetcher: query => this.planApi.getPlanDataTable(query), fetcher: query => this.planApi.getPlanDataTable(query),
mapRow: (plan, serialNumber) => ({ mapRow: (plan, serialNumber) => ({
id: plan.id, id: plan.id,
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Regions" tableTitle="Regions"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -47,17 +47,17 @@ export class RegionList implements OnInit {
readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly columns = signal<DataTableColumn<RegionTableRow>[]>([ readonly columns = signal<DataTableColumn<RegionTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '7%' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'left', align: 'left', width: '40%' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '110px', badge: true, { key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '16%', badge: true,
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary' badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary'
}, },
{ {
key: 'countryCount', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'center', align: 'center', width: '110px', key: 'countryCount', label: 'Countries', header: 'Countries', sortable: false, headerAlign: 'center', align: 'center', width: '16%',
formatter: value => (typeof value === 'number' ? value : 0).toString() formatter: value => (typeof value === 'number' ? value : 0).toString()
}, },
{ {
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, headerAlign: 'center', align: 'center', width: '13%',
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive' formatter: value => value ? 'Active' : 'Inactive'
} }
@@ -81,6 +81,7 @@ export class RegionList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'name' },
fetcher: query => this.regionApi.getRegionDataTable(query) fetcher: query => this.regionApi.getRegionDataTable(query)
}); });
} }
@@ -18,8 +18,11 @@ export class StateService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
getStateDataTable(query: DataTableQuery, countryId: string | null = null): Observable<DataTableResult<StateDto>> { getStateDataTable(query: DataTableQuery, countryIds: readonly string[] = []): Observable<DataTableResult<StateDto>> {
const params = countryId ? new HttpParams().set('countryId', countryId) : undefined; let params = new HttpParams();
for (const countryId of countryIds) {
params = params.append('countryIds', countryId);
}
return this.http.post<DataTableResult<StateDto>>(`${STATE_ENDPOINTS.dataTable}`, query, { params }); return this.http.post<DataTableResult<StateDto>>(`${STATE_ENDPOINTS.dataTable}`, query, { params });
} }
@@ -43,12 +46,12 @@ export class StateService {
return this.http.get<StateDto>(STATE_ENDPOINTS.getById(id)); return this.http.get<StateDto>(STATE_ENDPOINTS.getById(id));
} }
autocomplete(countryId: string, term = '', limit = 50): Observable<StateLookupDto[]> { autocomplete(countryIds: string | readonly string[], term = '', limit = 50): Observable<StateLookupDto[]> {
return this.http.get<StateLookupDto[]>(STATE_ENDPOINTS.autocomplete, { let params = new HttpParams().set('term', term).set('limit', limit);
params: new HttpParams() const ids = Array.isArray(countryIds) ? countryIds : [countryIds];
.set('countryId', countryId) for (const countryId of ids) {
.set('term', term) params = params.append('countryIds', countryId);
.set('limit', limit) }
}); return this.http.get<StateLookupDto[]>(STATE_ENDPOINTS.autocomplete, { params });
} }
} }
@@ -1,6 +1,8 @@
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()" <app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
[totalRecords]="tableStore.totalRecords()" [pageIndex]="tableStore.queryState.pageIndex()" [totalRecords]="tableStore.totalRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" tableTitle="States" buttonTitle="Add" [showSearch]="true" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()" [initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="States" buttonTitle="Add" [showSearch]="true"
[showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()" searchPlaceholder="Search states..." [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()" searchPlaceholder="Search states..."
[searchDebounceTime]="300" toolTip="Add State" (addClicked)="onAddState()" [searchDebounceTime]="300" toolTip="Add State" (addClicked)="onAddState()"
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)" (searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
@@ -10,11 +12,10 @@
<form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off" <form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off"
class="flex flex-wrap items-end gap-3 w-full"> class="flex flex-wrap items-end gap-3 w-full">
<div class="w-64 min-w-[200px]"> <div class="w-64 min-w-[200px]">
<app-autocomplete formControlName="countryId" inputId="state-country-filter" variant="floating" size="sm" <app-form-select formControlName="countryId" inputId="state-country-filter" variant="floating" size="sm"
label="Country" placeholder="Search country" [searchFn]="searchCountries" [displayWith]="displayCountry" label="Country" placeholder="All Countries" mode="multiple" searchMode="server" [searchable]="true"
[valueWith]="countryValue" [selectedItem]="selectedCountryLookup()" [minSearchLength]="1" [debounceTime]="300" [options]="countryOptions()" [loading]="countryOptionsLoading()" [clearable]="true" [hideValidation]="true"
[limit]="50" [clearable]="true" [hideValidation]="true" wrapperClass="!mb-0 w-full" appendTo="body" wrapperClass="!mb-0 w-full" (searchChanged)="onCountrySearchChanged($event.term)" />
(itemSelected)="onFilterCountrySelected($event)" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<app-button action="custom" label="Apply" icon="ti ti-filter" variant="primary-full" type="submit" size="sm" <app-button action="custom" label="Apply" icon="ti ti-filter" variant="primary-full" type="submit" size="sm"
@@ -1,11 +1,11 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { of } from 'rxjs'; import { Subject } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, finalize, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service'; import { NotificationService } from '../../../../../core/services/common/notification.service';
import { CountryLookupDto, CountryService } from '../../../countries/public-api'; import { CountryService } from '../../../countries/public-api';
import { StateDto, UpdateStateRequest } from '../../models/state.model'; import { StateDto, UpdateStateRequest } from '../../models/state.model';
import { StateService } from '../../data-access/state.service'; import { StateService } from '../../data-access/state.service';
import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table'; import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table';
@@ -16,13 +16,8 @@ import {
DataTableColumn, DataTableColumn,
DataTableRecord DataTableRecord
} from '../../../../../shared/components/data-table/data-table.types'; } from '../../../../../shared/components/data-table/data-table.types';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
import { import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
import { Button as AppButton } from '../../../../../shared/components/button/button'; import { Button as AppButton } from '../../../../../shared/components/button/button';
import { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal'; import { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal';
@@ -45,7 +40,7 @@ interface StateTableRow extends DataTableRecord {
DataTable, DataTable,
DataTableToolbarDirective, DataTableToolbarDirective,
ReactiveFormsModule, ReactiveFormsModule,
Autocomplete, FormSelect,
ConfirmDialog, ConfirmDialog,
AppButton, AppButton,
StateFormModalComponent StateFormModalComponent
@@ -62,7 +57,6 @@ export class StateList implements OnInit {
private readonly notification = inject(NotificationService); private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<StateDto, StateTableRow>); readonly tableStore = inject(DataTableStore<StateDto, StateTableRow>);
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
readonly statusChangingId = signal<string | null>(null); readonly statusChangingId = signal<string | null>(null);
readonly deletingId = signal<string | null>(null); readonly deletingId = signal<string | null>(null);
readonly pendingDeleteState = signal<StateTableRow | null>(null); readonly pendingDeleteState = signal<StateTableRow | null>(null);
@@ -71,28 +65,26 @@ export class StateList implements OnInit {
readonly showFilters = signal(false); readonly showFilters = signal(false);
readonly countryFilterForm = this.formBuilder.nonNullable.group({ readonly countryFilterForm = this.formBuilder.nonNullable.group({
countryId: [''] countryId: [[] as string[]]
}); });
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> = (term, limit) => readonly countryOptions = signal<FormSelectOption<string>[]>([]);
this.countryApi.autocomplete(term, limit); readonly countryOptionsLoading = signal(false);
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name; private readonly countrySearch$ = new Subject<string>();
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 columns = signal<DataTableColumn<StateTableRow>[]>([ readonly columns = signal<DataTableColumn<StateTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '7%' },
{ key: 'name', label: 'State Name', header: 'State Name', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'name', label: 'State Name', header: 'State Name', sortable: true, headerAlign: 'left', align: 'left', width: '28%' },
{ {
key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '14%', badge: true,
badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary', badgeClass: value => value ? 'badge bg-primary/10 text-primary' : 'badge bg-secondary/10 text-secondary',
formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—' formatter: value => (typeof value === 'string' && value.trim().length > 0) ? value : '—'
}, },
{ {
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'countryName', label: 'Country', header: 'Country', sortable: true, headerAlign: 'left', align: 'left', width: '28%',
},
{
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, headerAlign: 'center', align: 'center', width: '13%',
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger', badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive' formatter: value => value ? 'Active' : 'Inactive'
} }
@@ -115,17 +107,31 @@ export class StateList implements OnInit {
]); ]);
ngOnInit(): void { ngOnInit(): void {
this.countrySearch$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => {
this.countryOptionsLoading.set(true);
return this.countryApi.autocomplete(term, 50);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(countries => {
this.countryOptionsLoading.set(false);
this.countryOptions.set(countries.map(country => ({ value: country.id, label: country.name })));
});
this.countrySearch$.next('');
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'name' },
fetcher: query => { fetcher: query => {
const countryId = this.countryFilterForm.controls.countryId.value || null; const countryIds = this.countryFilterForm.controls.countryId.value;
return this.stateApi.getStateDataTable(query, countryId); return this.stateApi.getStateDataTable(query, countryIds);
} }
}); });
} }
onFilterCountrySelected(country: CountryLookupDto | null): void { onCountrySearchChanged(term: string): void {
this.selectedCountryLookup.set(country); this.countrySearch$.next(term);
this.countryFilterForm.controls.countryId.setValue(country ? country.id : '');
} }
onApplyFilter(): void { onApplyFilter(): void {
@@ -133,8 +139,7 @@ export class StateList implements OnInit {
} }
onResetFilter(): void { onResetFilter(): void {
this.countryFilterForm.reset({ countryId: '' }); this.countryFilterForm.reset({ countryId: [] });
this.selectedCountryLookup.set(null);
this.tableStore.reset(); this.tableStore.reset();
} }
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Timezones" tableTitle="Timezones"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -19,7 +21,7 @@
(actionClicked)="onActionClick($event)" (actionClicked)="onActionClick($event)"
> >
<ng-template appDataTableCell="ianaId" let-value="value"> <ng-template appDataTableCell="ianaId" let-value="value">
<span class="block max-w-72 truncate font-semibold" title="{{ value }}">{{ value }}</span> <span class="block max-w-72 truncate" title="{{ value }}">{{ value }}</span>
</ng-template> </ng-template>
<ng-template appDataTableCell="displayName" let-value="value"> <ng-template appDataTableCell="displayName" let-value="value">
<span class="block max-w-72 truncate" title="{{ value }}">{{ value }}</span> <span class="block max-w-72 truncate" title="{{ value }}">{{ value }}</span>
@@ -60,15 +60,15 @@ export class TimezoneList implements OnInit {
readonly deleteConfirmDialog = viewChild(ConfirmDialog); readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([ readonly columns = signal<DataTableColumn<TimezoneTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '6%' },
{ key: 'ianaId', label: 'IANA ID', header: 'IANA ID', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'ianaId', label: 'IANA ID', header: 'IANA ID', sortable: true, headerAlign: 'left', align: 'left', width: '26%' },
{ key: 'displayName', label: 'Display Name', header: 'Display Name', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'displayName', label: 'Display Name', header: 'Display Name', sortable: true, headerAlign: 'left', align: 'left', width: '32%' },
{ {
key: 'utcOffsetMinutes', label: 'UTC Offset', header: 'UTC Offset', sortable: true, key: 'utcOffsetMinutes', label: 'UTC Offset', header: 'UTC Offset', sortable: true, headerAlign: 'center', align: 'center', width: '18%',
formatter: value => this.formatUtcOffset(Number(value)) formatter: value => this.formatUtcOffset(Number(value))
}, },
{ {
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true, headerAlign: 'center', align: 'center', width: '11%',
badgeClass: value => value === true badgeClass: value => value === true
? 'badge bg-success/10 text-success' ? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger', : 'badge bg-danger/10 text-danger',
@@ -110,6 +110,7 @@ export class TimezoneList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'ianaId' },
fetcher: query => this.timezoneApi.getDataTable(query) fetcher: query => this.timezoneApi.getDataTable(query)
}); });
} }
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Translation Keys" tableTitle="Translation Keys"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -58,8 +58,8 @@ export class TranslationKeyList implements OnInit {
}); });
readonly columns = signal<DataTableColumn<TranslationKeyTableRow>[]>([ readonly columns = signal<DataTableColumn<TranslationKeyTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '80px' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '6%' },
{ key: 'keyName', label: 'Key Name', header: 'Key Name', sortable: true, align: 'left', headerAlign: 'left' }, { key: 'keyName', label: 'Key Name', header: 'Key Name', sortable: true, align: 'left', headerAlign: 'left', width: '16%' },
{ {
key: 'moduleCode', key: 'moduleCode',
label: 'Module Code', label: 'Module Code',
@@ -67,12 +67,13 @@ export class TranslationKeyList implements OnInit {
sortable: true, sortable: true,
align: 'left', align: 'left',
headerAlign: 'left', headerAlign: 'left',
width: '12%',
badge: true, badge: true,
badgeClass: value => value && value !== 'Global' ? 'badge bg-primary/10 text-primary' : 'badge bg-gray-100 text-gray-600' badgeClass: value => value && value !== 'Global' ? 'badge bg-primary/10 text-primary' : 'badge bg-gray-100 text-gray-600'
}, },
{ key: 'componentType', label: 'Component Type', header: 'Component Type', sortable: true, align: 'left', headerAlign: 'left' }, { key: 'componentType', label: 'Component Type', header: 'Component Type', sortable: true, align: 'left', headerAlign: 'left', width: '14%' },
{ key: 'defaultText', label: 'Default Text', header: 'Default Text', sortable: true, align: 'left', headerAlign: 'left' }, { key: 'defaultText', label: 'Default Text', header: 'Default Text', sortable: true, align: 'left', headerAlign: 'left', width: '22%' },
{ key: 'description', label: 'Description', header: 'Description', sortable: false, align: 'left', headerAlign: 'left' } { key: 'description', label: 'Description', header: 'Description', sortable: false, align: 'left', headerAlign: 'left', width: '20%' }
]); ]);
readonly actions = signal<DataTableAction<TranslationKeyTableRow>[]>([ readonly actions = signal<DataTableAction<TranslationKeyTableRow>[]>([
@@ -88,6 +89,7 @@ export class TranslationKeyList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'keyName' },
fetcher: query => { fetcher: query => {
const filters: TranslationKeyFilterParams = { const filters: TranslationKeyFilterParams = {
moduleCode: this.filterForm.controls.moduleCode.value || null, moduleCode: this.filterForm.controls.moduleCode.value || null,
@@ -4,6 +4,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Translation Manager" tableTitle="Translation Manager"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -72,24 +72,27 @@ export class TranslationList implements OnInit {
} }
private buildColumns(languages: readonly LanguageLookupDto[]): void { private buildColumns(languages: readonly LanguageLookupDto[]): void {
const languageColumnWidth = languages.length > 0 ? `${Math.floor(74 / languages.length)}%` : undefined;
const languageColumns: DataTableColumn<TranslationMatrixTableRow>[] = languages.map(lang => ({ const languageColumns: DataTableColumn<TranslationMatrixTableRow>[] = languages.map(lang => ({
key: lang.code, key: lang.code,
label: lang.name, label: lang.name,
header: `${lang.name} (${lang.code})`, header: `${lang.name} (${lang.code})`,
sortable: false, sortable: false,
align: 'left', align: 'left',
headerAlign: 'left' headerAlign: 'left',
width: languageColumnWidth
})); }));
this.columns.set([ this.columns.set([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '6%', align: 'center', headerAlign: 'center' },
{ key: 'keyName', label: 'Key / Default Text', header: 'Key / Default Text', sortable: true, align: 'left', headerAlign: 'left' }, { key: 'keyName', label: 'Key / Default Text', header: 'Key / Default Text', sortable: true, align: 'left', headerAlign: 'left', width: '20%' },
...languageColumns ...languageColumns
]); ]);
} }
private initializeTable(): void { private initializeTable(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'keyName' },
fetcher: query => this.fetchMatrixPage(query), fetcher: query => this.fetchMatrixPage(query),
mapRow: (key, serialNumber) => this.mapToRow(key, serialNumber), mapRow: (key, serialNumber) => this.mapToRow(key, serialNumber),
onError: (err: any) => { onError: (err: any) => {
@@ -190,6 +190,7 @@ export class Dashboard implements OnInit {
header: 'Name', header: 'Name',
sortable: true, sortable: true,
align: 'left', align: 'left',
headerAlign: 'left',
}, },
{ key: 'country', label: 'Country', header: 'Country', sortable: true }, { key: 'country', label: 'Country', header: 'Country', sortable: true },
{ {
@@ -6,6 +6,8 @@
[totalRecords]="tableStore.filteredRecords()" [totalRecords]="tableStore.filteredRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Organizations Awaiting Database" tableTitle="Organizations Awaiting Database"
[showSearch]="true" [showSearch]="true"
rowColorMode="none" rowColorMode="none"
@@ -48,6 +48,7 @@ export class OrganizationAwaitingDb implements OnInit {
header: 'Organization', header: 'Organization',
sortable: true, sortable: true,
align: 'left', align: 'left',
headerAlign: 'left',
}, },
{ key: 'country', label: 'Country', header: 'Country', sortable: true }, { key: 'country', label: 'Country', header: 'Country', sortable: true },
// { key: 'wizardFinished', label: 'Wizard Finished', header: 'Wizard Finished', sortable: true }, // { key: 'wizardFinished', label: 'Wizard Finished', header: 'Wizard Finished', sortable: true },
@@ -70,6 +71,7 @@ export class OrganizationAwaitingDb implements OnInit {
header: 'DB Connection Name', header: 'DB Connection Name',
sortable: true, sortable: true,
align: 'left', align: 'left',
headerAlign: 'left',
formatter: value => (value && value !== '—' ? String(value) : '—') formatter: value => (value && value !== '—' ? String(value) : '—')
} }
]); ]);
@@ -85,6 +87,7 @@ export class OrganizationAwaitingDb implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'code' },
fetcher: query => this.awaitingDbApi.getAwaitingDbOrganizations(query), fetcher: query => this.awaitingDbApi.getAwaitingDbOrganizations(query),
mapRow: (item, serialNumber) => { mapRow: (item, serialNumber) => {
const resolvedName = item.organizationName || item.name || '—'; const resolvedName = item.organizationName || item.name || '—';
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.filteredRecords()" [totalRecords]="tableStore.filteredRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Organizations" tableTitle="Organizations"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -73,6 +73,7 @@ export class OrganizationList implements OnInit {
header: 'Organization Name', header: 'Organization Name',
sortable: true, sortable: true,
align: 'left', align: 'left',
headerAlign: 'left',
}, },
{ key: 'country', label: 'Country', header: 'Country', sortable: true }, { key: 'country', label: 'Country', header: 'Country', sortable: true },
{ {
@@ -128,6 +129,7 @@ export class OrganizationList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'code' },
fetcher: query => this.organizationApi.getOrganizationDataTable(query, this.appliedCountryId()), fetcher: query => this.organizationApi.getOrganizationDataTable(query, this.appliedCountryId()),
mapRow: (item, serialNumber) => { mapRow: (item, serialNumber) => {
const statusText = typeof item.status === 'number' const statusText = typeof item.status === 'number'
@@ -5,6 +5,8 @@
[totalRecords]="totalRecords()" [totalRecords]="totalRecords()"
[pageIndex]="pageIndex()" [pageIndex]="pageIndex()"
[pageSize]="pageSize()" [pageSize]="pageSize()"
[initialSortColumn]="sortColumn()"
[initialSortDirection]="sortDirection()"
tableTitle="Tenant Domains" tableTitle="Tenant Domains"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -34,6 +34,8 @@ export class TenantDomainTableComponent {
readonly totalRecords = input<number>(0); readonly totalRecords = input<number>(0);
readonly pageIndex = input<number>(1); readonly pageIndex = input<number>(1);
readonly pageSize = input<number>(10); readonly pageSize = input<number>(10);
readonly sortColumn = input<string>('');
readonly sortDirection = input<'asc' | 'desc'>('asc');
readonly showFilters = input<boolean>(false); readonly showFilters = input<boolean>(false);
readonly addClicked = output<void>(); readonly addClicked = output<void>();
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.filteredRecords()" [totalRecords]="tableStore.filteredRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[sortColumn]="tableStore.queryState.sortColumn()"
[sortDirection]="tableStore.queryState.sortDirection()"
[showFilters]="showFilters()" [showFilters]="showFilters()"
(addClicked)="onAddDomain()" (addClicked)="onAddDomain()"
(filterClicked)="onToggleFilters()" (filterClicked)="onToggleFilters()"
@@ -56,8 +56,8 @@ export class TenantDomainList implements OnInit {
readonly columns = signal<DataTableColumn<TenantDomainTableRow>[]>([ readonly columns = signal<DataTableColumn<TenantDomainTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' }, { key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' },
{ key: 'domainName', label: 'Domain', header: 'Domain', sortable: true, align: 'left' }, { key: 'domainName', label: 'Domain', header: 'Domain', sortable: true, align: 'left', headerAlign: 'left' },
{ key: 'organizationName', label: 'Organization', header: 'Organization', sortable: true, align: 'left' }, { key: 'organizationName', label: 'Organization', header: 'Organization', sortable: true, align: 'left', headerAlign: 'left' },
{ key: 'domainType', label: 'Type', header: 'Type', sortable: true, }, { key: 'domainType', label: 'Type', header: 'Type', sortable: true, },
{ key: 'isPrimary', label: 'Primary', header: 'Primary', sortable: true }, { key: 'isPrimary', label: 'Primary', header: 'Primary', sortable: true },
{ key: 'sslStatus', label: 'SSL', header: 'SSL', sortable: true }, { key: 'sslStatus', label: 'SSL', header: 'SSL', sortable: true },
@@ -98,6 +98,7 @@ export class TenantDomainList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'domainName' },
fetcher: query => this.tenantDomainApi.getTenantDomainDataTable( fetcher: query => this.tenantDomainApi.getTenantDomainDataTable(
query, query,
this.appliedTenantId(), this.appliedTenantId(),
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Database Connections" tableTitle="Database Connections"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -41,13 +41,13 @@ export class DbConnectionList implements OnInit {
key: 'connectionCode', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '110px', badge: true, key: 'connectionCode', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', width: '110px', badge: true,
badgeClass: () => 'badge bg-primary/10 text-primary font-mono' badgeClass: () => 'badge bg-primary/10 text-primary font-mono'
}, },
{ key: 'connectionName', label: 'Connection Name', header: 'Connection Name', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'connectionName', label: 'Connection Name', header: 'Connection Name', sortable: true, headerAlign: 'left', align: 'left' },
{ {
key: 'dbEngine', label: 'DB Engine', header: 'DB Engine', sortable: true, headerAlign: 'center', align: 'center', width: '120px', key: 'dbEngine', label: 'DB Engine', header: 'DB Engine', sortable: true, headerAlign: 'center', align: 'center', width: '120px',
formatter: value => typeof value === 'string' ? value.toUpperCase() : '—' formatter: value => typeof value === 'string' ? value.toUpperCase() : '—'
}, },
{ {
key: 'hostPort', label: 'Host / Port', header: 'Host / Port', sortable: true, headerAlign: 'center', align: 'left', key: 'hostPort', label: 'Host / Port', header: 'Host / Port', sortable: true, headerAlign: 'left', align: 'left',
formatter: (val, row) => { formatter: (val, row) => {
if (row.host && row.port) return `${row.host} : ${row.port}`; if (row.host && row.port) return `${row.host} : ${row.port}`;
if (row.hostPort) { if (row.hostPort) {
@@ -58,7 +58,7 @@ export class DbConnectionList implements OnInit {
return typeof val === 'string' ? val : '—'; return typeof val === 'string' ? val : '—';
} }
}, },
{ key: 'databaseName', label: 'Database', header: 'Database', sortable: true, headerAlign: 'center', align: 'left' }, { key: 'databaseName', label: 'Database', header: 'Database', sortable: true, headerAlign: 'left', align: 'left' },
{ {
key: 'region', label: 'Region', header: 'Region', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true, key: 'region', label: 'Region', header: 'Region', sortable: true, headerAlign: 'center', align: 'center', width: '90px', badge: true,
badgeClass: () => 'badge bg-secondary/10 text-secondary' badgeClass: () => 'badge bg-secondary/10 text-secondary'
@@ -93,6 +93,7 @@ export class DbConnectionList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'connectionCode' },
fetcher: query => this.dbConnectionApi.getDbConnectionDataTable(query), fetcher: query => this.dbConnectionApi.getDbConnectionDataTable(query),
mapRow: (connection, serialNumber) => { mapRow: (connection, serialNumber) => {
const hostDisplay = connection.hostPort || (connection.host ? `${connection.host}:${connection.port || 5432}` : ''); const hostDisplay = connection.hostPort || (connection.host ? `${connection.host}:${connection.port || 5432}` : '');
@@ -36,7 +36,6 @@
[totalRecords]="totalRecords()" [totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()" [pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()" [pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
tableTitle="Tenant Management" tableTitle="Tenant Management"
buttonTitle="Add Tenant" buttonTitle="Add Tenant"
[showSearch]="true" [showSearch]="true"
@@ -5,6 +5,8 @@
[totalRecords]="tableStore.filteredRecords()" [totalRecords]="tableStore.filteredRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" [pageSize]="tableStore.queryState.pageSize()"
[initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Tenants" tableTitle="Tenants"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -61,6 +61,7 @@ export class TenantList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'code' },
fetcher: query => this.tenantApi.getTenantDataTable(query), fetcher: query => this.tenantApi.getTenantDataTable(query),
mapRow: (tenant, serialNumber) => ({ mapRow: (tenant, serialNumber) => ({
id: tenant.id, id: tenant.id,
@@ -5,7 +5,8 @@
(addClicked)="onAddUser()" (addClicked)="onAddUser()"
[totalRecords]="tableStore.totalRecords()" [totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSizeOptions]="[5, 10, 20, 50]" [initialSortColumn]="tableStore.queryState.sortColumn()"
[initialSortDirection]="tableStore.queryState.sortDirection()"
tableTitle="Users" tableTitle="Users"
buttonTitle="Add" buttonTitle="Add"
[showSearch]="true" [showSearch]="true"
@@ -114,6 +114,7 @@ export class UsersList implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.tableStore.initialize({ this.tableStore.initialize({
defaultSort: { column: 'email' },
fetcher: query => this.usersApi.getDataTable(query), fetcher: query => this.usersApi.getDataTable(query),
mapRow: (user, serialNumber) => ({ mapRow: (user, serialNumber) => ({
id: user.id, id: user.id,
@@ -1,66 +0,0 @@
<section class="space-y-4">
<div class="box custom-box !mb-0">
<div class="box-header !items-start !gap-4 flex-col xl:flex-row xl:!items-center">
<div class="min-w-0 me-auto">
<h1 class="box-title !text-lg">{{ title() }}</h1>
@if (subtitle()) { <p class="mt-1 text-sm text-textmuted dark:text-textmuted/80">{{ subtitle() }}</p> }
</div>
<div class="flex w-full flex-wrap items-center gap-2 xl:w-auto">
<label class="relative min-w-[14rem] flex-1 xl:flex-none">
<span class="sr-only">Search</span>
<input #searchInput type="search" class="form-control !pe-9 xl:w-64" [placeholder]="searchPlaceholder()"
(input)="onSearch(searchInput.value)" />
<i class="ri-search-line absolute end-3 top-1/2 -translate-y-1/2 text-textmuted"></i>
</label>
@if (showFilters()) {
<button type="button" class="ti-btn ti-btn-light !mb-0" [class.!border-primary]="filtersOpen()"
[attr.aria-expanded]="filtersOpen()" (click)="filtersOpen.set(!filtersOpen())">
<i class="ri-filter-3-line"></i><span>Filters</span>
</button>
}
<div class="inline-flex rounded-md shadow-sm" role="group" aria-label="View">
<button type="button" class="ti-btn !mb-0 !rounded-e-none" [class.ti-btn-primary-full]="view() === 'table'" [class.ti-btn-light]="view() !== 'table'" (click)="setView('table')" title="Table view"><i class="ri-list-check-2"></i><span class="hidden sm:inline">Table</span></button>
<button type="button" class="ti-btn !mb-0 !rounded-none" [class.ti-btn-primary-full]="view() === 'tile'" [class.ti-btn-light]="view() !== 'tile'" (click)="setView('tile')" title="Tile view"><i class="ri-layout-grid-line"></i><span class="hidden sm:inline">Tile</span></button>
<button type="button" class="ti-btn !mb-0 !rounded-s-none" [class.ti-btn-primary-full]="view() === 'detail'" [class.ti-btn-light]="view() !== 'detail'" (click)="setView('detail')" title="Detail view"><i class="ri-layout-row-line"></i><span class="hidden sm:inline">Detail</span></button>
</div>
<label class="flex items-center gap-2 text-sm"><span class="hidden sm:inline">Density</span>
<select class="ti-form-select form-select-sm !w-auto" [value]="density()" (change)="setDensity($event)">
<option value="compact">Compact</option><option value="comfortable">Comfortable</option><option value="spacious">Spacious</option>
</select>
</label>
<button type="button" class="ti-btn ti-btn-primary-full !mb-0" (click)="addClicked.emit()"><i class="ri-add-line"></i>{{ addLabel() }}</button>
</div>
</div>
@if (filtersOpen()) {
<div class="box-body border-t border-defaultborder">
<ng-content select="[dataListFilters]" />
<div class="mt-4 flex justify-end gap-2">
<button type="button" class="ti-btn ti-btn-light !mb-0" (click)="filtersCleared.emit()">Clear All</button>
<button type="button" class="ti-btn ti-btn-primary-full !mb-0" (click)="filtersApplied.emit()">Apply Filters</button>
</div>
</div>
}
</div>
<div class="box custom-box !mb-0">
@if (loading()) {
<div class="box-body flex min-h-48 flex-col items-center justify-center gap-3"><i class="ti ti-loader-2 animate-spin text-2xl"></i><span>Loading data...</span></div>
} @else if (totalRecords() === 0) {
<div class="box-body flex min-h-48 flex-col items-center justify-center text-center"><i class="ri-inbox-2-line mb-3 text-4xl text-textmuted"></i><h2 class="font-semibold">{{ emptyMessage() }}</h2><p class="mt-1 text-sm text-textmuted">{{ emptyDescription() }}</p></div>
} @else {
<div [class.hidden]="view() !== 'table'"><ng-content select="[dataListTable]" /></div>
<div [class.hidden]="view() !== 'tile'" class="box-body"><div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4" [class]="'grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 ' + contentSpacingClass()"><ng-content select="[dataListTile]" /></div></div>
<div [class.hidden]="view() !== 'detail'" class="box-body"><div class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3" [class]="'grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 ' + contentSpacingClass()"><ng-content select="[dataListDetail]" /></div></div>
}
<div class="box-footer flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div class="flex flex-wrap items-center gap-3 text-sm"><span>Showing <b>{{ startRecord() }}</b> to <b>{{ endRecord() }}</b> of <b>{{ totalRecords() }}</b> entries</span>
<label class="flex items-center gap-2">Show <select class="ti-form-select form-select-sm !w-[4.75rem]" [value]="pageSize()" (change)="onPageSizeChange($event)">@for (size of pageSizeOptions(); track size) { <option [value]="size">{{ size }}</option> }</select></label>
</div>
<nav aria-label="Pagination" class="pagination-style-4"><ul class="ti-pagination mb-0">
<li class="page-item" [class.disabled]="pageIndex() <= 1"><button type="button" class="page-link" [disabled]="pageIndex() <= 1" (click)="goToPage(pageIndex() - 1)"><i class="ri-arrow-left-s-line"></i></button></li>
@for (page of visiblePages(); track page) { <li class="page-item"><button type="button" class="page-link" [class.active]="page === pageIndex()" (click)="goToPage(page)">{{ page }}</button></li> }
<li class="page-item" [class.disabled]="pageIndex() >= totalPages()"><button type="button" class="page-link" [disabled]="pageIndex() >= totalPages()" (click)="goToPage(pageIndex() + 1)"><i class="ri-arrow-right-s-line"></i></button></li>
</ul></nav>
</div>
</div>
</section>
@@ -1,75 +0,0 @@
import { Component, DestroyRef, computed, inject, input, output, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
export type DataListView = 'table' | 'tile' | 'detail';
export type DataListDensity = 'compact' | 'comfortable' | 'spacious';
@Component({
selector: 'app-data-list',
standalone: true,
templateUrl: './data-list.html'
})
export class DataList {
private readonly destroyRef = inject(DestroyRef);
private readonly searchTerms$ = new Subject<string>();
readonly title = input.required<string>();
readonly subtitle = input('');
readonly addLabel = input('Add');
readonly searchPlaceholder = input('Search...');
readonly totalRecords = input(0);
readonly pageIndex = input(1);
readonly pageSize = input(10);
readonly pageSizeOptions = input<readonly number[]>([5, 10, 20, 50]);
readonly loading = input(false);
readonly emptyMessage = input('No records found.');
readonly emptyDescription = input('Try changing your search or filters.');
readonly showFilters = input(true);
readonly searchChanged = output<string>();
readonly addClicked = output<void>();
readonly pageChanged = output<{ pageIndex: number; pageSize: number }>();
readonly filtersCleared = output<void>();
readonly filtersApplied = output<void>();
readonly view = signal<DataListView>('table');
readonly density = signal<DataListDensity>('comfortable');
readonly filtersOpen = signal(false);
readonly totalPages = computed(() => Math.max(1, Math.ceil(this.totalRecords() / this.pageSize())));
readonly startRecord = computed(() => this.totalRecords() ? (this.pageIndex() - 1) * this.pageSize() + 1 : 0);
readonly endRecord = computed(() => Math.min(this.pageIndex() * this.pageSize(), this.totalRecords()));
readonly visiblePages = computed(() => {
const total = this.totalPages();
const current = this.pageIndex();
const start = Math.max(1, Math.min(current - 2, total - 4));
return Array.from({ length: Math.min(5, total) }, (_, index) => start + index);
});
readonly contentSpacingClass = computed(() => ({
compact: 'gap-3', comfortable: 'gap-4', spacious: 'gap-6'
})[this.density()]);
readonly cardPaddingClass = computed(() => ({
compact: 'p-3', comfortable: 'p-4', spacious: 'p-6'
})[this.density()]);
constructor() {
this.searchTerms$.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
).subscribe(value => this.searchChanged.emit(value));
}
setView(view: DataListView): void { this.view.set(view); }
setDensity(event: Event): void { this.density.set((event.target as HTMLSelectElement).value as DataListDensity); }
onSearch(value: string): void { this.searchTerms$.next(value.trim()); }
goToPage(page: number): void {
if (page >= 1 && page <= this.totalPages() && page !== this.pageIndex()) {
this.pageChanged.emit({ pageIndex: page - 1, pageSize: this.pageSize() });
}
}
onPageSizeChange(event: Event): void {
const pageSize = Number((event.target as HTMLSelectElement).value);
if (pageSize && pageSize !== this.pageSize()) this.pageChanged.emit({ pageIndex: 0, pageSize });
}
}
@@ -12,6 +12,17 @@ export class DataTableQueryState {
sortColumn = signal(''); sortColumn = signal('');
sortDirection = signal<'asc' | 'desc'>('asc'); sortDirection = signal<'asc' | 'desc'>('asc');
private readonly draw = signal(1); private readonly draw = signal(1);
private defaultSortColumn = '';
private defaultSortDirection: 'asc' | 'desc' = 'asc';
setDefaultSort(column: string, direction: 'asc' | 'desc' = 'asc'): void {
this.defaultSortColumn = column;
this.defaultSortDirection = direction;
if (!this.sortColumn()) {
this.sortColumn.set(column);
this.sortDirection.set(direction);
}
}
getQuery(): DataTableQuery { getQuery(): DataTableQuery {
return { return {
@@ -52,8 +63,8 @@ export class DataTableQueryState {
this.pageIndex.set(1); this.pageIndex.set(1);
this.pageSize.set(15); this.pageSize.set(15);
this.searchText.set(''); this.searchText.set('');
this.sortColumn.set(''); this.sortColumn.set(this.defaultSortColumn);
this.sortDirection.set('asc'); this.sortDirection.set(this.defaultSortDirection);
this.draw.set(1); this.draw.set(1);
return this.getQuery(); return this.getQuery();
} }
@@ -226,16 +226,9 @@
Show Show
</label> </label>
<select id="dataTablePageSize" <p-select inputId="dataTablePageSize" [ngModel]="pageSize()" (ngModelChange)="onPageSizeChange($event)"
class="form-select form-select-sm !w-[80px] !py-1 !ps-2.5 !pe-6 text-defaulttextcolor bg-white dark:bg-bodybg border border-defaultborder rounded-md" [options]="pageSizeOptions()" appendTo="body" styleClass="data-table-page-size"
[value]="pageSize()" panelStyleClass="data-table-page-size-panel" />
(change)="onPageSizeChange($event)">
@for (size of pageSizeOptions(); track size) {
<option [value]="size" [selected]="size === pageSize()" class="text-defaulttextcolor bg-white dark:bg-bodybg">
{{ size }}
</option>
}
</select>
<span class="text-sm whitespace-nowrap text-defaulttextcolor"> <span class="text-sm whitespace-nowrap text-defaulttextcolor">
entries entries
@@ -259,15 +252,21 @@
<!-- Page numbers --> <!-- Page numbers -->
@for (page of visiblePages(); track page) { @for (page of visiblePages(); track page) {
@if (page === 'ellipsis-start' || page === 'ellipsis-end') {
<li class="page-item page-item--ellipsis" aria-hidden="true">
<span class="page-link px-2 py-[0.375rem]">&hellip;</span>
</li>
} @else {
<li class="page-item" [class.active]="page === pageIndex()"> <li class="page-item" [class.active]="page === pageIndex()">
<button type="button" class="page-link px-3 py-[0.375rem]" [class.active]="page === pageIndex()" <button type="button" class="page-link px-3 py-[0.375rem]" [class.active]="page === pageIndex()"
[attr.aria-current]=" [attr.aria-current]="
page === pageIndex() ? 'page' : null page === pageIndex() ? 'page' : null
" (click)="goToPage(page)"> " (click)="goToPage($any(page))">
{{ page }} {{ page }}
</button> </button>
</li> </li>
} }
}
<!-- Next --> <!-- Next -->
<li class="page-item" [class.disabled]="pageIndex() >= totalPages()"> <li class="page-item" [class.disabled]="pageIndex() >= totalPages()">
@@ -142,10 +142,12 @@
// background-color: var(--color-light); // background-color: var(--color-light);
background-color: #e1d0ff; background-color: #e1d0ff;
box-shadow: inset 3px 0 0 0 #7c3deb;
} }
:host-context(.dark) .data-table-modern .table tbody tr:hover { :host-context(.dark) .data-table-modern .table tbody tr:hover {
background-color: color-mix(in srgb, #fff 6%, transparent); background-color: color-mix(in srgb, #fff 6%, transparent);
box-shadow: inset 3px 0 0 0 #7c3deb;
} }
/* Inactive row action buttons border */ /* Inactive row action buttons border */
@@ -218,6 +220,15 @@
background-color: transparent; background-color: transparent;
} }
.data-table-modern .ti-pagination li.page-item--ellipsis {
border: none;
}
.data-table-modern .ti-pagination li.page-item--ellipsis .page-link {
color: var(--color-textmuted, #8c9097);
cursor: default;
}
:host-context(.dark) .data-table-modern .ti-pagination li .page-link { :host-context(.dark) .data-table-modern .ti-pagination li .page-link {
background-color: transparent; background-color: transparent;
color: color-mix(in srgb, var(--color-defaulttextcolor) 80%, #fff); color: color-mix(in srgb, var(--color-defaulttextcolor) 80%, #fff);
@@ -234,6 +245,93 @@
background-color: var(--color-primary) !important; background-color: var(--color-primary) !important;
} }
/* Page size select — outline-primary pill, sized to fit "100", matching the
"Showing X to Y of Z entries" number color/weight. PrimeNG renders its own
structural CSS at runtime after this stylesheet, so equal-specificity
rules need !important to win the cascade tie (::ng-deep is global here
since the overlay panel is appended to <body>, outside this component). */
::ng-deep {
.data-table-page-size {
width: 4rem !important;
min-width: 4rem !important;
height: 1.5rem !important;
box-sizing: border-box;
border: 1px solid var(--color-primary) !important;
border-radius: 9999px !important;
background: var(--color-white) !important;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
&:not(.p-disabled).p-focus {
border-color: var(--color-primary) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent) !important;
}
.p-select-label {
display: flex !important;
align-items: center !important;
height: 100% !important;
padding: 0 0 0 0.6rem !important;
font-size: 0.75rem !important;
line-height: 1 !important;
font-weight: 700 !important;
color: var(--color-defaulttextcolor) !important;
}
.p-select-dropdown {
width: 1.25rem !important;
height: 100% !important;
margin-inline-end: 0.35rem;
color: var(--color-primary) !important;
}
}
.data-table-page-size-panel {
border: 1px solid var(--color-primary) !important;
border-radius: 12px !important;
background: var(--color-white) !important;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.12), 0 8px 10px -6px rgba(0, 0, 0, 0.08) !important;
overflow: hidden;
padding: 0.25rem !important;
.p-select-list {
padding: 0 !important;
margin: 0 !important;
gap: 1px;
}
.p-select-option {
padding: 0.4rem 0.75rem !important;
font-size: 0.875rem !important;
color: var(--color-defaulttextcolor) !important;
border-radius: 8px !important;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent) !important;
color: var(--color-primary) !important;
}
&.p-select-option-selected {
background: var(--color-primary) !important;
color: var(--color-white) !important;
}
}
}
}
:host-context(.dark) {
::ng-deep {
.data-table-page-size {
background: var(--color-bodybg) !important;
}
.data-table-page-size-panel {
background: var(--color-bodybg) !important;
}
}
}
/* Responsive scroll */ /* Responsive scroll */
.table-responsive { .table-responsive {
width: 100%; width: 100%;
@@ -15,6 +15,8 @@ export interface DataTableStoreOptions<TItem, TRow extends DataTableRecord> {
fetcher: (query: DataTableQuery) => Observable<DataTableResult<TItem>>; fetcher: (query: DataTableQuery) => Observable<DataTableResult<TItem>>;
mapRow?: (item: TItem, serialNumber: number, query: DataTableQuery) => TRow; mapRow?: (item: TItem, serialNumber: number, query: DataTableQuery) => TRow;
onError?: (error: unknown) => void; onError?: (error: unknown) => void;
/** Column sorted by default on first load (e.g. the first sortable column after Sr. No.). */
defaultSort?: { column: string; direction?: 'asc' | 'desc' };
} }
@Injectable() @Injectable()
@@ -34,6 +36,10 @@ export class DataTableStore<TItem, TRow extends DataTableRecord = TItem & DataTa
readonly saving = signal<boolean>(false); readonly saving = signal<boolean>(false);
initialize(options: DataTableStoreOptions<TItem, TRow>): void { initialize(options: DataTableStoreOptions<TItem, TRow>): void {
if (options.defaultSort) {
this.queryState.setDefaultSort(options.defaultSort.column, options.defaultSort.direction ?? 'asc');
}
this.querySubject$ this.querySubject$
.pipe( .pipe(
debounceTime(50), debounceTime(50),
@@ -4,6 +4,8 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { SelectModule } from 'primeng/select';
import { DataTableCellDirective } from '../../directives/data-table-cell.directive'; import { DataTableCellDirective } from '../../directives/data-table-cell.directive';
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay'; import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay';
@@ -20,6 +22,8 @@ import { DataTableFilterActionsDirective } from '../../directives/data-table-fil
MatPaginatorModule, MatPaginatorModule,
CdkOverlayOrigin, CdkOverlayOrigin,
CdkConnectedOverlay, CdkConnectedOverlay,
FormsModule,
SelectModule,
Button], Button],
templateUrl: './data-table.html', templateUrl: './data-table.html',
styleUrl: './data-table.scss', styleUrl: './data-table.scss',
@@ -121,7 +125,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
totalRecords = input(0); totalRecords = input(0);
pageIndex = input(1); pageIndex = input(1);
pageSize = input(15); pageSize = input(15);
pageSizeOptions = input<number[]>([10, 15, 20, 50]); pageSizeOptions = input<number[]>([10, 15, 20, 50, 100]);
showPaginator = input(true); showPaginator = input(true);
/*---------------------------*/ /*---------------------------*/
@@ -157,8 +161,24 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
importClicked = output<void>(); importClicked = output<void>();
exportClicked = output<void>(); exportClicked = output<void>();
/** Column/direction the table should show as sorted on load (e.g. the store's current query sort). */
initialSortColumn = input<string>('');
initialSortDirection = input<'asc' | 'desc'>('asc');
sortColumn = signal(''); sortColumn = signal('');
sortDirection = signal<'asc' | 'desc'>('asc'); sortDirection = signal<'asc' | 'desc'>('asc');
private sortInitialized = false;
private readonly syncInitialSortEffect = effect(() => {
const column = this.initialSortColumn();
const direction = this.initialSortDirection();
if (!this.sortInitialized && column) {
this.sortInitialized = true;
this.sortColumn.set(column);
this.sortDirection.set(direction);
}
});
openActionRowId = signal<string | number | null>(null); openActionRowId = signal<string | number | null>(null);
tableClass = input<string>('table table-bordered whitespace-nowrap min-w-full'); tableClass = input<string>('table table-bordered whitespace-nowrap min-w-full');
@@ -204,11 +224,15 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
return Math.min(this.pageIndex() * this.pageSize(), this.totalRecords()); return Math.min(this.pageIndex() * this.pageSize(), this.totalRecords());
}); });
visiblePages = computed(() => { visiblePages = computed<(number | 'ellipsis-start' | 'ellipsis-end')[]>(() => {
const currentPage = this.pageIndex(); const currentPage = this.pageIndex();
const total = this.totalPages(); const total = this.totalPages();
const maxVisiblePages = 5; const maxVisiblePages = 5;
if (total <= maxVisiblePages) {
return Array.from({ length: total }, (_, index) => index + 1);
}
let startPage = Math.max( let startPage = Math.max(
1, 1,
currentPage - Math.floor(maxVisiblePages / 2) currentPage - Math.floor(maxVisiblePages / 2)
@@ -226,10 +250,22 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
); );
} }
return Array.from( const pages: (number | 'ellipsis-start' | 'ellipsis-end')[] = Array.from(
{ length: endPage - startPage + 1 }, { length: endPage - startPage + 1 },
(_, index) => startPage + index (_, index) => startPage + index
); );
if (startPage > 1) {
if (startPage > 2) pages.unshift('ellipsis-start');
pages.unshift(1);
}
if (endPage < total) {
if (endPage < total - 1) pages.push('ellipsis-end');
pages.push(total);
}
return pages;
}); });
goToPage(page: number): void { goToPage(page: number): void {
if ( if (
@@ -247,11 +283,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
}); });
} }
onPageSizeChange(event: Event): void { onPageSizeChange(pageSize: number): void {
const pageSize = Number(
(event.target as HTMLSelectElement).value
);
if (!pageSize || pageSize === this.pageSize()) { if (!pageSize || pageSize === this.pageSize()) {
return; return;
} }
@@ -1 +0,0 @@
<p>form-checkbox works!</p>
@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-checkbox',
imports: [],
templateUrl: './form-checkbox.html',
styleUrl: './form-checkbox.scss',
})
export class FormCheckbox {
}
@@ -1 +0,0 @@
<p>form-file-upload works!</p>
@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-file-upload',
imports: [],
templateUrl: './form-file-upload.html',
styleUrl: './form-file-upload.scss',
})
export class FormFileUpload {
}
@@ -1 +0,0 @@
<p>form-radio works!</p>
@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-radio',
imports: [],
templateUrl: './form-radio.html',
styleUrl: './form-radio.scss',
})
export class FormRadio {
}
@@ -273,10 +273,19 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
); );
}); });
private readonly optionLabelCache = new Map<TValue, FormSelectOption<TValue>>();
readonly selectItems = computed<readonly FormSelectOption<TValue>[]>(() => { readonly selectItems = computed<readonly FormSelectOption<TValue>[]>(() => {
const selected = new Set(this.activeSelectedValues()); const options = this.options();
const selectedOptions = this.options()
.filter(option => selected.has(option.value)); for (const option of options) {
this.optionLabelCache.set(option.value, option);
}
const optionsByValue = new Map(options.map(option => [option.value, option]));
const selectedOptions = this.activeSelectedValues()
.map(value => optionsByValue.get(value) ?? this.optionLabelCache.get(value))
.filter((option): option is FormSelectOption<TValue> => option !== undefined);
const optionValues = new Set(selectedOptions.map(option => option.value)); const optionValues = new Set(selectedOptions.map(option => option.value));
const visibleOptions = this.resolvedOptions() const visibleOptions = this.resolvedOptions()
.filter(option => !optionValues.has(option.value)); .filter(option => !optionValues.has(option.value));
@@ -1 +0,0 @@
<p>form-textarea works!</p>
@@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'form-textarea',
imports: [],
templateUrl: './form-textarea.html',
styleUrl: './form-textarea.scss',
})
export class FormTextarea {
}
+9
View File
@@ -100,6 +100,9 @@
<a class="side-menu__item" [routerLink]="!childrenItem.type ? null : [childrenItem.path] " <a class="side-menu__item" [routerLink]="!childrenItem.type ? null : [childrenItem.path] "
routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}"
(click)="setNavActive($event, childrenItem.path ?? '')"> (click)="setNavActive($event, childrenItem.path ?? '')">
@if (childrenItem.icon) {
<span [appSvgReplace]="childrenItem.icon" class="iconclick"></span>
}
{{childrenItem.title}} {{childrenItem.title}}
@if(childrenItem.badgeClass){ @if(childrenItem.badgeClass){
@@ -111,6 +114,9 @@
<!-- empty --> <!-- empty -->
@if (childrenItem.type === 'empty' ) { @if (childrenItem.type === 'empty' ) {
<a class="side-menu__item " href="javascript:;" (click)="setNavActive($event, childrenItem.path ?? '')"> <a class="side-menu__item " href="javascript:;" (click)="setNavActive($event, childrenItem.path ?? '')">
@if (childrenItem.icon) {
<span [appSvgReplace]="childrenItem.icon" class="iconclick"></span>
}
{{childrenItem.title }} {{childrenItem.title }}
</a> </a>
} }
@@ -119,6 +125,9 @@
<a class="side-menu__item " [ngClass]="{'active': childrenItem.selected}" <a class="side-menu__item " [ngClass]="{'active': childrenItem.selected}"
[routerLink]="childrenItem.type ? null : [childrenItem.path]" [routerLink]="childrenItem.type ? null : [childrenItem.path]"
(click)="toggleNavActive($event, childrenItem)"> (click)="toggleNavActive($event, childrenItem)">
@if (childrenItem.icon) {
<span [appSvgReplace]="childrenItem.icon" class="iconclick"></span>
}
<span class="">{{childrenItem.title}}</span> <span class="">{{childrenItem.title}}</span>
<i class="fe fe-chevron-right side-menu__angle"></i> <i class="fe fe-chevron-right side-menu__angle"></i>
</a> </a>
+10
View File
@@ -0,0 +1,10 @@
/* Sub-menu items now render a leading icon instead of a bullet dot. */
.side-menu__item:before {
display: none !important;
}
/* Tighten sub-menu left indent now that the bullet dot is gone. */
.slide-menu.child1 .side-menu__item,
.slide-menu.child2 .side-menu__item {
padding-inline-start: 1.1rem !important;
}