new screens added for Plan, Db connections, and code refactor

This commit is contained in:
Gagan7900
2026-08-03 22:08:55 +05:30
parent 430389c611
commit c7c361cc0b
12937 changed files with 6501 additions and 158003 deletions
-18
View File
@@ -1,18 +0,0 @@
# Generated with ngx-htaccess-generator v1.2.4
# Check for updates: https://julianpoemp.github.io/ngx-htaccess-generator/
<IfModule mod_headers.c>
# Set allow Access-Control-Allow-Origin header
Header set Access-Control-Allow-Origin "*"
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirection of requests to index.html
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} -f [OR]
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} -d
RewriteRule ^.*$ - [NC,L]
# Redirect all non-file routes to index.html
RewriteRule ^(?!.*\.).*$ index.html [NC,L]
</IfModule>
@@ -1 +0,0 @@
<div echarts [ngClass]="echartClass()" [options]="options() || {}" [id]="id()"></div>
@@ -1,16 +0,0 @@
import { NgClass } from '@angular/common';
import { Component, input } from '@angular/core';
import { NgxEchartsDirective } from 'ngx-echarts';
import { EChartsOption } from 'echarts';
@Component({
selector: 'spk-echarts',
imports: [NgxEchartsDirective, NgClass],
templateUrl: './spk-echarts.html',
styleUrl: './spk-echarts.scss'
})
export class SpkEcharts {
options = input<EChartsOption>()
id = input<string>()
echartClass = input<string>()
theme = input<string>()
}
@@ -1 +0,0 @@
<ngx-particles [id]="id()" [options]="options()" />
@@ -1,22 +0,0 @@
import { Component, inject, input } from '@angular/core';
import { IParticlesProps, NgParticlesService } from '@tsparticles/angular';
import { loadFull } from 'tsparticles';
import { NgxParticlesModule } from "@tsparticles/angular";
import { Engine } from '@tsparticles/engine';
@Component({
selector: 'spk-particles',
imports: [NgxParticlesModule],
templateUrl: './spk-particles.html',
styleUrl: './spk-particles.scss',
})
export class SpkParticles {
id = input<string>('tsparticles');
ngParticlesService = inject(NgParticlesService)
options = input<IParticlesProps>()
ngOnInit(): void {
this.ngParticlesService.init(async (engine: Engine) => {
await loadFull(engine); // Load core features (optional, depending on needs)
});
}
}
@@ -26,4 +26,4 @@
[maxDate]="maxDate()!"
[disabled]="disabled"
[readonly]="readonly()"
/>
/>
+5 -3
View File
@@ -1,4 +1,5 @@
import { Component, signal, inject } from '@angular/core';
import { Component, DestroyRef, OnInit, signal, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { AppStateService } from './core/services/common/app-state.service';
@Component({
@@ -7,15 +8,16 @@ import { AppStateService } from './core/services/common/app-state.service';
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
export class App implements OnInit {
private router = inject(Router);
private appState=inject(AppStateService)
private readonly destroyRef = inject(DestroyRef);
constructor() {
this.appState.updateState();
}
protected readonly title = signal('Ynex-Tailwind');
ngOnInit() {
this.router.events.subscribe((event) => {
this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
if (event instanceof NavigationEnd) {
setTimeout(() => window.HSStaticMethods.autoInit(), 100);
}
+2 -17
View File
@@ -3,31 +3,16 @@ import { CanActivateFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../../../features/authentication/data-access/auth.service';
import { TokenStorageService } from '../../services/auth/token-storage.service';
import { resolveSafeReturnUrl } from '../../utils/safe-return-url.util';
const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm';
function resolveSafeReturnUrl(returnUrl: string | null): string {
const candidate = returnUrl?.trim();
if (!candidate) {
return DEFAULT_AUTHENTICATED_REDIRECT;
}
const lowerCandidate = candidate.toLowerCase();
const isSafeInternal =
candidate.startsWith('/') &&
!candidate.startsWith('//') &&
!lowerCandidate.includes('http://') &&
!lowerCandidate.includes('https://');
return isSafeInternal ? candidate : DEFAULT_AUTHENTICATED_REDIRECT;
}
export const guestGuard: CanActivateFn = (route) => {
const authService = inject(AuthService);
const tokenStorage = inject(TokenStorageService);
const router = inject(Router);
const targetUrl = resolveSafeReturnUrl(route.queryParamMap.get('returnUrl'));
const targetUrl = resolveSafeReturnUrl(route.queryParamMap.get('returnUrl'), DEFAULT_AUTHENTICATED_REDIRECT);
const targetUrlTree = router.createUrlTree([targetUrl]);
if (!authService.accessToken || !authService.currentUser) {
@@ -3,18 +3,11 @@ import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../../features/authentication/data-access/auth.service';
import { API_CONFIG } from '../config/api.config';
import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints';
import { isEndpointRequest } from '../utils/url-match.util';
const RETRY_HEADER = 'X-Auth-Retry';
function isEndpointRequest(requestUrl: string, endpointUrl: string): boolean {
return (
requestUrl === endpointUrl ||
requestUrl.startsWith(`${endpointUrl}?`)
);
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const router = inject(Router);
+18 -8
View File
@@ -1,8 +1,9 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { catchError, throwError } from 'rxjs';
import { API_CONFIG } from '../config/api.config';
import { AUTH_ENDPOINTS } from '../end-points/auth/auth.endpoints';
import { NotificationService } from '../services/common/notification.service';
import { isEndpointRequest } from '../utils/url-match.util';
const backendErrorMessage = (body: unknown): string | null => {
if (typeof body === 'string') return body.trim() || null;
@@ -29,17 +30,26 @@ const httpErrorMessage = (error: HttpErrorResponse): string => {
return error.message || 'Request failed';
};
// A 404 here means "this tenant has no current subscription" — an expected outcome
// the UI renders as an empty state, not a failure worth a global error toast.
const isExpectedSubscriptionLookupMiss = (req: { url: string }, error: HttpErrorResponse): boolean =>
error.status === 404 && req.url.includes('/subscriptions/by-tenant/');
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const toastr = inject(ToastrService);
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
const isLoginRequest = req.url.includes(`${authBaseUrl}/login`);
const isRefreshRequest = req.url.includes(`${authBaseUrl}/refresh`);
const notification = inject(NotificationService);
const isLoginRequest = isEndpointRequest(req.url, AUTH_ENDPOINTS.login);
const isRefreshRequest = isEndpointRequest(req.url, AUTH_ENDPOINTS.refresh);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (!isLoginRequest && !isRefreshRequest && error.status !== 401) {
const isSuppressed = isLoginRequest
|| isRefreshRequest
|| error.status === 401
|| isExpectedSubscriptionLookupMiss(req, error);
if (!isSuppressed) {
const message = httpErrorMessage(error);
toastr.error(message, 'Request failed');
notification.error(message);
}
return throwError(() => error);
+1 -1
View File
@@ -1,5 +1,5 @@
import { UserProfile } from '../../../features/authentication/models/auth.model';
import { Menu } from '../../services/common/nav.service';
import { Menu } from '../menu/menu.model';
export interface CurrentUserContext extends UserProfile {
fullName?: string;
+21
View File
@@ -0,0 +1,21 @@
export interface Menu {
headTitle?: string;
headTitle2?: string;
path?: string;
title?: string;
icon?: string;
type?: string;
badgeValue?: string;
badgeClass?: string;
badgeText?: string;
active?: boolean;
selected?: boolean;
bookmark?: boolean;
children?: Menu[];
children2?: Menu[];
Menusub?: boolean;
target?: boolean;
menutype?: string;
dirchange?: boolean;
nochild?: any;
}
@@ -1,8 +1,12 @@
import { Injectable } from '@angular/core';
import { LoginResponse, UserProfile } from '../../../features/authentication/models/auth.model';
import { normalizeUserProfile } from '../../utils/user-profile.util';
type StorageType = 'local' | 'session';
// Tokens live in Web Storage (XSS-readable) rather than an httpOnly cookie because the
// backend doesn't issue one; closing this out fully requires the API to set the refresh
// token as httpOnly and this service to stop persisting it client-side.
@Injectable({ providedIn: 'root' })
export class TokenStorageService {
private readonly accessTokenKey = 'master-admin-access-token';
@@ -31,7 +35,7 @@ export class TokenStorageService {
selectedStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn);
}
const user = this.buildUserProfile(response);
const user = normalizeUserProfile(response);
if (user) {
selectedStorage.setItem(this.userKey, JSON.stringify(user));
}
@@ -133,19 +137,4 @@ export class TokenStorageService {
return parsedExpiry <= Date.now();
}
private buildUserProfile(response: LoginResponse): UserProfile | null {
if (response.user) {
return response.user;
}
if (response.userId || response.email) {
return {
id: response.userId ?? '',
email: response.email ?? '',
roles: response.roles,
};
}
return null;
}
}
@@ -2,6 +2,15 @@ import { DOCUMENT, inject, Injectable } from '@angular/core';
import { signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
export interface ThemeBackground {
main: string;
secondary: string;
accent: string;
overlay: string;
primary: string;
theme: string;
}
export interface StateType {
direction: string;
theme: string;
@@ -15,6 +24,8 @@ export interface StateType {
menuColor: string;
headerColor: string;
themePrimary: string;
// Object shape when set (see ThemeBackground); '' when unset. Kept loose because the
// switcher template does dynamic bracket-access on this field.
themeBackground: any;
backgroundImage: string;
}
@@ -46,7 +57,6 @@ export class AppStateService {
readonly state$ = toObservable(this.stateSignal);
private document = inject(DOCUMENT);
navigationStyles: any;
private html = this.document.documentElement;
constructor() {
@@ -86,12 +96,7 @@ export class AppStateService {
}
}
private stateStore: { [key: string]: any } = {};
getState(menuStyles: string): any {
return this.stateStore[menuStyles];
}
private applyThemeBackgroundSpecificChanges(background: any) {
private applyThemeBackgroundSpecificChanges(background: ThemeBackground) {
this.html?.style.setProperty('--color-bodybg', background.main);
this.html?.style.setProperty('--color-bodybg2', background.secondary);
this.html?.style.setProperty('--color-light', background.accent);
@@ -257,7 +262,7 @@ export class AppStateService {
if (state.themePrimary) {
this.applyPrimarySpecificChanges(state.themePrimary);
}
if (state.themeBackground) {
if (state.themeBackground && typeof state.themeBackground === 'object') {
this.applyThemeBackgroundSpecificChanges(state.themeBackground);
}
if (state.headerColor) {
@@ -1,33 +0,0 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { API_CONFIG } from '../../config/api.config';
@Injectable({ providedIn: 'root' })
export class BaseApiService {
private readonly http = inject(HttpClient);
protected get<T>(resource: string, params?: Record<string, string | number | boolean>): Observable<T> {
let httpParams = new HttpParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
httpParams = httpParams.set(key, String(value));
});
}
return this.http.get<T>(`${API_CONFIG.baseUrl}${resource}`, { params: httpParams });
}
protected post<T, R = unknown>(resource: string, body: T): Observable<R> {
return this.http.post<R>(`${API_CONFIG.baseUrl}${resource}`, body);
}
protected put<T, R = unknown>(resource: string, body: T): Observable<R> {
return this.http.put<R>(`${API_CONFIG.baseUrl}${resource}`, body);
}
protected delete<R = unknown>(resource: string): Observable<R> {
return this.http.delete<R>(`${API_CONFIG.baseUrl}${resource}`);
}
}
+27
View File
@@ -25,11 +25,13 @@ export const SAAS_MENU_DATA: MenuContext = {
dirchange: false,
children: [
{ path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false },
{ path: '/global-masters/exchange-rates', title: 'Exchange Rates (ROE)', type: 'link', dirchange: false },
{ path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false },
{ path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false },
{ path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false },
{ path: '/global-masters/states', title: 'State', type: 'link', dirchange: false },
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
{ path: '/global-masters/plans', title: 'Plans', type: 'link', dirchange: false },
// {
// title: 'Tenant Master',
@@ -54,6 +56,30 @@ export const SAAS_MENU_DATA: MenuContext = {
children: [
{ path: '/organizations', title: 'Dashboard', type: 'link', dirchange: false },
{ path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false },
{ path: '/organizations/awaiting-database', title: 'Assign Database & Activate', type: 'link', dirchange: false },
{ path: '/organizations/tenant-domains', title: 'Tenant Domains', type: 'link', dirchange: false },
],
},
{
title: 'Billing',
icon: '<i class="bx bx-credit-card side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/billing/plans-subscriptions', title: 'Plans & Subscriptions', type: 'link', dirchange: false },
],
},
{
title: 'Localization',
icon: '<i class="bx bx-globe side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/localization/translations-manager', title: 'Translations Manager', type: 'link', dirchange: false },
],
},
{
@@ -65,6 +91,7 @@ export const SAAS_MENU_DATA: MenuContext = {
dirchange: false,
children: [
{ path: '/settings/branding', title: 'Branding', type: 'link', dirchange: false },
{ path: '/settings/db-connections', title: 'Database Connections', type: 'link', dirchange: false },
],
},
],
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, tap } from 'rxjs';
import { MenuContext } from '../../models/context/context.model';
import { SAAS_MENU_DATA } from './menu.data';
import { Menu } from '../../../core/services/common/nav.service';
import { Menu } from '../../models/menu/menu.model';
import { TokenStorageService } from '../auth/token-storage.service';
@Injectable({ providedIn: 'root' })
+3 -22
View File
@@ -3,28 +3,9 @@ import { toObservable } from '@angular/core/rxjs-interop';
import { Subject, fromEvent } from 'rxjs';
import { takeUntil, debounceTime } from 'rxjs/operators';
import { Router } from '@angular/router';
import { Menu } from '../../models/menu/menu.model';
export interface Menu {
headTitle?: string;
headTitle2?: string;
path?: string;
title?: string;
icon?: string;
type?: string;
badgeValue?: string;
badgeClass?: string;
badgeText?: string;
active?: boolean;
selected?: boolean;
bookmark?: boolean;
children?: Menu[];
children2?: Menu[];
Menusub?: boolean;
target?: boolean;
menutype?: string;
dirchange?: boolean;
nochild?: any;
}
export type { Menu };
@Injectable({
providedIn: 'root',
@@ -77,7 +58,7 @@ export class NavService implements OnDestroy {
}
});
if (window.innerWidth < 991) {
this.router.events.subscribe(() => {
this.router.events.pipe(takeUntil(this.unsubscriber)).subscribe(() => {
this.collapseSidebar = true;
this.megaMenu = false;
this.levelMenu = false;
@@ -0,0 +1,85 @@
import { Injectable } from '@angular/core';
import Swal, { SweetAlertIcon, SweetAlertOptions } from 'sweetalert2';
@Injectable({
providedIn: 'root'
})
export class NotificationService {
/**
* Triggers a top-right SweetAlert notification programmatically.
* Matches structure: position: 'top-end', showConfirmButton: false, timer: 1500
*
* @param message Dynamic message text to display
* @param icon SweetAlert icon type ('success' | 'error' | 'warning' | 'info')
* @param timer Notification display duration in ms (default 1500ms)
*/
notify(message: string, icon: SweetAlertIcon = 'success', timer = 1500): void {
Swal.fire({
position: 'top-end',
icon: icon,
title: message,
showConfirmButton: false,
timer: timer
});
}
/**
* Triggers top-right success notification
*/
success(message: string, timer = 1500): void {
this.notify(message, 'success', timer);
}
/**
* Displays Danger Sweetalert error modal centered on screen.
* Matches template design: icon: 'error', title: 'Oops...', text: message & DangerSweetalert class
*
* @param message Dynamic error text
* @param title Error title (defaults to 'Oops...')
* @param footer Optional footer HTML link (defaults to null)
*/
error(
message: string,
title = 'Oops...',
footer: string | null = null
): void {
Swal.fire({
position: 'center',
icon: 'error',
title: title,
text: message,
...(footer ? { footer: footer } : {}),
showConfirmButton: true,
confirmButtonText: 'OK',
customClass: {
popup: 'DangerSweetalert'
}
});
}
/**
* Triggers top-right warning notification
*/
warning(message: string, timer = 2500): void {
this.notify(message, 'warning', timer);
}
/**
* Triggers top-right info notification
*/
info(message: string, timer = 2000): void {
this.notify(message, 'info', timer);
}
/**
* Custom SweetAlert call with top-right defaults preset
*/
custom(options: SweetAlertOptions): void {
Swal.fire({
position: 'top-end',
showConfirmButton: false,
timer: 1500,
...options
});
}
}
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { finalize, forkJoin, Observable, of, shareReplay, tap } from 'rxjs';
import { AppContextState, MenuContext } from '../../models/context/context.model';
import { finalize, Observable, of, shareReplay, tap } from 'rxjs';
import { MenuContext } from '../../models/context/context.model';
import { MenuService } from '../common/menu.service';
import { PermissionService } from '../common/permission.service';
import { TenantContextService } from './tenant-context.service';
@@ -15,9 +15,7 @@ export class AppContextService {
private readonly menuService = inject(MenuService);
private readonly navService = inject(NavService);
private loadRequest$: Observable<AppContextState> | null = null;
private menuLoadRequest$: Observable<MenuContext> | null = null;
private loaded = false;
ensureMenuInitialized(forceReload = false): Observable<MenuContext> {
const currentMenu = this.menuService.menuContext();
@@ -43,62 +41,11 @@ export class AppContextService {
return this.menuLoadRequest$;
}
loadAppContext(forceReload = false): Observable<AppContextState> {
if (this.loaded && !forceReload) {
return of(this.getCurrentState());
}
if (this.loadRequest$ && !forceReload) {
return this.loadRequest$;
}
this.loadRequest$ = forkJoin({
user: this.userContextService.loadCurrentUserProfile(),
tenant: this.tenantContextService.loadTenantContext(),
permissions: this.permissionService.loadPermissions(),
menu: this.menuService.loadMenu(),
}).pipe(
tap((context) => {
this.navService.setMenuItems(this.menuService.getNavigationMenu());
this.loaded = true;
}),
finalize(() => {
this.loadRequest$ = null;
}),
shareReplay(1)
);
return this.loadRequest$;
}
ensureContextLoaded(): Observable<AppContextState> {
return this.loadAppContext();
}
clearContext(): void {
this.userContextService.clear();
this.tenantContextService.clear();
this.permissionService.clear();
this.menuService.clear();
this.navService.clearMenuItems();
this.loaded = false;
this.loadRequest$ = null;
}
getDefaultLandingPage(): string {
return this.userContextService.currentUserContext()?.defaultLandingPage
?? this.permissionService.permissionContext()?.defaultLandingPage
?? this.tenantContextService.tenantContext()?.defaultLandingPage
?? this.menuService.getDefaultLandingPage()
?? '/dashboards/crm';
}
private getCurrentState(): AppContextState {
return {
user: this.userContextService.currentUserContext() ?? { id: '', email: '', roles: [] },
tenant: this.tenantContextService.tenantContext() ?? {},
permissions: this.permissionService.permissionContext() ?? { roles: [], permissions: [] },
menu: this.menuService.menuContext() ?? { items: [] },
};
}
}
@@ -0,0 +1,16 @@
export function resolveSafeReturnUrl(returnUrl: string | null | undefined, fallbackUrl: string): string {
const candidate = returnUrl?.trim() ?? '';
if (!candidate) {
return fallbackUrl;
}
const lowerCandidate = candidate.toLowerCase();
const isSafeInternal =
candidate.startsWith('/') &&
!candidate.startsWith('//') &&
!candidate.includes('\\') &&
!lowerCandidate.includes('http://') &&
!lowerCandidate.includes('https://');
return isSafeInternal ? candidate : fallbackUrl;
}
+3
View File
@@ -0,0 +1,3 @@
export function isEndpointRequest(requestUrl: string, endpointUrl: string): boolean {
return requestUrl === endpointUrl || requestUrl.startsWith(`${endpointUrl}?`);
}
+22
View File
@@ -0,0 +1,22 @@
import { LoginResponse, UserProfile } from '../../features/authentication/models/auth.model';
export function normalizeUserProfile(
response: LoginResponse,
fallbackUser: UserProfile | null = null
): UserProfile | null {
if (response.user) {
return response.user;
}
const userId = response.userId?.trim();
const email = response.email?.trim();
if (userId || email) {
return {
id: userId ?? fallbackUser?.id ?? '',
email: email ?? fallbackUser?.email ?? '',
roles: response.roles,
};
}
return fallbackUser;
}
@@ -6,6 +6,7 @@ import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model';
import { TokenStorageService } from '../../../core/services/auth/token-storage.service';
import { AppContextService } from '../../../core/services/context/app-context.service';
import { AUTH_ENDPOINTS } from '../../../core/end-points/auth/auth.endpoints';
import { normalizeUserProfile } from '../../../core/utils/user-profile.util';
@Injectable({ providedIn: 'root' })
export class AuthService {
@@ -29,7 +30,7 @@ export class AuthService {
login(payload: LoginRequest, rememberMe: boolean): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${AUTH_ENDPOINTS.login}`, payload).pipe(
map((response) => {
const user = this.normalizeUserProfile(response);
const user = normalizeUserProfile(response);
this.tokenStorage.saveAuth(response, rememberMe);
this.setAuthState(user, this.tokenStorage.getAccessToken());
return response;
@@ -50,7 +51,7 @@ export class AuthService {
this.refreshRequest$ = this.http.post<LoginResponse>(`${AUTH_ENDPOINTS.refresh}`, { refreshToken }).pipe(
map((response) => {
const user = this.normalizeUserProfile(response, this.currentUserSignal());
const user = normalizeUserProfile(response, this.currentUserSignal());
const storageType = this.tokenStorage.getStorageType();
const rememberMe = storageType === 'local';
this.tokenStorage.saveAuth(response, rememberMe);
@@ -113,22 +114,4 @@ export class AuthService {
this.currentUserSignal.set(user);
this.accessTokenSignal.set(token);
}
private normalizeUserProfile(response: LoginResponse, fallbackUser: UserProfile | null = null): UserProfile | null {
if (response.user) {
return response.user;
}
const userId = response.userId?.trim();
const email = response.email?.trim();
if (userId || email) {
return {
id: userId ?? fallbackUser?.id ?? '',
email: email ?? fallbackUser?.email ?? '',
roles: response.roles,
};
}
return fallbackUser;
}
}
@@ -4,9 +4,10 @@ import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthService } from '../../data-access/auth.service';
import { ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { catchError, finalize, of, switchMap, tap } from 'rxjs';
import { AppContextService } from '../../../../core/services/context/app-context.service';
import { NotificationService } from '../../../../core/services/common/notification.service';
import { resolveSafeReturnUrl } from '../../../../core/utils/safe-return-url.util';
@Component({
selector: 'app-login',
@@ -20,6 +21,7 @@ export class Login {
private readonly formBuilder = inject(FormBuilder);
private readonly cdr = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly notification = inject(NotificationService);
private readonly fallbackRoute = '/dashboards/crm';
public readonly adminLoginForm = this.formBuilder.nonNullable.group({
@@ -40,8 +42,7 @@ export class Login {
public authservice: AuthService,
private appContextService: AppContextService,
private route: ActivatedRoute,
private router: Router,
private toastr: ToastrService
private router: Router
) { }
login() {
@@ -80,12 +81,12 @@ export class Login {
.subscribe({
next: () => {
if (this.loginError) {
this.toastr.error(this.loginError);
this.notification.error(this.loginError);
return;
}
void this.router.navigateByUrl(this.getSafeReturnUrl());
this.toastr.success('Login successful', username);
this.notification.success('Login successful');
},
error: (error) => {
this.loginError =
@@ -93,7 +94,7 @@ export class Login {
error?.error?.message ||
'Invalid email or password';
this.toastr.error(this.loginError);
this.notification.error(this.loginError);
}
});
}
@@ -104,20 +105,7 @@ export class Login {
}
private getSafeReturnUrl(): string {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim() ?? '';
if (!returnUrl) {
return this.fallbackRoute;
}
const lowerReturnUrl = returnUrl.toLowerCase();
const isSafeInternalUrl =
returnUrl.startsWith('/') &&
!returnUrl.startsWith('//') &&
!returnUrl.includes('\\') &&
!lowerReturnUrl.includes('http://') &&
!lowerReturnUrl.includes('https://');
return isSafeInternalUrl ? returnUrl : this.fallbackRoute;
return resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'), this.fallbackRoute);
}
}
@@ -0,0 +1,11 @@
import { Routes } from '@angular/router';
import { superAdminGuard } from '../../core/guards/auth/super-admin.guard';
export const billingRoutes: Routes = [
{
path: 'plans-subscriptions',
canActivate: [superAdminGuard],
loadComponent: () => import('./plans-subscriptions/pages/plans-subscriptions/plans-subscriptions').then((m) => m.PlansSubscriptions),
data: { childTitle: 'Plans & Subscriptions', parentTitle: 'Billing', subParentTitle: 'Configuration' },
}
];
@@ -0,0 +1,107 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="md"
[submitAction]="isChangePlanMode() ? 'update' : 'save'"
[submitLabel]="isChangePlanMode() ? 'Change Plan' : 'Save'"
[loadingLabel]="isChangePlanMode() ? 'Changing...' : 'Saving...'"
[loading]="saving()"
(closed)="closeModal()"
(submitted)="save()"
>
<form [formGroup]="subscriptionForm" (ngSubmit)="save()" autocomplete="off" class="py-1">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
@if (isCreateMode()) {
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="tenantId"
inputId="subscription-tenant-id"
variant="floating"
size="sm"
label="Tenant"
placeholder="Select tenant"
[required]="true"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
[submitAttempted]="submitAttempted()"
[searchFn]="searchTenants"
[valueWith]="tenantValue"
[displayWith]="tenantDisplay"
[selectedItem]="selectedTenant()"
(itemSelected)="selectedTenant.set($event)"
[validationMessages]="{ required: 'Tenant is required.' }"
wrapperClass="w-full"
/>
</div>
}
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="planId"
inputId="subscription-plan-id"
variant="floating"
size="sm"
label="Plan"
placeholder="Select plan"
[required]="true"
[minSearchLength]="0"
[submitAttempted]="submitAttempted()"
[searchFn]="searchPlans"
[valueWith]="planValue"
[displayWith]="planDisplay"
[selectedItem]="selectedPlan()"
(itemSelected)="selectedPlan.set($event)"
[validationMessages]="{ required: 'Plan is required.' }"
wrapperClass="w-full"
/>
</div>
@if (isCreateMode()) {
<div class="col-span-12 md:col-span-6">
<app-form-select
formControlName="status"
inputId="subscription-status"
variant="floating"
label="Status"
placeholder="Select status"
[options]="statusOptions()"
[required]="true"
[searchable]="false"
[clearable]="false"
dropdownPosition="auto"
appendTo="body"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Status is required.' }"
wrapperClass="w-full"
/>
</div>
}
@if (isCreateMode()) {
<div class="col-span-12 md:col-span-6">
<app-form-date-picker
formControlName="startsOn"
inputId="subscription-starts-on"
variant="floating"
label="Starts On"
mode="single"
[required]="true"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Start date is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-date-picker
formControlName="endsOn"
inputId="subscription-ends-on"
variant="floating"
label="Ends On"
mode="single"
[submitAttempted]="submitAttempted()"
/>
</div>
}
</div>
</form>
</modal>
@@ -0,0 +1,229 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CreateSubscriptionRequest,
SubscriptionDto,
SubscriptionFormMode,
SubscriptionStatus
} from '../../models/subscription.model';
import { SubscriptionService } from '../../data-access/subscription.service';
import { PlanLookupDto, PlanService } from '../../../../global-masters/plans/public-api';
import { TenantLookupDto } from '../../../../tenants/models/tenant.model';
import { TenantService } from '../../../../tenants/data-access/tenant.service';
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
import { FormDatePicker } from '../../../../../shared/components/form/form-date-picker/form-date-picker';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { Modal } from '../../../../../shared/components/modal/modal';
@Component({
selector: 'app-subscription-form-modal',
standalone: true,
imports: [Modal, ReactiveFormsModule, FormSelect, FormDatePicker, Autocomplete],
templateUrl: './subscription-form-modal.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SubscriptionFormModalComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly subscriptionApi = inject(SubscriptionService);
private readonly planApi = inject(PlanService);
private readonly tenantApi = inject(TenantService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<SubscriptionFormMode>('create');
/** Optional pre-fill for create mode (e.g. opened from a tenant-scoped context). Always editable. */
readonly tenantId = input<string | null>(null);
readonly subscriptionId = input<string | null>(null);
readonly saved = output<SubscriptionDto>();
readonly closed = output<void>();
readonly saving = signal(false);
readonly submitAttempted = signal(false);
readonly selectedPlan = signal<PlanLookupDto | null>(null);
readonly selectedTenant = signal<TenantLookupDto | null>(null);
readonly subscriptionForm = this.formBuilder.group({
tenantId: this.formBuilder.control<string | null>(null),
planId: this.formBuilder.control<string | null>(null, [Validators.required]),
status: this.formBuilder.control<SubscriptionStatus>(SubscriptionStatus.Trialing, [Validators.required]),
startsOn: this.formBuilder.control<string | null>(null),
endsOn: this.formBuilder.control<string | null>(null)
});
readonly isCreateMode = computed(() => this.mode() === 'create');
readonly isChangePlanMode = computed(() => this.mode() === 'change-plan');
readonly statusOptions = signal<FormSelectOption<SubscriptionStatus>[]>([
{ value: SubscriptionStatus.Trialing, label: 'Trialing' },
{ value: SubscriptionStatus.Active, label: 'Active' },
{ value: SubscriptionStatus.PastDue, label: 'Past Due' },
{ value: SubscriptionStatus.Canceled, label: 'Canceled' },
{ value: SubscriptionStatus.Expired, label: 'Expired' }
]);
readonly modalTitle = computed(() => this.isChangePlanMode() ? 'Change Plan' : 'Add New Subscription');
readonly searchTenants: AutocompleteSearchFn<TenantLookupDto> = (term, limit) =>
this.tenantApi.autocomplete(term, limit).pipe(catchError(() => of([])));
readonly tenantDisplay: AutocompleteDisplayFn<TenantLookupDto> = tenant => [tenant.code, tenant.name].filter(Boolean).join(' - ');
readonly tenantValue: AutocompleteValueFn<TenantLookupDto, string> = tenant => tenant.id;
readonly searchPlans: AutocompleteSearchFn<PlanLookupDto> = (term, limit) =>
this.planApi.autocomplete(term, limit).pipe(catchError(() => of([])));
readonly planDisplay: AutocompleteDisplayFn<PlanLookupDto> = plan => `${plan.code} - ${plan.name}`;
readonly planValue: AutocompleteValueFn<PlanLookupDto, string> = plan => plan.id;
constructor() {
effect(() => {
if (this.open()) {
this.prepareModal();
}
});
}
prepareModal(): void {
this.submitAttempted.set(false);
this.selectedPlan.set(null);
this.selectedTenant.set(null);
this.subscriptionForm.reset({
tenantId: this.tenantId(),
planId: null,
status: SubscriptionStatus.Trialing,
startsOn: new Date().toISOString().split('T')[0],
endsOn: null
});
this.applyModeValidators();
const tenantId = this.tenantId();
if (this.isCreateMode() && tenantId) {
this.tenantApi.getTenantById(tenantId).pipe(
map(tenant => ({ id: tenant.id, code: tenant.code, name: tenant.name })),
catchError(() => of(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe(tenant => this.selectedTenant.set(tenant));
}
const subscriptionId = this.subscriptionId();
if (this.isChangePlanMode() && subscriptionId) {
this.subscriptionApi.getById(subscriptionId).pipe(
switchMap(subscription => {
this.subscriptionForm.controls.planId.setValue(subscription.planId);
return this.planApi.getPlanById(subscription.planId).pipe(
catchError(() => of(null))
);
}),
catchError(() => of(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe(plan => {
if (plan) {
this.selectedPlan.set({ id: plan.id, code: plan.code, name: plan.name });
}
});
}
}
/** Only the fields relevant to the active mode are required — the others are hidden in the template. */
private applyModeValidators(): void {
const controls = this.subscriptionForm.controls;
const createOnly = [controls.tenantId, controls.planId, controls.startsOn];
if (this.isCreateMode()) {
createOnly.forEach(control => control.setValidators([Validators.required]));
} else {
createOnly.forEach(control => control.clearValidators());
}
if (this.isChangePlanMode()) {
controls.planId.setValidators([Validators.required]);
}
createOnly.forEach(control => control.updateValueAndValidity({ emitEvent: false }));
}
save(): void {
this.submitAttempted.set(true);
if (this.subscriptionForm.invalid || this.saving()) return;
const val = this.subscriptionForm.getRawValue();
this.saving.set(true);
if (this.isChangePlanMode()) {
const subscriptionId = this.subscriptionId();
if (!subscriptionId) return;
this.subscriptionApi.changePlan({ subscriptionId, planId: val.planId! }).pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: subscription => {
this.notification.success('Subscription plan changed successfully.');
this.saved.emit(subscription);
this.closed.emit();
},
error: err => this.handleSaveError(err)
});
} else {
const request: CreateSubscriptionRequest = {
tenantId: val.tenantId!,
planId: val.planId!,
status: val.status ?? SubscriptionStatus.Trialing,
startsOn: this.resolveStartsOn(val.startsOn!),
endsOn: val.endsOn || null
};
this.subscriptionApi.createSubscription(request).pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: subscription => {
this.notification.success('Subscription created successfully.');
this.saved.emit(subscription);
this.closed.emit();
},
error: err => this.handleSaveError(err)
});
}
}
closeModal(): void {
if (this.saving()) return;
this.closed.emit();
}
private resolveStartsOn(dateOnly: string): string {
const now = new Date();
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
return dateOnly === localToday ? now.toISOString() : dateOnly;
}
private handleSaveError(error: HttpErrorResponse): void {
const message = error?.error?.detail || error?.error?.message || error?.error?.title;
const fallback = this.isChangePlanMode()
? 'Unable to change plan. Please try again.'
: 'Unable to create subscription. Please try again.';
this.notification.error(message || fallback);
}
}
@@ -0,0 +1,27 @@
import { buildApiUrl } from '../../../../core/config/api-url.util';
export const SUBSCRIPTION_ENDPOINTS = {
/**
* Not yet implemented on the backend (SubscriptionsController currently only exposes
* Create/ChangePlan/Cancel/UpdateStatus/Delete/GetById — no list endpoint). This targets
* the DataTableRequest/DataTableResponse convention every other grid in this app uses
* (Plans, Currency, Tenants, ...), so the UI is ready the moment it ships.
*/
dataTable: buildApiUrl('masterAdmin', '/v1/subscriptions/datatable'),
create: buildApiUrl('masterAdmin', '/v1/subscriptions'),
changePlan: buildApiUrl('masterAdmin', '/v1/subscriptions/change-plan'),
cancel: buildApiUrl('masterAdmin', '/v1/subscriptions/cancel'),
updateStatus: (id: string) =>
buildApiUrl('masterAdmin', `/v1/subscriptions/${encodeURIComponent(id)}/status`),
delete: (id: string) =>
buildApiUrl('masterAdmin', `/v1/subscriptions/${encodeURIComponent(id)}`),
getById: (id: string) =>
buildApiUrl('masterAdmin', `/v1/subscriptions/${encodeURIComponent(id)}`),
/**
* Not yet implemented on the backend (SubscriptionsController currently only
* exposes GetById). Requesting this path lets the UI wire up the moment the
* endpoint ships, without another round of client changes.
*/
getByTenantId: (tenantId: string) =>
buildApiUrl('masterAdmin', `/v1/subscriptions/by-tenant/${encodeURIComponent(tenantId)}`)
} as const;
@@ -0,0 +1,61 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { DataTableQuery, DataTableResult } from '../../../../shared/components/data-table/data-table.types';
import {
CancelSubscriptionRequest,
ChangeSubscriptionPlanRequest,
CreateSubscriptionRequest,
SubscriptionDto,
UpdateSubscriptionStatusRequest
} from '../models/subscription.model';
import { SUBSCRIPTION_ENDPOINTS } from './subscription.endpoints';
@Injectable({ providedIn: 'root' })
export class SubscriptionService {
private readonly http = inject(HttpClient);
getSubscriptionDataTable(query: DataTableQuery): Observable<DataTableResult<SubscriptionDto>> {
return this.http.post<DataTableResult<SubscriptionDto>>(SUBSCRIPTION_ENDPOINTS.dataTable, query);
}
createSubscription(request: CreateSubscriptionRequest): Observable<SubscriptionDto> {
return this.http.post<SubscriptionDto>(SUBSCRIPTION_ENDPOINTS.create, request);
}
changePlan(request: ChangeSubscriptionPlanRequest): Observable<SubscriptionDto> {
return this.http.post<SubscriptionDto>(SUBSCRIPTION_ENDPOINTS.changePlan, request);
}
cancel(request: CancelSubscriptionRequest): Observable<SubscriptionDto> {
return this.http.post<SubscriptionDto>(SUBSCRIPTION_ENDPOINTS.cancel, request);
}
updateStatus(id: string, request: UpdateSubscriptionStatusRequest): Observable<SubscriptionDto> {
return this.http.patch<SubscriptionDto>(SUBSCRIPTION_ENDPOINTS.updateStatus(id), request);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(SUBSCRIPTION_ENDPOINTS.delete(id));
}
getById(id: string): Observable<SubscriptionDto> {
return this.http.get<SubscriptionDto>(SUBSCRIPTION_ENDPOINTS.getById(id));
}
/**
* Resolves the current subscription for a tenant. The backend does not yet expose
* a lookup-by-tenant endpoint (SubscriptionsController only supports GetById) — this
* targets the endpoint shape that needs to be added. A 404 is treated as "no
* subscription for this tenant"; any other error (e.g. 405 while the route doesn't
* exist yet) is rethrown so the caller can distinguish "not found" from "unavailable".
*/
getByTenantId(tenantId: string): Observable<SubscriptionDto | null> {
return this.http.get<SubscriptionDto>(SUBSCRIPTION_ENDPOINTS.getByTenantId(tenantId)).pipe(
catchError((error: HttpErrorResponse) =>
error.status === 404 ? of(null) : throwError(() => error))
);
}
}
@@ -0,0 +1,76 @@
import { DataTableRecord } from '../../../../shared/components/data-table/data-table.types';
export enum SubscriptionStatus {
Trialing = 0,
Active = 1,
PastDue = 2,
Canceled = 3,
Expired = 4
}
export interface SubscriptionDto {
id: string;
tenantId: string;
/** Denormalized display fields — confirmed absent from Create/ChangePlan/Cancel/GetById responses;
* expected only from the (not-yet-implemented) datatable listing endpoint, which needs them to
* render a grid without per-row lookups. Always resolve the plan name via PlanService as a fallback. */
tenantName?: string | null;
planId: string;
planCode?: string | null;
planName?: string | null;
status: SubscriptionStatus;
startsOn: string;
endsOn: string | null;
/** Per-subscription limit overrides — confirmed present on the real backend response,
* seeded from the plan's defaults at creation. This is the "editable override per
* customer" the UI describes, not the linked plan's own limits. */
maxCompanies: number;
maxUsers: number;
maxStorageGb: number;
trialExpiresAt?: string | null;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface CreateSubscriptionRequest {
tenantId: string;
planId: string;
status: SubscriptionStatus;
startsOn: string;
endsOn: string | null;
}
export interface ChangeSubscriptionPlanRequest {
subscriptionId: string;
planId: string;
}
export interface CancelSubscriptionRequest {
subscriptionId: string;
}
/** Confirmed via live validation error — this is a plain activate/deactivate toggle,
* not an arbitrary SubscriptionStatus setter (matches the Plans/Currency isActive pattern). */
export interface UpdateSubscriptionStatusRequest {
isActive: boolean;
}
export type SubscriptionFormMode = 'create' | 'change-plan';
export interface SubscriptionTableRow extends DataTableRecord {
readonly id: string;
readonly tenantId: string;
readonly tenantName: string;
readonly planId: string;
readonly planCode: string;
readonly planName: string;
readonly status: SubscriptionStatus;
readonly startsOn: string;
readonly endsOn: string | null;
readonly maxCompanies: number;
readonly maxUsers: number;
readonly maxStorageGb: number;
readonly isActive: boolean;
readonly serialNumber: number;
}
@@ -0,0 +1,41 @@
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[actions]="actions()"
[totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Tenant Subscriptions"
buttonTitle="Add New Subscription"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search subscriptions..."
[searchDebounceTime]="300"
(addClicked)="onAddSubscription()"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)"
(actionClicked)="onSubscriptionActionClick($event)"
/>
<app-confirm-dialog
title="Cancel Subscription"
text="Do you really want to cancel this subscription?"
confirmButtonText="Cancel Subscription"
cancelButtonText="Keep Subscription"
(confirmed)="onCancelConfirmed()"
(cancelled)="onCancelDismissed()"
/>
<app-subscription-form-modal
[open]="showSubscriptionModal()"
[mode]="subscriptionModalMode()"
[tenantId]="subscriptionModalTenantId()"
[subscriptionId]="subscriptionModalSubscriptionId()"
(saved)="onSubscriptionModalSaved($event)"
(closed)="onSubscriptionModalClosed()"
/>
@@ -0,0 +1,369 @@
import { Component, DestroyRef, OnInit, computed, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { catchError, of, switchMap } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { PlanDto, PlanService } from '../../../../global-masters/plans/public-api';
import { TenantLookupDto } from '../../../../tenants/models/tenant.model';
import { TenantService } from '../../../../tenants/data-access/tenant.service';
import { SubscriptionDto, SubscriptionFormMode, SubscriptionStatus, SubscriptionTableRow } from '../../models/subscription.model';
import { SubscriptionService } from '../../data-access/subscription.service';
import { SubscriptionFormModalComponent } from '../../components/subscription-form-modal/subscription-form-modal';
import { DataTable } from '../../../../../shared/components/data-table/data-table';
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
import { DataTableAction, DataTableActionEvent, DataTableColumn } from '../../../../../shared/components/data-table/data-table.types';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import { AutocompleteDisplayFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { Button } from '../../../../../shared/components/button/button';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
const SUBSCRIPTION_STATUS_LABELS: Record<SubscriptionStatus, string> = {
[SubscriptionStatus.Trialing]: 'Trialing',
[SubscriptionStatus.Active]: 'Active',
[SubscriptionStatus.PastDue]: 'Past Due',
[SubscriptionStatus.Canceled]: 'Canceled',
[SubscriptionStatus.Expired]: 'Expired'
};
function subscriptionStatusBadgeClass(status: SubscriptionStatus): string {
switch (status) {
case SubscriptionStatus.Active: return 'badge bg-success/10 text-success';
case SubscriptionStatus.Trialing: return 'badge bg-info/10 text-info';
case SubscriptionStatus.PastDue: return 'badge bg-warning/10 text-warning';
case SubscriptionStatus.Canceled: return 'badge bg-danger/10 text-danger';
case SubscriptionStatus.Expired: return 'badge bg-light text-defaulttextcolor';
default: return 'badge bg-light text-defaulttextcolor';
}
}
function formatDate(value: string | null): string {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleDateString();
}
@Component({
selector: 'plans-subscriptions',
standalone: true,
imports: [
DataTable,
ReactiveFormsModule,
Autocomplete,
Button,
ConfirmDialog,
SubscriptionFormModalComponent
],
providers: [DataTableStore],
templateUrl: './plans-subscriptions.html'
})
export class PlansSubscriptions implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly planApi = inject(PlanService);
private readonly tenantApi = inject(TenantService);
private readonly subscriptionApi = inject(SubscriptionService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<SubscriptionDto, SubscriptionTableRow>);
readonly subscriptionsDataTableUnavailable = signal(false);
readonly formatDate = formatDate;
readonly subscriptionStatusBadgeClass = subscriptionStatusBadgeClass;
readonly tenantForm = this.formBuilder.nonNullable.group({ tenantId: [''] });
readonly selectedTenant = signal<TenantLookupDto | null>(null);
readonly subscriptionLoading = signal(false);
readonly subscription = signal<SubscriptionDto | null>(null);
readonly subscriptionPlan = signal<PlanDto | null>(null);
readonly subscriptionLookupUnavailable = signal(false);
readonly cancelling = signal(false);
readonly statusChangingId = signal<string | null>(null);
readonly cancelConfirmDialog = viewChild(ConfirmDialog);
private pendingCancelId: string | null = null;
readonly showSubscriptionModal = signal(false);
readonly subscriptionModalMode = signal<SubscriptionFormMode>('create');
readonly subscriptionModalTenantId = signal<string | null>(null);
readonly subscriptionModalSubscriptionId = signal<string | null>(null);
readonly currentPlanName = computed(() =>
this.subscription()?.planName ?? this.subscriptionPlan()?.name ?? '—'
);
readonly limitsSummary = computed(() => {
const subscription = this.subscription();
return subscription
? `${subscription.maxCompanies} companies · ${subscription.maxUsers} users · ${subscription.maxStorageGb} GB`
: '—';
});
readonly subscriptionStatusLabel = computed(() => {
const subscription = this.subscription();
return subscription ? SUBSCRIPTION_STATUS_LABELS[subscription.status] : '—';
});
readonly canCancelSubscription = computed(() => {
const subscription = this.subscription();
return !!subscription
&& subscription.status !== SubscriptionStatus.Canceled
&& subscription.status !== SubscriptionStatus.Expired;
});
readonly columns = signal<DataTableColumn<SubscriptionTableRow>[]>([
{ key: 'tenantName', label: 'Tenant / Organization', header: 'Tenant / Organization', sortable: true, align: 'left' },
{
key: 'planName', label: 'Plan', header: 'Plan', sortable: true, align: 'left',
formatter: (value, row) => `${row.planCode ? row.planCode + ' - ' : ''}${value ?? '—'}`
},
{
key: 'status', label: 'Status', header: 'Status', sortable: true, badge: true,
badgeClass: value => subscriptionStatusBadgeClass(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: 'endsOn', label: 'Ends On', header: 'Ends On', sortable: true, align: 'left', formatter: value => formatDate(value as string | null) },
{
key: 'isActive', label: 'Active', header: 'Active', sortable: true, badge: true,
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Yes' : 'No'
}
]);
readonly actions = signal<DataTableAction<SubscriptionTableRow>[]>([
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-primary' },
{ type: 'change-plan', label: 'Change Plan', icon: 'ti ti-replace', className: 'text-primary' },
{
type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning',
visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id
},
{
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id
},
{
type: 'cancel', label: 'Cancel Subscription', icon: 'ti ti-x', className: 'text-danger',
visible: row => row.status !== SubscriptionStatus.Canceled && row.status !== SubscriptionStatus.Expired
}
]);
readonly searchTenants: AutocompleteSearchFn<TenantLookupDto> = (term, limit) =>
this.tenantApi.autocomplete(term, limit).pipe(
catchError(() => {
this.notification.error('Unable to load tenants.');
return of<readonly TenantLookupDto[]>([]);
})
);
readonly displayTenant: AutocompleteDisplayFn<TenantLookupDto> = tenant => [tenant.code, tenant.name].filter(Boolean).join(' - ');
readonly tenantValue: AutocompleteValueFn<TenantLookupDto, string> = tenant => tenant.id;
ngOnInit(): void {
this.tableStore.initialize({
fetcher: query => this.subscriptionApi.getSubscriptionDataTable(query),
mapRow: (sub, serialNumber) => ({
id: sub.id,
tenantId: sub.tenantId,
tenantName: sub.tenantName ?? '—',
planId: sub.planId,
planCode: sub.planCode ?? '',
planName: sub.planName ?? '—',
status: sub.status,
startsOn: sub.startsOn,
endsOn: sub.endsOn,
maxCompanies: sub.maxCompanies,
maxUsers: sub.maxUsers,
maxStorageGb: sub.maxStorageGb,
isActive: sub.isActive,
serialNumber
}),
onError: () => this.subscriptionsDataTableUnavailable.set(true)
});
}
onAddSubscription(): void {
this.subscriptionModalMode.set('create');
this.subscriptionModalTenantId.set(null);
this.subscriptionModalSubscriptionId.set(null);
this.showSubscriptionModal.set(true);
}
onSubscriptionActionClick(event: DataTableActionEvent<SubscriptionTableRow>): void {
const row = event.row;
this.applyRowAsSelection(row);
switch (event.action.type) {
case 'activate':
this.changeSubscriptionStatus(row, true);
break;
case 'deactivate':
this.changeSubscriptionStatus(row, false);
break;
case 'change-plan':
this.subscriptionModalMode.set('change-plan');
this.subscriptionModalSubscriptionId.set(row.id);
this.showSubscriptionModal.set(true);
break;
case 'cancel':
this.pendingCancelId = row.id;
this.cancelConfirmDialog()?.open();
break;
// 'view' just populates the Subscription detail panel below via applyRowAsSelection.
}
}
private changeSubscriptionStatus(row: SubscriptionTableRow, activate: boolean): void {
this.statusChangingId.set(row.id);
this.subscriptionApi.updateStatus(row.id, { isActive: activate }).pipe(
finalize(() => this.statusChangingId.set(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: (updated) => {
this.notification.success(`Subscription ${activate ? 'activated' : 'deactivated'} successfully.`);
if (this.subscription()?.id === row.id) {
this.applySubscription(updated);
}
this.tableStore.refresh();
},
error: (err) => {
const msg = err?.error?.detail || err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} subscription.`;
this.notification.error(msg);
}
});
}
/** Populates the tenant-scoped detail panel directly from a grid row — no extra API call needed. */
private applyRowAsSelection(row: SubscriptionTableRow): void {
this.selectedTenant.set({ id: row.tenantId, name: row.tenantName, code: '' });
this.tenantForm.controls.tenantId.setValue(row.tenantId);
this.subscriptionLookupUnavailable.set(false);
this.subscription.set({
id: row.id,
tenantId: row.tenantId,
tenantName: row.tenantName,
planId: row.planId,
planCode: row.planCode,
planName: row.planName,
status: row.status,
startsOn: row.startsOn,
endsOn: row.endsOn,
maxCompanies: row.maxCompanies,
maxUsers: row.maxUsers,
maxStorageGb: row.maxStorageGb,
isActive: row.isActive
});
this.planApi.getPlanById(row.planId).pipe(
catchError(() => of(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe(plan => this.subscriptionPlan.set(plan));
}
onTenantSelected(tenant: TenantLookupDto | null): void {
this.selectedTenant.set(tenant);
this.tenantForm.controls.tenantId.setValue(tenant?.id ?? '');
this.subscription.set(null);
this.subscriptionPlan.set(null);
this.subscriptionLookupUnavailable.set(false);
if (!tenant) return;
this.subscriptionLoading.set(true);
this.subscriptionApi.getByTenantId(tenant.id).pipe(
switchMap(subscription => {
if (!subscription) return of({ subscription: null, plan: null });
return this.planApi.getPlanById(subscription.planId).pipe(
catchError(() => of(null)),
switchMap(plan => of({ subscription, plan }))
);
}),
finalize(() => this.subscriptionLoading.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: ({ subscription, plan }) => {
this.subscription.set(subscription);
this.subscriptionPlan.set(plan);
},
error: () => {
// The tenant-lookup endpoint doesn't exist on the backend yet (see
// SubscriptionService.getByTenantId) — surface that distinctly from a
// real failure so the empty state reads correctly either way.
this.subscriptionLookupUnavailable.set(true);
}
});
}
/**
* Applies a subscription returned directly from a mutation (create/change-plan/cancel/
* edit-status) instead of re-fetching via getByTenantId — that lookup endpoint doesn't
* exist on the backend yet, so relying on it here would misreport a successful action.
*/
private applySubscription(subscription: SubscriptionDto): void {
this.subscription.set(subscription);
this.subscriptionLookupUnavailable.set(false);
this.selectedTenant.set({ id: subscription.tenantId, name: subscription.tenantName ?? '—', code: '' });
this.planApi.getPlanById(subscription.planId).pipe(
catchError(() => of(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe(plan => this.subscriptionPlan.set(plan));
}
onCreateSubscriptionForTenant(): void {
this.subscriptionModalMode.set('create');
this.subscriptionModalTenantId.set(this.selectedTenant()?.id ?? null);
this.subscriptionModalSubscriptionId.set(null);
this.showSubscriptionModal.set(true);
}
onChangePlan(): void {
const subscription = this.subscription();
if (!subscription) return;
this.subscriptionModalMode.set('change-plan');
this.subscriptionModalSubscriptionId.set(subscription.id);
this.showSubscriptionModal.set(true);
}
onSubscriptionModalSaved(subscription: SubscriptionDto): void {
this.showSubscriptionModal.set(false);
this.applySubscription(subscription);
this.tableStore.refresh();
}
onSubscriptionModalClosed(): void {
this.showSubscriptionModal.set(false);
}
onCancelSubscription(): void {
const subscription = this.subscription();
if (!subscription) return;
this.pendingCancelId = subscription.id;
this.cancelConfirmDialog()?.open();
}
onCancelConfirmed(): void {
const subscriptionId = this.pendingCancelId;
this.pendingCancelId = null;
if (!subscriptionId) return;
this.cancelling.set(true);
this.subscriptionApi.cancel({ subscriptionId }).pipe(
finalize(() => this.cancelling.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: updated => {
this.notification.success('Subscription canceled successfully.');
this.applySubscription(updated);
this.tableStore.refresh();
},
error: (err) => {
const message = err?.error?.detail || err?.error?.message || err?.error?.title || 'Unable to cancel subscription.';
this.notification.error(message);
}
});
}
onCancelDismissed(): void {
this.pendingCancelId = null;
}
}
@@ -5,7 +5,7 @@ export const dashboardRoutingModule: Routes = [
{
path: 'crm',
loadComponent: () => import('./crm/crm').then((m) => m.Crm),
title: 'YNEX - Crm',
title: 'Dashboard',
},
+1 -1
View File
@@ -6,7 +6,7 @@ export const errorRoutingModule: Routes = [
{
path: 'error404',
loadComponent: () => import('./error404/error404').then( (m) => m.Error404 ),
title: 'YNEX - Error 404'
title: 'Page Not Found'
},
]
@@ -1,7 +1,5 @@
<div class="page error-bg dark:!bg-bodybg" id="particles-js">
<div class="page error-bg dark:!bg-bodybg">
<!-- Start::error-page -->
<spk-particles id="tsparticles" [options]="particlesOptions" />
<div class="error-page">
<div class="container text-defaulttextcolo dark:text-defaulttextcolor/70r text-defaultsize">
<div class="text-center p-5 my-auto">
+3 -7
View File
@@ -1,16 +1,12 @@
import { Component } from '@angular/core';
import {RouterModule} from'@angular/router';
import { SpkParticles } from "../../../@spk/plugins&reusable/spk-particles/spk-particles";
import {particlesOptions} from "../particleoptions"
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-error404',
standalone: true,
imports: [RouterModule, SpkParticles],
imports: [RouterModule],
templateUrl: './error404.html',
styleUrls: ['./error404.scss']
})
export class Error404 {
particlesOptions=particlesOptions
}
@@ -1,36 +0,0 @@
export const particlesOptions = {
fpsLimit: 60, // 200 is excessive; 60 is smooth and efficient
interactivity: {
events: {
onClick: { enable: true },
onHover: { enable: true },
resize: { enable: true }
},
modes: {
push: { quantity: 4 },
repulse: { distance: 200, duration: 0.4 }
}
},
particles: {
number: {
value: 80,
density: { enable: true, value_area: 800 }
},
color: { value: "#845adf" },
shape: { type: "circle" },
opacity: { value: 0.5 },
size: { value: 2, random: true },
line_linked: {
enable: true,
distance: 150,
color: "#d1d9e0",
opacity: 0.4,
width: 1
},
move: {
enable: true,
speed: 2,
out_mode: "out"
}
}
};
@@ -12,9 +12,9 @@ import {
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import { of } from 'rxjs';
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { CityDto, CityModalMode, CreateCityRequest, UpdateCityRequest } from '../../models/city.model';
import { CountryLookupDto } from '../../../countries/models/country.model';
@@ -47,7 +47,7 @@ export class CityFormModalComponent {
private readonly countryApi = inject(CountryService);
private readonly stateApi = inject(StateService);
private readonly timezoneApi = inject(TimezoneService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<CityModalMode>('create');
@@ -152,7 +152,7 @@ export class CityFormModalComponent {
});
},
error: () => {
this.toastr.error('Unable to load city details.');
this.notification.error('Unable to load city details.');
this.closeModal();
}
});
@@ -182,7 +182,7 @@ export class CityFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('City created successfully.');
this.notification.success('City created successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -205,7 +205,7 @@ export class CityFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('City updated successfully.');
this.notification.success('City updated successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -221,9 +221,9 @@ export class CityFormModalComponent {
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.toastr.error('A city with this code already exists in this state.');
this.notification.error('A city with this code already exists in this state.');
return;
}
this.toastr.error(`Unable to ${action} city. Please try again.`);
this.notification.error(`Unable to ${action} city. Please try again.`);
}
}
@@ -1,9 +1,9 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { of } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { CityDto, UpdateCityRequest } from '../../models/city.model';
import { CountryLookupDto } from '../../../countries/models/country.model';
@@ -67,7 +67,7 @@ export class CityList implements OnInit {
private readonly countryApi = inject(CountryService);
private readonly stateApi = inject(StateService);
private readonly formBuilder = inject(FormBuilder);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<CityDto, CityTableRow>);
readonly selectedCountry = signal<CountryLookupDto | null>(null);
@@ -216,7 +216,7 @@ export class CityList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success('City deleted successfully.');
this.notification.success('City deleted successfully.');
this.tableStore.refresh();
},
error: (err) => {
@@ -228,7 +228,7 @@ export class CityList implements OnInit {
} else if (err?.error?.message || err?.error?.title) {
errorMsg = err.error.message || err.error.title;
}
this.toastr.error(errorMsg);
this.notification.error(errorMsg);
}
});
}
@@ -250,12 +250,12 @@ export class CityList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(`City ${activate ? 'activated' : 'deactivated'} successfully.`);
this.notification.success(`City ${activate ? 'activated' : 'deactivated'} successfully.`);
this.tableStore.refresh();
},
error: (err) => {
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} city.`;
this.toastr.error(msg);
this.notification.error(msg);
}
});
}
@@ -12,9 +12,9 @@ import {
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import { of } from 'rxjs';
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CountryDto,
@@ -45,7 +45,7 @@ export class CountryFormModalComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly countryApi = inject(CountryService);
private readonly currencyApi = inject(CurrencyService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<CountryModalMode>('create');
@@ -131,7 +131,7 @@ export class CountryFormModalComponent {
});
},
error: () => {
this.toastr.error('Unable to load country details.');
this.notification.error('Unable to load country details.');
this.closeModal();
}
});
@@ -162,7 +162,7 @@ export class CountryFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('Country created successfully.');
this.notification.success('Country created successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -187,7 +187,7 @@ export class CountryFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('Country updated successfully.');
this.notification.success('Country updated successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -203,9 +203,9 @@ export class CountryFormModalComponent {
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.toastr.error('A country with this ISO code already exists.');
this.notification.error('A country with this ISO code already exists.');
return;
}
this.toastr.error(`Unable to ${action} country. Please try again.`);
this.notification.error(`Unable to ${action} country. Please try again.`);
}
}
@@ -1,7 +1,7 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ToastrService } from 'ngx-toastr';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { CountryDto, UpdateCountryRequest } from '../../models/country.model';
import { CountryService } from '../../data-access/country.service';
@@ -41,7 +41,7 @@ interface CountryTableRow extends DataTableRecord {
export class CountryList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly countryApi = inject(CountryService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<CountryDto, CountryTableRow>);
readonly statusChangingId = signal<string | null>(null);
@@ -114,7 +114,7 @@ export class CountryList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success('Country deleted successfully.');
this.notification.success('Country deleted successfully.');
this.tableStore.refresh();
},
error: (err) => {
@@ -126,7 +126,7 @@ export class CountryList implements OnInit {
} else if (err?.error?.message || err?.error?.title) {
errorMsg = err.error.message || err.error.title;
}
this.toastr.error(errorMsg);
this.notification.error(errorMsg);
}
});
}
@@ -160,12 +160,12 @@ export class CountryList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(`Country ${activate ? 'activated' : 'deactivated'} successfully.`);
this.notification.success(`Country ${activate ? 'activated' : 'deactivated'} successfully.`);
this.tableStore.refresh();
},
error: (err) => {
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} country.`;
this.toastr.error(msg);
this.notification.error(msg);
}
});
}
@@ -12,8 +12,8 @@ import {
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CreateCurrencyRequest,
@@ -37,7 +37,7 @@ export class CurrencyFormModalComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly currencyApi = inject(CurrencyService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<CurrencyModalMode>('create');
@@ -110,7 +110,7 @@ export class CurrencyFormModalComponent {
});
},
error: () => {
this.toastr.error('Unable to load currency details.');
this.notification.error('Unable to load currency details.');
this.closeModal();
}
});
@@ -141,7 +141,7 @@ export class CurrencyFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('Currency created successfully.');
this.notification.success('Currency created successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -166,7 +166,7 @@ export class CurrencyFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('Currency updated successfully.');
this.notification.success('Currency updated successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -182,9 +182,9 @@ export class CurrencyFormModalComponent {
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.toastr.error('A currency with this ISO code already exists.');
this.notification.error('A currency with this ISO code already exists.');
return;
}
this.toastr.error(`Unable to ${action} currency. Please try again.`);
this.notification.error(`Unable to ${action} currency. Please try again.`);
}
}
@@ -1,7 +1,7 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ToastrService } from 'ngx-toastr';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
@@ -62,7 +62,7 @@ interface CurrencyTableRow extends DataTableRecord {
export class CurrencyList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly currencyApi = inject(CurrencyService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<CurrencyDto, CurrencyTableRow>);
readonly statusChangingId = signal<string | null>(null);
@@ -142,7 +142,7 @@ export class CurrencyList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success('Currency deleted successfully.');
this.notification.success('Currency deleted successfully.');
this.tableStore.refresh();
},
error: (err) => {
@@ -154,7 +154,7 @@ export class CurrencyList implements OnInit {
} else if (err?.error?.message || err?.error?.title) {
errorMsg = err.error.message || err.error.title;
}
this.toastr.error(errorMsg);
this.notification.error(errorMsg);
}
});
}
@@ -176,12 +176,12 @@ export class CurrencyList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(`Currency ${activate ? 'activated' : 'deactivated'} successfully.`);
this.notification.success(`Currency ${activate ? 'activated' : 'deactivated'} successfully.`);
this.tableStore.refresh();
},
error: (err) => {
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} currency.`;
this.toastr.error(msg);
this.notification.error(msg);
}
});
}
@@ -0,0 +1,144 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="lg"
[submitAction]="mode() === 'create' ? 'save' : 'update'"
[submitLabel]="mode() === 'create' ? 'Save Rate' : 'Update Rate'"
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
[loading]="saving() || modalLoading()"
[showSubmitButton]="!isViewMode()"
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
(closed)="closeModal()"
(submitted)="saveRate()"
>
@if (modalLoading()) {
<div class="flex min-h-32 items-center justify-center">
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
<span class="ms-2">Loading exchange rate details...</span>
</div>
} @else {
<form [formGroup]="form" (ngSubmit)="saveRate()" autocomplete="off" class="grid grid-cols-12 gap-4 pt-2">
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="fromCurrencyId"
inputId="from-currency"
variant="floating"
label="From Currency"
placeholder="Select Base Currency..."
[required]="!isViewMode()"
[submitAttempted]="submitAttempted()"
[searchFn]="searchCurrencies"
[displayWith]="displayCurrency"
[valueWith]="currencyValue"
[resolveValueFn]="resolveCurrency"
[selectedItem]="selectedFromCurrency()"
[minSearchLength]="0"
[debounceTime]="300"
[limit]="20"
[clearable]="!isViewMode()"
[readonly]="isViewMode()"
[validationMessages]="{ required: 'From currency is required.' }"
wrapperClass="w-full"
(itemSelected)="onFromCurrencySelected($event)"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="toCurrencyId"
inputId="to-currency"
variant="floating"
label="To Currency"
placeholder="Select Target Currency..."
[required]="!isViewMode()"
[submitAttempted]="submitAttempted()"
[searchFn]="searchCurrencies"
[displayWith]="displayCurrency"
[valueWith]="currencyValue"
[resolveValueFn]="resolveCurrency"
[selectedItem]="selectedToCurrency()"
[minSearchLength]="0"
[debounceTime]="300"
[limit]="20"
[clearable]="!isViewMode()"
[readonly]="isViewMode()"
[validationMessages]="{ required: 'To currency is required.' }"
wrapperClass="w-full"
(itemSelected)="onToCurrencySelected($event)"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="rate"
inputId="rate-input"
variant="floating"
type="number"
label="Exchange Rate"
placeholder="e.g. 83.1500"
[required]="!isViewMode()"
[submitAttempted]="submitAttempted()"
[step]="0.0001"
[min]="0.000001"
[readonly]="isViewMode()"
[validationMessages]="{ required: 'Exchange rate is required.', min: 'Exchange rate must be greater than 0.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="rateType"
inputId="rate-type-input"
variant="floating"
type="text"
label="Rate Type"
placeholder="e.g. spot, forward, custom"
[required]="!isViewMode()"
[submitAttempted]="submitAttempted()"
[readonly]="isViewMode()"
[validationMessages]="{ required: 'Rate type is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="source"
inputId="source-input"
variant="floating"
type="text"
label="Source"
placeholder="e.g. rbi, manual"
[required]="!isViewMode()"
[submitAttempted]="submitAttempted()"
[readonly]="isViewMode()"
[validationMessages]="{ required: 'Source is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-date-picker
formControlName="effectiveFrom"
inputId="effective-from-picker"
variant="floating"
label="Effective From"
placeholder="Select Start Date"
[required]="!isViewMode()"
[submitAttempted]="submitAttempted()"
[readonly]="isViewMode()"
[validationMessages]="{ required: 'Effective from date is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-date-picker
formControlName="effectiveTo"
inputId="effective-to-picker"
variant="floating"
label="Effective To (Optional)"
placeholder="Leave empty for open period"
[readonly]="isViewMode()"
/>
</div>
</form>
}
</modal>
@@ -0,0 +1,326 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CreateExchangeRateRequest,
ExchangeRateDto,
ExchangeRateModalMode,
UpdateExchangeRateRequest
} from '../../models/exchange-rate.model';
import { ExchangeRateService } from '../../data-access/exchange-rate.service';
import { CurrencyService } from '../../../currencies/data-access/currency.service';
import { CurrencyLookupDto } from '../../../currencies/models/currency.model';
import {
AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { FormDatePicker } from '../../../../../shared/components/form/form-date-picker/form-date-picker';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import { Modal } from '../../../../../shared/components/modal/modal';
@Component({
selector: 'app-exchange-rate-form-modal',
standalone: true,
imports: [
Modal,
ReactiveFormsModule,
FormInput,
FormDatePicker,
Autocomplete
],
templateUrl: './exchange-rate-form-modal.html',
styleUrl: './exchange-rate-form-modal.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExchangeRateFormModalComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly exchangeRateApi = inject(ExchangeRateService);
private readonly currencyService = inject(CurrencyService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<ExchangeRateModalMode>('create');
readonly exchangeRateId = input<string | null>(null);
readonly saved = output<void>();
readonly closed = output<void>();
readonly modalLoading = signal(false);
readonly saving = signal(false);
readonly submitAttempted = signal(false);
readonly selectedRate = signal<ExchangeRateDto | null>(null);
readonly selectedFromCurrency = signal<CurrencyLookupDto | null>(null);
readonly selectedToCurrency = signal<CurrencyLookupDto | null>(null);
readonly form = this.formBuilder.nonNullable.group({
fromCurrencyId: ['', [Validators.required]],
toCurrencyId: ['', [Validators.required]],
rate: [0, [Validators.required, Validators.min(0.000001)]],
rateType: ['spot', [Validators.required, Validators.maxLength(50)]],
source: ['manual', [Validators.required, Validators.maxLength(50)]],
effectiveFrom: ['', [Validators.required]],
effectiveTo: ['' as string | null]
});
readonly isViewMode = computed(() => this.mode() === 'view');
readonly modalTitle = computed(() => {
switch (this.mode()) {
case 'create': return 'Add Exchange Rate (ROE)';
case 'edit': return 'Edit Exchange Rate';
case 'view': return 'View Exchange Rate Details';
}
});
constructor() {
effect(() => {
if (this.open()) {
this.prepareModal(this.exchangeRateId());
}
});
}
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) => {
return this.currencyService.autocomplete(term, limit || 20);
};
readonly displayCurrency: AutocompleteDisplayFn<CurrencyLookupDto> = c => c ? `${c.code} - ${c.name}` : '';
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = c => c?.id ?? '';
readonly resolveCurrency: AutocompleteResolveValueFn<CurrencyLookupDto, string> = (id: string) => {
if (!id) return of(null);
return this.currencyService.getCurrencyById(id).pipe(
map(c => ({
id: c.id,
code: c.code,
name: c.name,
symbol: c.symbol ?? ''
})),
catchError(() => of(null))
);
};
onFromCurrencySelected(c: CurrencyLookupDto | null): void {
if (this.isViewMode()) return;
this.selectedFromCurrency.set(c);
this.form.patchValue({ fromCurrencyId: c?.id ?? '' });
}
onToCurrencySelected(c: CurrencyLookupDto | null): void {
if (this.isViewMode()) return;
this.selectedToCurrency.set(c);
this.form.patchValue({ toCurrencyId: c?.id ?? '' });
}
prepareModal(id: string | null): void {
this.submitAttempted.set(false);
this.selectedFromCurrency.set(null);
this.selectedToCurrency.set(null);
const todayStr = new Date().toISOString().split('T')[0];
this.form.enable();
this.form.reset({
fromCurrencyId: '',
toCurrencyId: '',
rate: 1.0,
rateType: 'spot',
source: 'manual',
effectiveFrom: todayStr,
effectiveTo: null
});
if (!id || this.mode() === 'create') {
this.selectedRate.set(null);
this.modalLoading.set(false);
return;
}
this.modalLoading.set(true);
this.exchangeRateApi.getExchangeRateById(id).pipe(
finalize(() => this.modalLoading.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: rate => {
this.selectedRate.set(rate);
let effFrom = '';
if (rate.effectiveFrom) {
effFrom = String(rate.effectiveFrom).split(/[T\s]/)[0];
}
let effTo: string | null = null;
if (rate.effectiveTo) {
effTo = String(rate.effectiveTo).split(/[T\s]/)[0];
}
this.form.patchValue({
fromCurrencyId: rate.fromCurrencyId,
toCurrencyId: rate.toCurrencyId,
rate: rate.rate,
rateType: rate.rateType,
source: rate.source,
effectiveFrom: effFrom,
effectiveTo: effTo
});
if (rate.fromCurrencyId) {
if (rate.fromCurrencyCode) {
this.selectedFromCurrency.set({
id: rate.fromCurrencyId,
code: rate.fromCurrencyCode,
name: rate.fromCurrencyName ?? rate.fromCurrencyCode,
symbol: ''
});
} else {
this.currencyService.getCurrencyById(rate.fromCurrencyId).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: currency => {
if (currency) {
this.selectedFromCurrency.set({
id: currency.id,
code: currency.code,
name: currency.name,
symbol: currency.symbol ?? ''
});
}
},
error: () => {}
});
}
}
if (rate.toCurrencyId) {
if (rate.toCurrencyCode) {
this.selectedToCurrency.set({
id: rate.toCurrencyId,
code: rate.toCurrencyCode,
name: rate.toCurrencyName ?? rate.toCurrencyCode,
symbol: ''
});
} else {
this.currencyService.getCurrencyById(rate.toCurrencyId).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: currency => {
if (currency) {
this.selectedToCurrency.set({
id: currency.id,
code: currency.code,
name: currency.name,
symbol: currency.symbol ?? ''
});
}
},
error: () => {}
});
}
}
},
error: () => {
this.notification.error('Unable to load exchange rate details.');
this.closeModal();
}
});
}
saveRate(): void {
if (this.isViewMode()) {
this.closeModal();
return;
}
this.submitAttempted.set(true);
this.form.markAllAsTouched();
if (this.form.invalid || this.saving()) return;
this.saving.set(true);
const formVal = this.form.getRawValue();
if (this.mode() === 'create') {
const request: CreateExchangeRateRequest = {
fromCurrencyId: formVal.fromCurrencyId,
toCurrencyId: formVal.toCurrencyId,
rate: Number(formVal.rate),
rateType: formVal.rateType.trim(),
source: formVal.source.trim(),
effectiveFrom: typeof formVal.effectiveFrom === 'string' ? formVal.effectiveFrom : '',
effectiveTo: formVal.effectiveTo ? String(formVal.effectiveTo) : null
};
this.exchangeRateApi.createExchangeRate(request).pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.saving.set(false);
this.notification.success('Exchange Rate created successfully. Open period auto-closed if applicable.');
this.saved.emit();
this.closed.emit();
},
error: err => this.handleSaveError(err, 'create')
});
} else {
const id = this.exchangeRateId();
if (!id) return;
const request: UpdateExchangeRateRequest = {
fromCurrencyId: formVal.fromCurrencyId,
toCurrencyId: formVal.toCurrencyId,
rate: Number(formVal.rate),
rateType: formVal.rateType.trim(),
source: formVal.source.trim(),
effectiveFrom: typeof formVal.effectiveFrom === 'string' ? formVal.effectiveFrom : '',
effectiveTo: formVal.effectiveTo ? String(formVal.effectiveTo) : null,
isActive: this.selectedRate()?.isActive ?? true
};
this.exchangeRateApi.updateExchangeRate(id, request).pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.saving.set(false);
this.notification.success('Exchange Rate updated successfully.');
this.saved.emit();
this.closed.emit();
},
error: err => this.handleSaveError(err, 'update')
});
}
}
closeModal(): void {
if (this.saving()) return;
this.closed.emit();
}
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.notification.error('An overlapping exchange rate already exists for this currency pair and effective date range.');
return;
}
this.notification.error(`Unable to ${action} exchange rate. Please try again.`);
}
}
@@ -0,0 +1,42 @@
import { buildApiUrl } from '../../../../core/config/api-url.util';
export const EXCHANGE_RATE_ENDPOINTS = {
dataTable: buildApiUrl(
'masterAdmin',
'/v1/exchange-rates/datatable'
),
create: buildApiUrl(
'masterAdmin',
'/v1/exchange-rates'
),
getById: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/exchange-rates/${encodeURIComponent(id)}`
),
update: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/exchange-rates/${encodeURIComponent(id)}`
),
delete: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/exchange-rates/${encodeURIComponent(id)}`
),
changeStatus: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/exchange-rates/${encodeURIComponent(id)}/status`
),
autocomplete: buildApiUrl(
'masterAdmin',
'/v1/exchange-rates/autocomplete'
),
} as const;
@@ -0,0 +1,57 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { DataTableQuery, DataTableResult } from '../../../../shared/components/data-table/data-table.types';
import {
CreateExchangeRateRequest,
ExchangeRateDto,
ExchangeRateLookupDto,
UpdateExchangeRateRequest,
UpdateExchangeRateStatusRequest
} from '../models/exchange-rate.model';
import { EXCHANGE_RATE_ENDPOINTS } from './exchange-rate.endpoints';
@Injectable({
providedIn: 'root'
})
export class ExchangeRateService {
private readonly http = inject(HttpClient);
getExchangeRateDataTable(
query: DataTableQuery & Record<string, unknown>
): Observable<DataTableResult<ExchangeRateDto>> {
return this.http.post<DataTableResult<ExchangeRateDto>>(EXCHANGE_RATE_ENDPOINTS.dataTable, query);
}
createExchangeRate(request: CreateExchangeRateRequest): Observable<ExchangeRateDto> {
return this.http.post<ExchangeRateDto>(EXCHANGE_RATE_ENDPOINTS.create, request);
}
updateExchangeRate(id: string, request: UpdateExchangeRateRequest): Observable<ExchangeRateDto> {
return this.http.put<ExchangeRateDto>(EXCHANGE_RATE_ENDPOINTS.update(id), request);
}
updateStatus(id: string, request: UpdateExchangeRateStatusRequest): Observable<ExchangeRateDto> {
return this.http.patch<ExchangeRateDto>(EXCHANGE_RATE_ENDPOINTS.changeStatus(id), request);
}
deleteExchangeRate(id: string): Observable<void> {
return this.http.delete<void>(EXCHANGE_RATE_ENDPOINTS.delete(id));
}
getExchangeRateById(id: string): Observable<ExchangeRateDto> {
return this.http.get<ExchangeRateDto>(EXCHANGE_RATE_ENDPOINTS.getById(id));
}
autocomplete(term: string | null, limit = 10): Observable<readonly ExchangeRateLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly ExchangeRateLookupDto[]>(EXCHANGE_RATE_ENDPOINTS.autocomplete, { params });
}
}
@@ -0,0 +1,60 @@
export interface ExchangeRateDto {
id: string;
fromCurrencyId: string;
fromCurrencyCode?: string;
fromCurrencyName?: string;
toCurrencyId: string;
toCurrencyCode?: string;
toCurrencyName?: string;
rate: number;
effectiveFrom: string;
effectiveTo?: string | null;
rateType: string;
source: string;
isActive: boolean;
status?: string;
createdOn?: string;
modifiedOn?: string | null;
}
export interface CreateExchangeRateRequest {
fromCurrencyId: string;
toCurrencyId: string;
rate: number;
effectiveFrom: string;
effectiveTo?: string | null;
rateType: string;
source: string;
}
export interface UpdateExchangeRateRequest {
fromCurrencyId: string;
toCurrencyId: string;
rate: number;
effectiveFrom: string;
effectiveTo?: string | null;
rateType: string;
source: string;
isActive?: boolean;
}
export interface UpdateExchangeRateStatusRequest {
isActive: boolean;
status?: string;
}
export interface ExchangeRateLookupDto {
id: string;
pair: string;
rate: number;
effectiveFrom: string;
effectiveTo?: string | null;
}
export interface ExchangeRateFilterParams {
organizationId?: string | null;
rateType?: string | null;
search?: string | null;
}
export type ExchangeRateModalMode = 'create' | 'edit' | 'view';
@@ -0,0 +1,119 @@
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
[totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" tableTitle="Exchange Rates (ROE)" buttonTitle="Add Exchange Rate"
[showSearch]="true" [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()"
searchPlaceholder="Search pair..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()"
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)" (actionClicked)="onActionClick($event)"
(filterClicked)="onToggleFilters()">
<!-- Built-in Datatable Toolbar Filter Form -->
<ng-template appDataTableToolbar>
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter()" autocomplete="off"
class="flex flex-wrap items-end gap-3 w-full">
<div class="w-64 min-w-[200px]">
<app-autocomplete formControlName="organizationId" inputId="exchange-rate-org-filter" variant="floating"
size="sm" label="Organization" placeholder="Search organization" [searchFn]="searchOrganizations"
[displayWith]="displayOrg" [valueWith]="orgValue" [selectedItem]="selectedOrgLookup()" [minSearchLength]="0"
[debounceTime]="300" [limit]="20" [clearable]="true" [hideValidation]="true" wrapperClass="!mb-0 w-full"
(itemSelected)="onOrgSelected($event)" />
</div>
<div class="w-64 min-w-[200px]">
<app-form-input formControlName="rateType" inputId="exchange-rate-type-filter" variant="floating" type="text"
label="Rate Type" placeholder="e.g. spot, forward" [hideValidation]="true" />
</div>
<div class="flex items-center gap-2">
<app-button action="custom" label="Apply" icon="ti ti-filter" variant="primary-full" type="submit" size="sm"
className="!rounded-full shadow-sm !mb-0 min-h-8" />
<app-button action="custom" label="Reset" icon="ti ti-refresh" variant="outline-primary" type="button" size="sm"
className="!rounded-full shadow-sm !mb-0 min-h-8" (buttonClicked)="onResetFilter()" />
</div>
</form>
</ng-template>
<!-- Custom From Currency Cell -->
<ng-template appDataTableCell="fromCurrencyCode" let-row let-value="value">
<span class="font-bold text-gray-800 dark:text-gray-200 tracking-wide">
{{ getCurrencyDisplay(row.fromCurrencyId, value) }}
</span>
</ng-template>
<!-- Custom To Currency Cell -->
<ng-template appDataTableCell="toCurrencyCode" let-row let-value="value">
<span class="font-bold text-gray-800 dark:text-gray-200 tracking-wide">
{{ getCurrencyDisplay(row.toCurrencyId, value) }}
</span>
</ng-template>
<!-- Custom Rate Cell -->
<ng-template appDataTableCell="rate" let-row let-value="value">
<span class="font-mono font-semibold text-primary dark:text-primary-light">
{{ value | number:'1.4-4' }}
</span>
</ng-template>
<!-- Custom Type Cell (High contrast badge) -->
<ng-template appDataTableCell="rateType" let-row let-value="value">
<span
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-bold bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light uppercase tracking-wider">
{{ value }}
</span>
</ng-template>
<!-- Custom Source Cell -->
<ng-template appDataTableCell="source" let-row let-value="value">
<span
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-bold bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200 uppercase tracking-wider">
{{ value }}
</span>
</ng-template>
<!-- Custom Effective From Cell -->
<ng-template appDataTableCell="effectiveFrom" let-row let-value="value">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ value ? (value | date:'dd-MM-yyyy') : '-' }}
</span>
</ng-template>
<!-- Custom Effective To Cell (Soft Green Badge for Open-ended / Current periods) -->
<ng-template appDataTableCell="effectiveTo" let-row let-value="value">
@if (!value) {
<span
class="badge bg-success/10 text-success font-semibold px-2.5 py-1 rounded text-xs inline-flex items-center gap-1">
<span class="w-1.5 h-1.5 rounded-full bg-success"></span>
Current
</span>
} @else {
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ value | date:'dd-MM-yyyy' }}
</span>
}
</ng-template>
<!-- Custom Status Cell -->
<ng-template appDataTableCell="isActive" let-row let-value="value">
@if (value) {
<span class="badge bg-success/10 text-success font-semibold px-2.5 py-1 rounded text-xs">Active</span>
} @else {
<span class="badge bg-danger/10 text-danger font-semibold px-2.5 py-1 rounded text-xs">Closed</span>
}
</ng-template>
</app-data-table>
<!-- Close Period Confirm Dialog -->
<app-confirm-dialog #closeDialog title="Close Rate Period"
text="Are you sure you want to close this exchange rate period? This will set its effective to date to active period end."
confirmButtonText="Close Period" cancelButtonText="Cancel" (confirmed)="onCloseConfirmed()" />
<!-- Delete Confirm Dialog -->
<app-confirm-dialog #deleteDialog title="Delete Exchange Rate"
text="Do you really want to delete this exchange rate record?" confirmButtonText="Delete" cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()" />
<!-- Form Modal (Add / Edit / View) -->
<app-exchange-rate-form-modal [open]="tableStore.showModal()" [mode]="tableStore.modalMode()"
[exchangeRateId]="tableStore.selectedItem()?.id ?? null" (saved)="tableStore.refresh()"
(closed)="tableStore.closeModal()" />
@@ -0,0 +1,3 @@
:host {
display: block;
}
@@ -0,0 +1,272 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs/operators';
import { DatePipe, DecimalPipe } from '@angular/common';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { ExchangeRateDto, ExchangeRateFilterParams } from '../../models/exchange-rate.model';
import { ExchangeRateService } from '../../data-access/exchange-rate.service';
import { CurrencyService } from '../../../currencies/data-access/currency.service';
import { CurrencyLookupDto } from '../../../currencies/models/currency.model';
import { OrganizationService } from '../../../../organizations/pages/organization-list/data-access/organization.service';
import { OrganizationLookupDto } from '../../../../organizations/pages/organization-list/models/organization.model';
import { DataTable, DataTableToolbarDirective, DataTableCellDirective } from '../../../../../shared/components/data-table/data-table';
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
import {
DataTableAction,
DataTableActionEvent,
DataTableColumn,
DataTableRecord
} from '../../../../../shared/components/data-table/data-table.types';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { Button } from '../../../../../shared/components/button/button';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
import { ExchangeRateFormModalComponent } from '../../components/exchange-rate-form-modal/exchange-rate-form-modal';
export interface ExchangeRateTableRow extends DataTableRecord {
id: string;
fromCurrencyId: string;
fromCurrencyCode?: string;
fromCurrencyName?: string;
toCurrencyId: string;
toCurrencyCode?: string;
toCurrencyName?: string;
rate: number;
rateType: string;
source: string;
effectiveFrom: string;
effectiveTo: string | null;
isActive: boolean;
status?: string;
createdOn?: string;
modifiedOn?: string | null;
serialNumber: number;
}
@Component({
selector: 'app-exchange-rate-list',
standalone: true,
imports: [
ReactiveFormsModule,
DataTable,
DataTableToolbarDirective,
DataTableCellDirective,
Autocomplete,
FormInput,
Button,
ConfirmDialog,
ExchangeRateFormModalComponent,
DatePipe,
DecimalPipe
],
providers: [DataTableStore],
templateUrl: './exchange-rate-list.html',
styleUrl: './exchange-rate-list.scss'
})
export class ExchangeRateList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly exchangeRateApi = inject(ExchangeRateService);
private readonly currencyService = inject(CurrencyService);
private readonly orgService = inject(OrganizationService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<ExchangeRateDto, ExchangeRateTableRow>);
readonly closingId = signal<string | null>(null);
readonly deletingId = signal<string | null>(null);
readonly pendingDeleteRate = signal<ExchangeRateTableRow | null>(null);
readonly pendingCloseRate = signal<ExchangeRateTableRow | null>(null);
readonly deleteConfirmDialog = viewChild('deleteDialog', { read: ConfirmDialog });
readonly closeConfirmDialog = viewChild('closeDialog', { read: ConfirmDialog });
readonly showFilters = signal(false);
readonly selectedOrgLookup = signal<OrganizationLookupDto | null>(null);
readonly currencyCodeMap = signal<Record<string, string>>({});
private readonly pendingCurrencyFetchIds = new Set<string>();
readonly filterForm = this.formBuilder.group({
organizationId: [''],
rateType: ['']
});
readonly columns = signal<DataTableColumn<ExchangeRateTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' },
{ key: 'fromCurrencyCode', label: 'From', header: 'From', sortable: true, headerAlign: 'center', align: 'center', width:'200px'},
{ key: 'toCurrencyCode', label: 'To', header: 'To', sortable: true, headerAlign: 'center', align: 'center', width:'200px'},
{ key: 'rate', label: 'Rate', header: 'Rate', sortable: true, headerAlign: 'right', align: 'right' },
{ key: 'rateType', label: 'Type', header: 'Type', sortable: true, headerAlign: 'center', align: 'center' },
{ key: 'source', label: 'Source', header: 'Source', sortable: true, headerAlign: 'center', align: 'center' },
{ key: 'effectiveFrom', label: 'Effective From', header: 'Effective From', sortable: true, headerAlign: 'center', align: 'center'},
{ key: 'effectiveTo', label: 'Effective To', header: 'Effective To', sortable: true, headerAlign: 'center', align: 'center' },
{ key: 'isActive', label: 'Status', header: 'Status', sortable: true, headerAlign: 'center', align: 'center' }
]);
readonly actions = signal<DataTableAction<ExchangeRateTableRow>[]>([
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
{
type: 'delete',
label: 'Delete',
icon: 'ti ti-trash',
className: 'text-danger',
disabled: row => this.closingId() === row.id || this.deletingId() === row.id
}
]);
ngOnInit(): void {
this.loadCurrencyLookup();
this.tableStore.initialize({
fetcher: query => {
const orgId = this.filterForm.controls.organizationId.value;
const rType = this.filterForm.controls.rateType.value;
const fullQuery = {
...query,
...(orgId ? { organizationId: orgId } : {}),
...(rType ? { rateType: rType } : {})
};
return this.exchangeRateApi.getExchangeRateDataTable(fullQuery);
}
});
}
private loadCurrencyLookup(): void {
this.currencyService.autocomplete('', 100).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: (list: readonly CurrencyLookupDto[]) => {
const map: Record<string, string> = { ...this.currencyCodeMap() };
for (const c of list) {
if (c.id && c.code) {
map[c.id] = c.code;
}
}
this.currencyCodeMap.set(map);
},
error: () => {}
});
}
getCurrencyDisplay(id: string, explicitCode?: string): string {
if (explicitCode) return explicitCode;
if (!id) return '-';
const map = this.currencyCodeMap();
if (map[id]) return map[id];
if (!this.pendingCurrencyFetchIds.has(id)) {
this.pendingCurrencyFetchIds.add(id);
this.currencyService.getCurrencyById(id).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: currency => {
if (currency?.id && currency?.code) {
this.currencyCodeMap.update(m => ({ ...m, [currency.id]: currency.code }));
}
},
error: () => {}
});
}
return id ? id.substring(0, 8) + '...' : '-';
}
readonly searchOrganizations: AutocompleteSearchFn<OrganizationLookupDto> = (term, limit) => {
return this.orgService.autocomplete(term, limit || 20);
};
readonly displayOrg: AutocompleteDisplayFn<OrganizationLookupDto> = org => org?.name ?? '';
readonly orgValue: AutocompleteValueFn<OrganizationLookupDto, string> = org => org?.id ?? '';
onOrgSelected(org: OrganizationLookupDto | null): void {
this.selectedOrgLookup.set(org);
this.filterForm.patchValue({ organizationId: org?.id ?? '' });
}
onToggleFilters(): void {
this.showFilters.update(v => !v);
}
onApplyFilter(): void {
this.tableStore.refresh();
}
onResetFilter(): void {
this.selectedOrgLookup.set(null);
this.filterForm.reset({
organizationId: '',
rateType: ''
});
this.tableStore.refresh();
}
onAddRate(): void {
this.tableStore.openCreateModal();
}
onActionClick(event: DataTableActionEvent<ExchangeRateTableRow>): void {
if (event.action.type === 'view') this.tableStore.openViewModal(event.row as ExchangeRateDto);
if (event.action.type === 'edit') this.tableStore.openEditModal(event.row as ExchangeRateDto);
if (event.action.type === 'close') this.requestClosePeriod(event.row);
if (event.action.type === 'delete') this.requestDeleteRate(event.row);
}
requestClosePeriod(row: ExchangeRateTableRow): void {
this.pendingCloseRate.set(row);
this.closeConfirmDialog()?.open();
}
onCloseConfirmed(): void {
const rate = this.pendingCloseRate();
if (!rate) return;
this.pendingCloseRate.set(null);
this.closingId.set(rate.id);
this.exchangeRateApi.updateStatus(rate.id, { isActive: false, status: 'Closed' }).pipe(
finalize(() => this.closingId.set(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.notification.success('Exchange rate period closed successfully.');
this.tableStore.refresh();
},
error: () => {
this.notification.error('Unable to close exchange rate period.');
}
});
}
requestDeleteRate(row: ExchangeRateTableRow): void {
this.pendingDeleteRate.set(row);
this.deleteConfirmDialog()?.open();
}
onDeleteConfirmed(): void {
const rate = this.pendingDeleteRate();
if (!rate) return;
this.pendingDeleteRate.set(null);
this.deletingId.set(rate.id);
this.exchangeRateApi.deleteExchangeRate(rate.id).pipe(
finalize(() => this.deletingId.set(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.notification.success('Exchange rate deleted successfully.');
this.tableStore.refresh();
},
error: () => {
this.notification.error('Unable to delete exchange rate.');
}
});
}
}
@@ -0,0 +1,3 @@
export * from './models/exchange-rate.model';
export * from './data-access/exchange-rate.service';
export * from './pages/exchange-rate-list/exchange-rate-list';
@@ -1,34 +1,53 @@
import { Routes } from '@angular/router';
import { superAdminGuard } from '../../core/guards/auth/super-admin.guard';
export const globalMastersRoutes: Routes = [
{
path: 'countries',
canActivate: [superAdminGuard],
loadComponent: () => import('./countries/pages/country-list/country-list').then((m) => m.CountryList),
data: { childTitle: 'Country Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'states',
canActivate: [superAdminGuard],
loadComponent: () => import('./states/pages/state-list/state-list').then((m) => m.StateList),
data: { childTitle: 'State Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'cities',
canActivate: [superAdminGuard],
loadComponent: () => import('./cities/pages/city-list/city-list').then((m) => m.CityList),
data: { childTitle: 'City Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'currencies',
canActivate: [superAdminGuard],
loadComponent: () => import('./currencies/pages/currency-list/currency-list').then((m) => m.CurrencyList),
data: { childTitle: 'Currency Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'exchange-rates',
canActivate: [superAdminGuard],
loadComponent: () => import('./exchange-rates/pages/exchange-rate-list/exchange-rate-list').then((m) => m.ExchangeRateList),
data: { childTitle: 'Exchange Rates (ROE)', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'languages',
canActivate: [superAdminGuard],
loadComponent: () => import('./languages/pages/language-list/language-list').then((m) => m.LanguageList),
data: { childTitle: 'Language Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'timezones',
canActivate: [superAdminGuard],
loadComponent: () => import('./timezones/pages/timezone-list/timezone-list').then((m) => m.TimezoneList),
data: { childTitle: 'Timezone Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
},
{
path: 'plans',
canActivate: [superAdminGuard],
loadComponent: () => import('./plans/pages/plan-list/plan-list').then((m) => m.PlanList),
data: { childTitle: 'Plan Management', parentTitle: 'Global Master', subParentTitle: 'Configuration' },
}
];
@@ -12,8 +12,8 @@ import {
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CreateLanguageRequest,
@@ -23,7 +23,6 @@ import {
} from '../../models/language.model';
import { LanguageService } from '../../data-access/language.service';
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { FormCheckbox } from '../../../../../shared/components/form/form-checkbox/form-checkbox';
import { Modal } from '../../../../../shared/components/modal/modal';
@Component({
@@ -37,7 +36,7 @@ export class LanguageFormModalComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly languageApi = inject(LanguageService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<LanguageModalMode>('create');
@@ -100,7 +99,7 @@ export class LanguageFormModalComponent {
});
},
error: () => {
this.toastr.error('Unable to load language details.');
this.notification.error('Unable to load language details.');
this.closeModal();
}
});
@@ -130,7 +129,7 @@ export class LanguageFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('Language created successfully.');
this.notification.success('Language created successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -154,7 +153,7 @@ export class LanguageFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('Language updated successfully.');
this.notification.success('Language updated successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -170,9 +169,9 @@ export class LanguageFormModalComponent {
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.toastr.error('A language with this code already exists.');
this.notification.error('A language with this code already exists.');
return;
}
this.toastr.error(`Unable to ${action} language. Please try again.`);
this.notification.error(`Unable to ${action} language. Please try again.`);
}
}
@@ -1,7 +1,7 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ToastrService } from 'ngx-toastr';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { LanguageDto, UpdateLanguageRequest } from '../../models/language.model';
import { LanguageService } from '../../data-access/language.service';
@@ -39,7 +39,7 @@ interface LanguageTableRow extends DataTableRecord {
export class LanguageList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly languageApi = inject(LanguageService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<LanguageDto, LanguageTableRow>);
readonly statusChangingId = signal<string | null>(null);
@@ -109,7 +109,7 @@ export class LanguageList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success('Language deleted successfully.');
this.notification.success('Language deleted successfully.');
this.tableStore.refresh();
},
error: (err) => {
@@ -121,7 +121,7 @@ export class LanguageList implements OnInit {
} else if (err?.error?.message || err?.error?.title) {
errorMsg = err.error.message || err.error.title;
}
this.toastr.error(errorMsg);
this.notification.error(errorMsg);
}
});
}
@@ -143,12 +143,12 @@ export class LanguageList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(`Language ${activate ? 'activated' : 'deactivated'} successfully.`);
this.notification.success(`Language ${activate ? 'activated' : 'deactivated'} successfully.`);
this.tableStore.refresh();
},
error: (err) => {
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} language.`;
this.toastr.error(msg);
this.notification.error(msg);
}
});
}
@@ -0,0 +1,190 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="md"
[submitAction]="mode() === 'create' ? 'save' : 'update'"
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
[loading]="saving() || modalLoading()"
[showSubmitButton]="!isViewMode()"
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
(closed)="closeModal()"
(submitted)="savePlan()"
>
@if (modalLoading()) {
<div class="flex min-h-32 items-center justify-center">
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
<span class="ms-2">Loading plan...</span>
</div>
} @else {
<form [formGroup]="planForm" (ngSubmit)="savePlan()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="name"
inputId="plan-name"
variant="floating"
label="Plan Name"
placeholder="Name"
[required]="true"
[readonly]="isViewMode()"
[maxLength]="150"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Plan name is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="code"
inputId="plan-code"
variant="floating"
label="Plan Code"
placeholder="Code"
[required]="true"
[readonly]="isViewMode()"
[maxLength]="32"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Plan code is required.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="price"
inputId="plan-price"
variant="floating"
label="Price"
type="number"
inputMode="decimal"
placeholder="Price"
[required]="true"
[readonly]="isViewMode()"
[min]="0"
[step]="0.01"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Price is required.', min: 'Price must be 0 or greater.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="currencyId"
inputId="plan-currency-id"
variant="floating"
size="sm"
label="Currency"
placeholder="Select currency"
[readonly]="isViewMode()"
[minSearchLength]="0"
[clearable]="true"
[submitAttempted]="submitAttempted()"
[searchFn]="searchCurrencies"
[valueWith]="currencyValue"
[displayWith]="currencyDisplay"
[selectedItem]="selectedCurrency()"
(itemSelected)="selectedCurrency.set($event)"
(cleared)="selectedCurrency.set(null)"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-select
formControlName="billingCycle"
inputId="plan-billing-cycle"
variant="floating"
label="Billing Cycle"
placeholder="Select billing cycle"
[required]="true"
[readonly]="isViewMode()"
[clearable]="false"
[options]="billingCycleOptions()"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Billing cycle is required.' }"
/>
</div>
@if (mode() === 'edit') {
<div class="col-span-12 md:col-span-6">
<app-form-select
formControlName="isActive"
inputId="plan-is-active"
variant="floating"
label="Active Status"
placeholder="Select active status"
[required]="true"
[readonly]="isViewMode()"
[clearable]="false"
[options]="activeStatusOptions()"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Active status is required.' }"
/>
</div>
}
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="maxCompanies"
inputId="plan-max-companies"
variant="floating"
label="Max Companies"
type="number"
inputMode="numeric"
placeholder="Max Companies"
[required]="true"
[readonly]="isViewMode()"
[min]="1"
[step]="1"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Max companies is required.', min: 'Max companies must be at least 1.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="maxUsers"
inputId="plan-max-users"
variant="floating"
label="Max Users"
type="number"
inputMode="numeric"
placeholder="Max Users"
[required]="true"
[readonly]="isViewMode()"
[min]="1"
[step]="1"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Max users is required.', min: 'Max users must be at least 1.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="maxStorageGb"
inputId="plan-max-storage-gb"
variant="floating"
label="Max Storage (GB)"
type="number"
inputMode="numeric"
placeholder="Max Storage (GB)"
[required]="true"
[readonly]="isViewMode()"
[min]="1"
[step]="1"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Max storage is required.', min: 'Max storage must be at least 1 GB.' }"
/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="defaultTrialDays"
inputId="plan-default-trial-days"
variant="floating"
label="Default Trial Days"
type="number"
inputMode="numeric"
placeholder="Default Trial Days"
[required]="true"
[readonly]="isViewMode()"
[min]="0"
[step]="1"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Default trial days is required.', min: 'Default trial days must be 0 or greater.' }"
/>
</div>
</div>
</form>
}
</modal>
@@ -0,0 +1,248 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
BillingCycle,
CreatePlanRequest,
PlanDto,
PlanModalMode,
UpdatePlanRequest
} from '../../models/plan.model';
import { CurrencyLookupDto, CurrencyService } from '../../../currencies/public-api';
import { PlanService } from '../../data-access/plan.service';
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { Modal } from '../../../../../shared/components/modal/modal';
@Component({
selector: 'app-plan-form-modal',
standalone: true,
imports: [Modal, ReactiveFormsModule, FormInput, FormSelect, Autocomplete],
templateUrl: './plan-form-modal.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PlanFormModalComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly planApi = inject(PlanService);
private readonly currencyApi = inject(CurrencyService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<PlanModalMode>('create');
readonly planId = input<string | null>(null);
readonly saved = output<void>();
readonly closed = output<void>();
readonly modalLoading = signal(false);
readonly saving = signal(false);
readonly submitAttempted = signal(false);
readonly selectedPlan = signal<PlanDto | null>(null);
readonly selectedCurrency = signal<CurrencyLookupDto | null>(null);
readonly planForm = this.formBuilder.group({
name: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(150)]),
code: this.formBuilder.nonNullable.control('', [Validators.required, Validators.maxLength(32), Validators.pattern(/^[A-Za-z0-9_-]+$/)]),
price: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(0)]),
currencyId: this.formBuilder.control<string | null>(null),
billingCycle: this.formBuilder.control<BillingCycle>(BillingCycle.Monthly, [Validators.required]),
maxCompanies: this.formBuilder.control<number | null>(1, [Validators.required, Validators.min(1)]),
maxUsers: this.formBuilder.control<number | null>(1, [Validators.required, Validators.min(1)]),
maxStorageGb: this.formBuilder.control<number | null>(1, [Validators.required, Validators.min(1)]),
defaultTrialDays: this.formBuilder.control<number | null>(14, [Validators.required, Validators.min(0)]),
isActive: this.formBuilder.control<number>(1, [Validators.required])
});
readonly billingCycleOptions = signal<FormSelectOption<BillingCycle>[]>([
{ value: BillingCycle.Monthly, label: 'Monthly' },
{ value: BillingCycle.Quarterly, label: 'Quarterly' },
{ value: BillingCycle.Annual, label: 'Annual' }
]);
readonly activeStatusOptions = signal<FormSelectOption<number>[]>([
{ value: 1, label: 'Active' },
{ value: 0, label: 'Inactive' }
]);
readonly isViewMode = computed(() => this.mode() === 'view');
readonly modalTitle = computed(() => {
switch (this.mode()) {
case 'create': return 'Add Plan';
case 'edit': return 'Edit Plan';
case 'view': return 'View Plan';
}
});
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
this.currencyApi.autocomplete(term, limit).pipe(catchError(() => of([])));
readonly currencyDisplay: AutocompleteDisplayFn<CurrencyLookupDto> = currency => `${currency.code} - ${currency.name}`;
readonly currencyValue: AutocompleteValueFn<CurrencyLookupDto, string> = currency => currency.id;
constructor() {
effect(() => {
if (this.open()) {
this.prepareModal(this.planId());
}
});
}
prepareModal(id: string | null): void {
this.submitAttempted.set(false);
this.planForm.reset({
name: '', code: '', price: null, currencyId: null, billingCycle: BillingCycle.Monthly,
maxCompanies: 1, maxUsers: 1, maxStorageGb: 1, defaultTrialDays: 14, isActive: 1
});
this.selectedCurrency.set(null);
if (!id || this.mode() === 'create') {
this.selectedPlan.set(null);
this.modalLoading.set(false);
return;
}
this.modalLoading.set(true);
this.planApi.getPlanById(id).pipe(
switchMap(plan => plan.currencyId
? this.currencyApi.getCurrencyById(plan.currencyId).pipe(
map(currency => ({ plan, currency })),
catchError(() => of({ plan, currency: null }))
)
: of({ plan, currency: null })),
finalize(() => this.modalLoading.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: ({ plan, currency }) => {
this.selectedPlan.set(plan);
if (currency) {
this.selectedCurrency.set({
id: currency.id,
code: currency.code,
name: currency.name,
symbol: currency.symbol
});
}
this.planForm.patchValue({
name: plan.name,
code: plan.code,
price: plan.price,
currencyId: plan.currencyId,
billingCycle: plan.billingCycle,
maxCompanies: plan.maxCompanies,
maxUsers: plan.maxUsers,
maxStorageGb: plan.maxStorageGb,
defaultTrialDays: plan.defaultTrialDays,
isActive: plan.isActive ? 1 : 0
});
},
error: () => {
this.notification.error('Unable to load plan details.');
this.closeModal();
}
});
}
savePlan(): void {
if (this.isViewMode()) {
this.closeModal();
return;
}
this.submitAttempted.set(true);
if (this.planForm.invalid || this.saving()) return;
const val = this.planForm.getRawValue();
this.saving.set(true);
if (this.mode() === 'create') {
const request: CreatePlanRequest = {
name: val.name.trim(),
code: val.code.trim().toUpperCase(),
price: val.price!,
currencyId: val.currencyId || null,
billingCycle: val.billingCycle ?? BillingCycle.Monthly,
maxCompanies: val.maxCompanies!,
maxUsers: val.maxUsers!,
maxStorageGb: val.maxStorageGb!,
defaultTrialDays: val.defaultTrialDays!
};
this.planApi.createPlan(request).pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.notification.success('Plan created successfully.');
this.saved.emit();
this.closed.emit();
},
error: err => this.handleSaveError(err, 'create')
});
} else {
const id = this.planId();
if (!id) return;
const request: UpdatePlanRequest = {
name: val.name.trim(),
code: val.code.trim().toUpperCase(),
price: val.price!,
currencyId: val.currencyId || null,
billingCycle: val.billingCycle ?? BillingCycle.Monthly,
maxCompanies: val.maxCompanies!,
maxUsers: val.maxUsers!,
maxStorageGb: val.maxStorageGb!,
defaultTrialDays: val.defaultTrialDays!,
isActive: val.isActive === 1
};
this.planApi.updatePlan(id, request).pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.notification.success('Plan updated successfully.');
this.saved.emit();
this.closed.emit();
},
error: err => this.handleSaveError(err, 'update')
});
}
}
closeModal(): void {
if (this.saving()) return;
this.closed.emit();
}
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.notification.error('A plan with this code already exists.');
return;
}
this.notification.error(`Unable to ${action} plan. Please try again.`);
}
}
@@ -0,0 +1,15 @@
import { buildApiUrl } from '../../../../core/config/api-url.util';
export const PLAN_ENDPOINTS = {
dataTable: buildApiUrl('masterAdmin', '/v1/plans/datatable'),
create: buildApiUrl('masterAdmin', '/v1/plans'),
getById: (id: string) =>
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}`),
update: (id: string) =>
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}`),
delete: (id: string) =>
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}`),
changeStatus: (id: string) =>
buildApiUrl('masterAdmin', `/v1/plans/${encodeURIComponent(id)}/status`),
autocomplete: buildApiUrl('masterAdmin', '/v1/plans/autocomplete')
} as const;
@@ -0,0 +1,53 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { DataTableQuery, DataTableResult } from '../../../../shared/components/data-table/data-table.types';
import {
CreatePlanRequest,
PlanDto,
PlanLookupDto,
UpdatePlanRequest,
UpdatePlanStatusRequest
} from '../models/plan.model';
import { PLAN_ENDPOINTS } from './plan.endpoints';
@Injectable({ providedIn: 'root' })
export class PlanService {
private readonly http = inject(HttpClient);
getPlanDataTable(query: DataTableQuery): Observable<DataTableResult<PlanDto>> {
return this.http.post<DataTableResult<PlanDto>>(PLAN_ENDPOINTS.dataTable, query);
}
createPlan(request: CreatePlanRequest): Observable<PlanDto> {
return this.http.post<PlanDto>(PLAN_ENDPOINTS.create, request);
}
updatePlan(id: string, request: UpdatePlanRequest): Observable<PlanDto> {
return this.http.put<PlanDto>(PLAN_ENDPOINTS.update(id), request);
}
updateStatus(id: string, request: UpdatePlanStatusRequest): Observable<PlanDto> {
return this.http.patch<PlanDto>(PLAN_ENDPOINTS.changeStatus(id), request);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(PLAN_ENDPOINTS.delete(id));
}
getPlanById(id: string): Observable<PlanDto> {
return this.http.get<PlanDto>(PLAN_ENDPOINTS.getById(id));
}
autocomplete(term: string | null, limit = 10): Observable<readonly PlanLookupDto[]> {
let params = new HttpParams().set('limit', limit);
const normalizedTerm = term?.trim();
if (normalizedTerm) {
params = params.set('term', normalizedTerm);
}
return this.http.get<readonly PlanLookupDto[]>(PLAN_ENDPOINTS.autocomplete, { params });
}
}
@@ -0,0 +1,68 @@
import { DataTableRecord } from '../../../../shared/components/data-table/data-table.types';
export enum BillingCycle {
Monthly = 0,
Quarterly = 1,
Annual = 2
}
export interface PlanDto {
id: string;
name: string;
code: string;
price: number;
currencyId: string | null;
billingCycle: BillingCycle;
maxCompanies: number;
maxUsers: number;
maxStorageGb: number;
defaultTrialDays: number;
isActive: boolean;
createdOn?: string;
modifiedOn?: string | null;
}
export interface PlanLookupDto {
readonly id: string;
readonly name: string;
readonly code: string;
}
export interface CreatePlanRequest {
name: string;
code: string;
price: number;
currencyId: string | null;
billingCycle: BillingCycle;
maxCompanies: number;
maxUsers: number;
maxStorageGb: number;
defaultTrialDays: number;
}
export interface UpdatePlanRequest extends CreatePlanRequest {
isActive: boolean;
}
export interface UpdatePlanStatusRequest {
isActive: boolean;
}
export type PlanModalMode = 'create' | 'edit' | 'view';
export interface PlanTableRow extends DataTableRecord {
readonly id: string;
readonly name: string;
readonly code: string;
readonly price: number;
readonly currencyId: string | null;
readonly billingCycle: BillingCycle;
readonly maxCompanies: number;
readonly maxUsers: number;
readonly maxStorageGb: number;
readonly defaultTrialDays: number;
readonly isActive: boolean;
readonly serialNumber: number;
readonly createdOn?: string;
readonly modifiedOn?: string | null;
}
@@ -0,0 +1,37 @@
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[actions]="actions()"
[totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Plans"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
searchPlaceholder="Search plans..."
[searchDebounceTime]="300"
toolTip="Add Plan"
(addClicked)="onAddPlan()"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)"
(actionClicked)="onActionClick($event)"
/>
<app-confirm-dialog
title="Delete Plan"
text="Do you really want to delete this plan?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
<app-plan-form-modal
[open]="tableStore.showModal()"
[mode]="tableStore.modalMode()"
[planId]="tableStore.selectedItem()?.id ?? null"
(saved)="tableStore.refresh()"
(closed)="tableStore.closeModal()"
/>
@@ -0,0 +1,170 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { BillingCycle, PlanDto, PlanTableRow } from '../../models/plan.model';
import { PlanService } from '../../data-access/plan.service';
import { DataTable } from '../../../../../shared/components/data-table/data-table';
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
import {
DataTableAction,
DataTableActionEvent,
DataTableColumn
} from '../../../../../shared/components/data-table/data-table.types';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
import { PlanFormModalComponent } from '../../components/plan-form-modal/plan-form-modal';
const BILLING_CYCLE_LABELS: Record<BillingCycle, string> = {
[BillingCycle.Monthly]: 'Monthly',
[BillingCycle.Quarterly]: 'Quarterly',
[BillingCycle.Annual]: 'Annual'
};
@Component({
selector: 'plan-list',
standalone: true,
imports: [DataTable, ConfirmDialog, PlanFormModalComponent],
providers: [DataTableStore],
templateUrl: './plan-list.html',
styleUrl: './plan-list.scss'
})
export class PlanList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly planApi = inject(PlanService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<PlanDto, PlanTableRow>);
readonly statusChangingId = signal<string | null>(null);
readonly deletingId = signal<string | null>(null);
readonly pendingDeletePlan = signal<PlanTableRow | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly columns = signal<DataTableColumn<PlanTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, headerAlign: 'center', align: 'left' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true, headerAlign: 'center', align: 'center', badge: true, badgeClass: () => 'badge bg-primary/10 text-primary' },
{
key: 'price', label: 'Price', header: 'Price', sortable: true, headerAlign: 'center', align: 'right',
formatter: value => Number(value).toFixed(2)
},
{
key: 'billingCycle', label: 'Billing Cycle', header: 'Billing Cycle', sortable: true, headerAlign: 'center', align: 'center',
formatter: value => BILLING_CYCLE_LABELS[value as BillingCycle] ?? '—'
},
{ key: 'maxCompanies', label: 'Max Companies', header: 'Max Companies', sortable: true, headerAlign: 'center', align: 'center' },
{ key: 'maxUsers', label: 'Max Users', header: 'Max Users', sortable: true, headerAlign: 'center', align: 'center' },
{ key: 'maxStorageGb', label: 'Max Storage (GB)', header: 'Max Storage (GB)', sortable: true, headerAlign: 'center', align: 'center' },
{ key: 'defaultTrialDays', label: 'Trial Days', header: 'Trial Days', sortable: true, headerAlign: 'center', align: 'center' },
{
key: 'isActive', label: 'Status', header: 'Status', sortable: true, badge: true,
badgeClass: value => value === true ? 'badge bg-success/10 text-success' : 'badge bg-danger/10 text-danger',
formatter: value => value ? 'Active' : 'Inactive'
}
]);
readonly actions = signal<DataTableAction<PlanTableRow>[]>([
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
{
type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning',
visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
},
{
type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success',
visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
},
{
type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger',
disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id
}
]);
ngOnInit(): void {
this.tableStore.initialize({
fetcher: query => this.planApi.getPlanDataTable(query),
mapRow: (plan, serialNumber) => ({
id: plan.id,
name: plan.name,
code: plan.code,
price: plan.price,
currencyId: plan.currencyId,
billingCycle: plan.billingCycle,
maxCompanies: plan.maxCompanies,
maxUsers: plan.maxUsers,
maxStorageGb: plan.maxStorageGb,
defaultTrialDays: plan.defaultTrialDays,
isActive: plan.isActive,
serialNumber,
createdOn: plan.createdOn,
modifiedOn: plan.modifiedOn
})
});
}
onAddPlan(): void {
this.tableStore.openCreateModal();
}
onActionClick(event: DataTableActionEvent<PlanTableRow>): void {
if (event.action.type === 'view') this.tableStore.openViewModal(event.row);
if (event.action.type === 'edit') this.tableStore.openEditModal(event.row);
if (event.action.type === 'delete') this.requestDeletePlan(event.row);
if (event.action.type === 'activate') this.changePlanStatus(event.row, true);
if (event.action.type === 'deactivate') this.changePlanStatus(event.row, false);
}
onDeleteConfirmed(): void {
const plan = this.pendingDeletePlan();
if (!plan) return;
this.pendingDeletePlan.set(null);
this.deletingId.set(plan.id);
this.planApi.delete(plan.id).pipe(
finalize(() => this.deletingId.set(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.notification.success('Plan deleted successfully.');
this.tableStore.refresh();
},
error: (err) => {
let errorMsg = 'Unable to delete plan.';
if (err?.status === 409) {
errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete plan because it has active subscriptions.';
} else if (err?.status === 404) {
errorMsg = err?.error?.message || 'Plan not found or has already been deleted.';
} else if (err?.error?.message || err?.error?.title) {
errorMsg = err.error.message || err.error.title;
}
this.notification.error(errorMsg);
}
});
}
onDeleteCancelled(): void {
this.pendingDeletePlan.set(null);
}
private requestDeletePlan(plan: PlanTableRow): void {
this.pendingDeletePlan.set(plan);
this.deleteConfirmDialog()?.open();
}
private changePlanStatus(plan: PlanTableRow, activate: boolean): void {
this.statusChangingId.set(plan.id);
this.planApi.updateStatus(plan.id, { isActive: activate }).pipe(
finalize(() => this.statusChangingId.set(null)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.notification.success(`Plan ${activate ? 'activated' : 'deactivated'} successfully.`);
this.tableStore.refresh();
},
error: (err) => {
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} plan.`;
this.notification.error(msg);
}
});
}
}
@@ -0,0 +1,4 @@
export { PlanService } from './data-access/plan.service';
export { PlanFormModalComponent } from './components/plan-form-modal/plan-form-modal';
export { BillingCycle } from './models/plan.model';
export type { PlanDto, PlanLookupDto } from './models/plan.model';
@@ -12,9 +12,9 @@ import {
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import { of } from 'rxjs';
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
import {
@@ -45,7 +45,7 @@ export class StateFormModalComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly stateApi = inject(StateService);
private readonly countryApi = inject(CountryService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<StateModalMode>('create');
@@ -123,7 +123,7 @@ export class StateFormModalComponent {
});
},
error: () => {
this.toastr.error('Unable to load state details.');
this.notification.error('Unable to load state details.');
this.closeModal();
}
});
@@ -152,7 +152,7 @@ export class StateFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('State created successfully.');
this.notification.success('State created successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -175,7 +175,7 @@ export class StateFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('State updated successfully.');
this.notification.success('State updated successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -191,9 +191,9 @@ export class StateFormModalComponent {
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.toastr.error('A state with this code already exists in this country.');
this.notification.error('A state with this code already exists in this country.');
return;
}
this.toastr.error(`Unable to ${action} state. Please try again.`);
this.notification.error(`Unable to ${action} state. Please try again.`);
}
}
@@ -1,9 +1,9 @@
import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
import { StateDto, UpdateStateRequest } from '../../models/state.model';
@@ -59,7 +59,7 @@ export class StateList implements OnInit {
private readonly stateApi = inject(StateService);
private readonly countryApi = inject(CountryService);
private readonly formBuilder = inject(FormBuilder);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<StateDto, StateTableRow>);
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
@@ -161,7 +161,7 @@ export class StateList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success('State deleted successfully.');
this.notification.success('State deleted successfully.');
this.tableStore.refresh();
},
error: (err) => {
@@ -173,7 +173,7 @@ export class StateList implements OnInit {
} else if (err?.error?.message || err?.error?.title) {
errorMsg = err.error.message || err.error.title;
}
this.toastr.error(errorMsg);
this.notification.error(errorMsg);
}
});
}
@@ -195,12 +195,12 @@ export class StateList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(`State ${activate ? 'activated' : 'deactivated'} successfully.`);
this.notification.success(`State ${activate ? 'activated' : 'deactivated'} successfully.`);
this.tableStore.refresh();
},
error: (err) => {
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} state.`;
this.toastr.error(msg);
this.notification.error(msg);
}
});
}
@@ -12,8 +12,8 @@ import {
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CreateTimezoneRequest,
@@ -36,7 +36,7 @@ export class TimezoneFormModalComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly timezoneApi = inject(TimezoneService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<TimezoneModalMode>('create');
@@ -104,7 +104,7 @@ export class TimezoneFormModalComponent {
});
},
error: () => {
this.toastr.error('Unable to load timezone details.');
this.notification.error('Unable to load timezone details.');
this.closeModal();
}
});
@@ -133,7 +133,7 @@ export class TimezoneFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('Timezone created successfully.');
this.notification.success('Timezone created successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -156,7 +156,7 @@ export class TimezoneFormModalComponent {
).subscribe({
next: () => {
this.saving.set(false);
this.toastr.success('Timezone updated successfully.');
this.notification.success('Timezone updated successfully.');
this.saved.emit();
this.closed.emit();
},
@@ -183,9 +183,9 @@ export class TimezoneFormModalComponent {
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.toastr.error('A timezone with this IANA ID already exists.');
this.notification.error('A timezone with this IANA ID already exists.');
return;
}
this.toastr.error(`Unable to ${action} timezone. Please try again.`);
this.notification.error(`Unable to ${action} timezone. Please try again.`);
}
}
@@ -7,8 +7,8 @@ import {
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ToastrService } from 'ngx-toastr';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { TimezoneDto, UpdateTimezoneRequest } from '../../models/timezone.model';
import { TimezoneService } from '../../data-access/timezone.service';
@@ -51,7 +51,7 @@ interface TimezoneTableRow extends DataTableRecord {
export class TimezoneList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly timezoneApi = inject(TimezoneService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<TimezoneDto, TimezoneTableRow>);
readonly statusChangingId = signal<string | null>(null);
@@ -137,7 +137,7 @@ export class TimezoneList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success('Timezone deleted successfully.');
this.notification.success('Timezone deleted successfully.');
this.tableStore.refresh();
},
error: (err) => {
@@ -149,7 +149,7 @@ export class TimezoneList implements OnInit {
} else if (err?.error?.message || err?.error?.title) {
errorMsg = err.error.message || err.error.title;
}
this.toastr.error(errorMsg);
this.notification.error(errorMsg);
}
});
}
@@ -171,12 +171,12 @@ export class TimezoneList implements OnInit {
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: () => {
this.toastr.success(`Timezone ${activate ? 'activated' : 'deactivated'} successfully.`);
this.notification.success(`Timezone ${activate ? 'activated' : 'deactivated'} successfully.`);
this.tableStore.refresh();
},
error: (err) => {
const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} timezone.`;
this.toastr.error(msg);
this.notification.error(msg);
}
});
}
@@ -1,7 +1,7 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { map, tap } from 'rxjs/operators';
import { NotificationService } from '../../../core/services/common/notification.service';
import { DataTable } from '../../../shared/components/data-table/data-table';
import { DataTableStore } from '../../../shared/components/data-table/data-table.store';
@@ -14,8 +14,8 @@ import {
import {
OrganizationStatusCountsDto,
RecentOrganizationDto,
} from '../organization-list/models/organization.model';
import { OrganizationService } from '../organization-list/data-access/organization.service';
} from '../pages/organization-list/models/organization.model';
import { OrganizationService } from '../pages/organization-list/data-access/organization.service';
export interface RecentOrganizationTableRow extends DataTableRecord {
readonly id: string;
@@ -99,7 +99,7 @@ function formatDateDisplay(dateStr: string | undefined | null): string {
})
export class Dashboard implements OnInit {
private readonly router = inject(Router);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
private readonly organizationApi = inject(OrganizationService);
readonly tableStore = inject(DataTableStore<RecentOrganizationDto, RecentOrganizationTableRow>);
@@ -328,7 +328,7 @@ export class Dashboard implements OnInit {
},
onError: (err: any) => {
const msg = err?.error?.message || err?.error?.title || 'Failed to load dashboard organizations.';
this.toastr.error(msg);
this.notification.error(msg);
},
});
}
@@ -419,6 +419,6 @@ export class Dashboard implements OnInit {
void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } });
return;
}
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
this.notification.info(`${event.action.label} clicked for ${event.row.organizationName}`);
}
}
@@ -90,7 +90,15 @@ export class OnboardingStepper {
return false;
}
return index >= 0 && index < this.steps().length;
if (index < 0 || index >= this.steps().length) {
return false;
}
if (this.isEditMode()) {
return true;
}
return index <= this.currentStepIndex() || this.completedStepIndexes().includes(index - 1);
}
selectStep(index: number): void {
@@ -90,7 +90,15 @@ export class OnboardingTabs {
return false;
}
return index >= 0 && index < this.steps().length;
if (index < 0 || index >= this.steps().length) {
return false;
}
if (this.isEditMode()) {
return true;
}
return index <= this.currentStepIndex() || this.completedStepIndexes().includes(index - 1);
}
selectStep(index: number): void {
@@ -157,7 +157,15 @@ export class OrganizationOnboardingStateService {
}
canOpenStep(index: number): boolean {
return index >= 0 && index <= 3;
if (index < 0 || index > 3) {
return false;
}
if (this.isEditModeState()) {
return true;
}
return index <= this.currentStepIndexState() || this.completedStepIndexesState().includes(index - 1);
}
isStepCompleted(index: number): boolean {
@@ -189,9 +197,20 @@ export class OrganizationOnboardingStateService {
serverDraft.code, rawRecord['Code'], rawRecord['code'], rawRecord['organizationCode'], rawRecord['OrganizationCode']
) ?? null;
// The server is expected to return nested basics/localization/planLimits/admin objects. The
// flat-field checks below are a compatibility fallback for older/alternate response shapes;
// logging here makes that contract drift visible instead of silently guessing forever.
const flatFallback = (sectionName: string): RawDraftRecord => {
console.warn(
`Organization draft response (id=${serverDraft.id}) has no nested "${sectionName}" object; ` +
'falling back to flat-field heuristics. The backend response shape may have drifted from what the client expects.'
);
return rawRecord;
};
let basicsData = (firstDefined(serverDraft.basics, rawRecord['Basics'], rawRecord['basics']) ?? (
firstTruthy(rawRecord['name'], rawRecord['Name'], rawRecord['organizationName'], rawRecord['OrganizationName'], orgCode) !== undefined
? rawRecord
? flatFallback('basics')
: null
)) as RawDraftRecord | null;
@@ -205,19 +224,19 @@ export class OrganizationOnboardingStateService {
const localizationData = (firstDefined(serverDraft.localization, rawRecord['Localization'], rawRecord['localization']) ?? (
firstTruthy(rawRecord['defaultTimezoneId'], rawRecord['DefaultTimezoneId'], rawRecord['defaultCurrencyId'], rawRecord['DefaultCurrencyId'], rawRecord['dateFormat'], rawRecord['DateFormat']) !== undefined
? rawRecord
? flatFallback('localization')
: null
)) as RawDraftRecord | null;
const planLimitsData = (firstDefined(serverDraft.planLimits, rawRecord['PlanLimits'], rawRecord['planLimits'], rawRecord['plan'], rawRecord['Plan']) ?? (
firstTruthy(rawRecord['planId'], rawRecord['PlanId'], rawRecord['licenseType'], rawRecord['LicenseType'], rawRecord['maxCompanies'], rawRecord['MaxCompanies']) !== undefined
? rawRecord
? flatFallback('planLimits')
: null
)) as RawDraftRecord | null;
const adminData = (firstDefined(serverDraft.admin, rawRecord['Admin'], rawRecord['admin'], rawRecord['adminContact'], rawRecord['AdminContact']) ?? (
firstTruthy(rawRecord['adminEmail'], rawRecord['AdminEmail'], rawRecord['orgEmail'], rawRecord['OrgEmail'], rawRecord['administratorEmail'], rawRecord['AdministratorEmail']) !== undefined
? rawRecord
? flatFallback('admin')
: null
)) as RawDraftRecord | null;
@@ -266,18 +266,26 @@ export class OrganizationOnboardingService {
return this.resolveSubscriptionPlan(planId).pipe(
map(plan => {
if (!plan) return null;
if (plan.maxCompanies == null || plan.maxUsers == null || plan.maxStorageGb == null) {
// These limits must come from the server. Guessing them from the plan code (e.g. tier-based
// numbers per "ENTERPRISE"/"GROWTH") silently applies the wrong quota if the API omits them
// or a plan code changes, with no indication to the user that a fallback was used.
console.error(
`Subscription plan "${plan.code || plan.id}" is missing maxCompanies/maxUsers/maxStorageGb ` +
'in the API response. Falling back to conservative defaults; verify the plan record on the server.'
);
}
const codeUpper = (plan.code || '').toUpperCase();
const maxCompanies = plan.maxCompanies ?? (codeUpper === 'ENTERPRISE' ? 25 : codeUpper === 'GROWTH' ? 5 : 1);
const maxUsers = plan.maxUsers ?? (codeUpper === 'ENTERPRISE' ? 1000 : codeUpper === 'GROWTH' ? 100 : 10);
const maxStorageGb = plan.maxStorageGb ?? (codeUpper === 'ENTERPRISE' ? 500 : codeUpper === 'GROWTH' ? 100 : 10);
const licenseType: OrganizationLicenseType = codeUpper === 'STARTER' ? 'Trial' : 'Paid';
return {
plan,
licenseType,
maximumCompanies: maxCompanies,
maximumUsers: maxUsers,
maximumStorageGb: maxStorageGb,
maximumCompanies: plan.maxCompanies ?? 1,
maximumUsers: plan.maxUsers ?? 10,
maximumStorageGb: plan.maxStorageGb ?? 10,
defaultTrialDays: plan.defaultTrialDays ?? 14,
limitsEditable: true,
};
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { NotificationService } from '../../../core/services/common/notification.service';
import { OrganizationOnboarding } from './organization-onboarding';
import { OnboardingStepper } from './components/onboarding-stepper/onboarding-stepper';
@@ -61,7 +61,7 @@ describe('OrganizationOnboarding', () => {
provideHttpClientTesting(),
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: Router, useValue: routerStub },
{ provide: ToastrService, useValue: toastrStub },
{ provide: NotificationService, useValue: toastrStub },
],
}).compileComponents();
@@ -9,9 +9,9 @@ import {
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { catchError, finalize, forkJoin, Observable, of, switchMap } from 'rxjs';
import { NgTemplateOutlet } from '@angular/common';
import { NotificationService } from '../../../core/services/common/notification.service';
import {
OnboardingStep,
OnboardingStepper
@@ -59,7 +59,7 @@ export class OrganizationOnboarding {
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
readonly stateService = inject(OrganizationOnboardingStateService);
private readonly onboardingService = inject(OrganizationOnboardingService);
@@ -100,7 +100,7 @@ export class OrganizationOnboarding {
.pipe(
catchError(err => {
const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to restore organization details from server.';
this.toastr.error(errorMsg, 'Organization Restoration Failed');
this.notification.error(errorMsg, 'Organization Restoration Failed');
return of(null);
}),
takeUntilDestroyed(this.destroyRef)
@@ -144,6 +144,10 @@ export class OrganizationOnboarding {
}
onNext(): void {
if (this.savingDraft() || this.finishing()) {
return;
}
const currentIndex = this.currentStepIndex();
if (currentIndex >= this.steps.length - 1) {
@@ -154,8 +158,11 @@ export class OrganizationOnboarding {
return;
}
this.savingDraft.set(true);
this.persistStepToServer(currentIndex, true)
.pipe(
finalize(() => this.savingDraft.set(false)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
@@ -165,7 +172,7 @@ export class OrganizationOnboarding {
},
error: (err) => {
const errorMsg = err?.error?.detail || err?.error?.message || 'Failed to save step progress to server.';
this.toastr.error(errorMsg, 'Step Persistence Failed');
this.notification.error(errorMsg, 'Step Persistence Failed');
}
});
}
@@ -192,11 +199,11 @@ export class OrganizationOnboarding {
.subscribe({
next: () => {
this.savedDraftSnapshot = this.serializeDraftState();
this.toastr.success('Onboarding draft saved successfully on server.', 'Draft Saved');
this.notification.success('Onboarding draft saved successfully on server.');
},
error: (err) => {
const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to save onboarding draft to server.';
this.toastr.error(errorMsg, 'Draft Save Failed');
this.notification.error(errorMsg, 'Draft Save Failed');
}
});
}
@@ -212,7 +219,7 @@ export class OrganizationOnboarding {
const data = this.stateService.onboardingData();
if (!data.basics || !data.localization || !data.planLimits || !data.admin) {
this.toastr.error('Complete and validate every onboarding step before saving.');
this.notification.error('Complete and validate every onboarding step before saving.');
return;
}
@@ -243,7 +250,7 @@ export class OrganizationOnboarding {
const orgId = this.stateService.organizationId();
if (!orgId) {
this.toastr.error('Organization ID is missing. Unable to update step details.', 'Update Failed');
this.notification.error('Organization ID is missing. Unable to update step details.', 'Update Failed');
return;
}
@@ -256,9 +263,8 @@ export class OrganizationOnboarding {
if (!activeStepComponent.validate()) {
const stepLabel = this.steps[currentIndex]?.label ?? `Step ${currentIndex + 1}`;
this.toastr.warning(
`Please fix the validation errors in ${stepLabel} before saving.`,
'Validation Required'
this.notification.warning(
`Please fix the validation errors in ${stepLabel} before saving.`
);
return;
}
@@ -314,11 +320,11 @@ export class OrganizationOnboarding {
next: () => {
this.stateService.markStepCompleted(currentIndex);
this.savedDraftSnapshot = this.serializeDraftState();
this.toastr.success(`${currentStepLabel} details updated successfully.`, 'Changes Saved');
this.notification.success(`${currentStepLabel} details updated successfully.`);
},
error: (err) => {
const errorMsg = err?.error?.detail || err?.error?.message || `Failed to update ${currentStepLabel.toLowerCase()} details.`;
this.toastr.error(errorMsg, 'Update Failed');
this.notification.error(errorMsg, 'Update Failed');
}
});
}
@@ -342,7 +348,7 @@ export class OrganizationOnboarding {
const adminData = this.stateService.admin();
if (!orgId || !adminData) {
this.toastr.error('Missing organization ID or admin contact details.');
this.notification.error('Missing organization ID or admin contact details.');
return;
}
@@ -356,12 +362,10 @@ export class OrganizationOnboarding {
if (basicsDraft && (basicsDraft.organizationName || (basicsDraft as any).name || (basicsDraft as any).Name)) {
const payload = mapBasicsStepToApiRequest(basicsDraft, true);
return this.onboardingService.updateBasics(orgId, payload).pipe(
catchError(err => {
console.warn('Unable to mark basics step completed on server prior to updating subsequent step:', err);
return of(null);
})
);
// Intentionally not swallowing errors here: subsequent steps (localization/plan/admin/finish)
// must not be persisted against an organization whose basics step failed to save, or the
// server ends up with a "finished" org that never actually completed step 1.
return this.onboardingService.updateBasics(orgId, payload);
}
return of(null);
}
@@ -382,13 +386,13 @@ export class OrganizationOnboarding {
)
.subscribe({
next: () => {
this.toastr.success('Organization onboarding submitted successfully. Status updated to AwaitingDatabase.', 'Onboarding Completed');
this.notification.success('Organization onboarding submitted successfully.');
this.stateService.clear();
void this.router.navigate(['/organizations/list']);
},
error: (err) => {
const errorMsg = err?.error?.detail || err?.error?.message || 'Server error during organization provisioning.';
this.toastr.error(errorMsg, 'Provisioning Failed');
this.notification.error(errorMsg, 'Provisioning Failed');
}
});
}
@@ -473,9 +477,8 @@ export class OrganizationOnboarding {
this.getStepComponent(i)?.validate();
});
const stepLabel = this.steps[i]?.label ?? `Step ${i + 1}`;
this.toastr.warning(
`Please complete all required fields in Step ${i + 1} (${stepLabel}) before proceeding.`,
'Validation Required'
this.notification.warning(
`Please complete all required fields in Step ${i + 1} (${stepLabel}) before proceeding.`
);
}
return false;
@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { catchError, map, of } from 'rxjs';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CountryLookupDto,
@@ -50,7 +50,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
private readonly onboardingService = inject(OrganizationOnboardingService);
private readonly countryService = inject(CountryService);
private readonly industryApiService = inject(IndustryApiService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly stateService = inject(OrganizationOnboardingStateService, { optional: true });
@@ -89,7 +89,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
readonly searchOrganizationTypes: AutocompleteSearchFn<OnboardingLookupValue> = (term, limit) =>
this.onboardingService.searchOrganizationTypes(term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load organization types.');
this.notification.error('Unable to load organization types.');
return of<readonly OnboardingLookupValue[]>([]);
})
);
@@ -102,7 +102,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
secondaryLabel: item.industryCode,
}))),
catchError(() => {
this.toastr.error('Unable to load industries.');
this.notification.error('Unable to load industries.');
return of<readonly OnboardingLookupValue[]>([]);
})
);
@@ -110,7 +110,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> = (term, limit) =>
this.countryService.autocomplete(term ?? '', limit).pipe(
catchError(() => {
this.toastr.error('Unable to load countries.');
this.notification.error('Unable to load countries.');
return of<readonly CountryLookupDto[]>([]);
})
);
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { catchError, map, of } from 'rxjs';
import {
@@ -73,7 +73,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
private readonly timezoneService = inject(TimezoneService);
private readonly currencyService = inject(CurrencyService);
private readonly languageService = inject(LanguageService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
readonly submitAttempted = signal(false);
@@ -108,7 +108,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
readonly searchTimezones: AutocompleteSearchFn<TimezoneLookupDto> = (term, limit) =>
this.timezoneService.autocomplete(term ?? '', limit).pipe(
catchError(() => {
this.toastr.error('Unable to load timezones.');
this.notification.error('Unable to load timezones.');
return of<readonly TimezoneLookupDto[]>([]);
})
);
@@ -116,7 +116,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
readonly searchCurrencies: AutocompleteSearchFn<CurrencyLookupDto> = (term, limit) =>
this.currencyService.autocomplete(term ?? '', limit).pipe(
catchError(() => {
this.toastr.error('Unable to load currencies.');
this.notification.error('Unable to load currencies.');
return of<readonly CurrencyLookupDto[]>([]);
})
);
@@ -124,7 +124,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
readonly searchLanguages: AutocompleteSearchFn<LanguageLookupDto> = (term, limit) =>
this.languageService.autocomplete(term ?? '', limit).pipe(
catchError(() => {
this.toastr.error('Unable to load languages.');
this.notification.error('Unable to load languages.');
return of<readonly LanguageLookupDto[]>([]);
})
);
@@ -182,7 +182,7 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
label: `${item.code} - ${item.name}`,
}))),
catchError(() => {
this.toastr.error('Unable to load language options.');
this.notification.error('Unable to load language options.');
return of<readonly FormSelectOption<string>[]>([]);
}),
takeUntilDestroyed(this.destroyRef)
@@ -8,7 +8,7 @@ import {
ValidatorFn,
Validators,
} from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { catchError, of, switchMap } from 'rxjs';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
@@ -89,7 +89,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly onboardingService = inject(OrganizationOnboardingService);
private readonly toastr = inject(ToastrService);
private readonly notification = inject(NotificationService);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
readonly submitAttempted = signal(false);
@@ -117,7 +117,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
readonly searchPlans: AutocompleteSearchFn<OrganizationPlanLookupValue> = (term, limit) =>
this.onboardingService.searchSubscriptionPlans(term, limit).pipe(
catchError(() => {
this.toastr.error('Unable to load subscription plans.');
this.notification.error('Unable to load subscription plans.');
return of<readonly OrganizationPlanLookupValue[]>([]);
})
);
@@ -151,7 +151,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
return this.onboardingService.getPlanDefaults(planId).pipe(
catchError(() => {
this.toastr.error('Unable to load plan defaults.');
this.notification.error('Unable to load plan defaults.');
return of<OrganizationPlanDefaults | null>(null);
})
);
@@ -1,19 +1,35 @@
import { Routes } from '@angular/router';
import { superAdminGuard } from '../../core/guards/auth/super-admin.guard';
export const organizationsRoutes: Routes = [
{
path: '',
canActivate: [superAdminGuard],
loadComponent: () => import('./dashboard/dashboard').then((m) => m.Dashboard),
data: { childTitle: 'Dashboard', parentTitle: 'Organizations', subParentTitle: 'Global Organization Management' },
},
{
path: 'list',
loadComponent: () => import('./organization-list/organization-list').then((m) => m.OrganizationList),
canActivate: [superAdminGuard],
loadComponent: () => import('./pages/organization-list/organization-list').then((m) => m.OrganizationList),
data: { childTitle: 'Organization Management', parentTitle: 'Organizations', subParentTitle: 'Configuration' },
},
{
path:'onboarding',
canActivate: [superAdminGuard],
loadComponent: () => import('./organization-onboarding/organization-onboarding').then((m) => m.OrganizationOnboarding),
data: { childTitle: 'Organization Onboarding', parentTitle: 'Organizations', subParentTitle: 'Configuration' },
},
{
path: 'awaiting-database',
canActivate: [superAdminGuard],
loadComponent: () => import('./pages/organization-awaiting-db/organization-awaiting-db').then((m) => m.OrganizationAwaitingDb),
data: { childTitle: 'Assign Database & Activate', parentTitle: 'Organizations', subParentTitle: 'Configuration' },
},
{
path: 'tenant-domains',
canActivate: [superAdminGuard],
loadComponent: () => import('./pages/tenant-domain-list/tenant-domain-list').then((m) => m.TenantDomainList),
data: { childTitle: 'Tenant Domains', parentTitle: 'Organizations', subParentTitle: 'Configuration' },
}
];
];
@@ -0,0 +1,100 @@
<div class="activation-result-container space-y-5">
<!-- Warning Banner -->
<div class="p-4 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400 flex items-start gap-3">
<i class="ti ti-alert-triangle text-xl shrink-0 mt-0.5"></i>
<div class="text-sm">
<h6 class="font-semibold text-amber-800 dark:text-amber-300">Activation Result — shown ONCE</h6>
<p class="mt-0.5 opacity-90">
Please copy or save the temporary credentials below. The temporary password will not be shown again.
</p>
</div>
</div>
<!-- Status Card -->
<div class="p-4 rounded-lg bg-success/10 border border-success/20 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-success text-white font-bold text-sm">
</span>
<div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium uppercase tracking-wider text-muted">Status</span>
<span class="badge bg-success text-white font-semibold px-2.5 py-1 text-xs rounded-full">ACTIVE ✓</span>
</div>
<p class="text-xs text-muted mt-1">HQ group created in tenant DB · identity user created (tenant_admin)</p>
</div>
</div>
</div>
<!-- Admin Email & Password Fields -->
<div class="space-y-4 pt-1">
<!-- Admin Email -->
<div>
<label class="block text-xs font-medium text-defaulttextcolor mb-1.5">Admin Email</label>
<div class="relative">
<input
type="text"
readonly
[value]="result().adminEmail"
class="form-control !bg-light/60 dark:!bg-black/20 text-defaulttextcolor font-medium pr-10"
/>
<i class="ti ti-mail absolute right-3 top-1/2 -translate-y-1/2 text-muted"></i>
</div>
</div>
<!-- One-time Password -->
<div>
<label class="block text-xs font-medium text-defaulttextcolor mb-1.5">One-time Password</label>
<div class="flex gap-2">
<div class="relative flex-grow">
<input
type="text"
readonly
[value]="result().temporaryPassword"
class="form-control !bg-light/60 dark:!bg-black/20 font-mono text-defaulttextcolor font-bold tracking-wide"
/>
</div>
<app-button
type="button"
variant="outline-primary"
(clicked)="copyPassword()"
>
<i class="ti" [class.ti-check]="copied()" [class.ti-copy]="!copied()"></i>
<span class="ms-1.5">{{ copied() ? 'Copied!' : 'Copy' }}</span>
</app-button>
</div>
<p class="text-[11px] text-muted mt-1.5 flex items-center gap-1">
<i class="ti ti-info-circle text-info"></i>
<span>Never stored anywhere. Hand over to the client. Forced change at first login = roadmap</span>
</p>
</div>
</div>
<!-- Footer Help & Action Buttons -->
<div class="pt-4 border-t border-defaultborder/50 space-y-3">
<div class="p-3 rounded bg-light/50 dark:bg-black/10 border border-defaultborder/40 text-xs text-muted flex items-start gap-2">
<i class="ti ti-help-circle text-muted shrink-0 mt-0.5"></i>
<span>If activation fails midway the org stays in Awaiting Activation — fix the cause and press Retry.</span>
</div>
<div class="flex items-center justify-between gap-3 pt-2">
<app-button
type="button"
variant="outline-primary"
[loading]="retrying()"
(clicked)="onRetry()"
>
<i class="ti ti-refresh me-1.5"></i>
<span>Retry Activation</span>
</app-button>
<app-button
type="button"
variant="primary"
(clicked)="onDone()"
>
<span>Done</span>
</app-button>
</div>
</div>
</div>
@@ -0,0 +1,7 @@
:host {
display: block;
}
.activation-result-container {
font-size: 0.875rem;
}
@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, inject, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NotificationService } from '../../../../../../core/services/common/notification.service';
import { Button } from '../../../../../../shared/components/button/button';
import { OrganizationActivationResultDto } from '../../models/organization-awaiting-db.model';
@Component({
selector: 'app-activation-result-display',
standalone: true,
imports: [CommonModule, Button],
templateUrl: './activation-result-display.html',
styleUrl: './activation-result-display.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ActivationResultDisplayComponent {
private readonly notification = inject(NotificationService);
readonly result = input.required<OrganizationActivationResultDto>();
readonly retrying = input<boolean>(false);
readonly retryClicked = output<void>();
readonly doneClicked = output<void>();
readonly copied = signal(false);
copyPassword(): void {
const password = this.result().temporaryPassword;
if (!password) return;
navigator.clipboard.writeText(password).then(() => {
this.copied.set(true);
this.notification.success('One-time password copied to clipboard.');
setTimeout(() => this.copied.set(false), 3000);
}).catch(() => {
this.notification.error('Failed to copy password to clipboard.');
});
}
onRetry(): void {
this.retryClicked.emit();
}
onDone(): void {
this.doneClicked.emit();
}
}
@@ -0,0 +1,40 @@
<modal
[open]="open()"
[title]="modalTitle()"
[size]="'md'"
[showFooter]="!activationResult()"
[showSubmitButton]="!activationResult()"
[submitLabel]="'Assign & Activate'"
[loadingLabel]="'Assigning & Activating...'"
[loading]="activating()"
[submitDisabled]="form.invalid || activating()"
(closed)="closeModal()"
(submitted)="assignAndActivate()"
>
@if (!activationResult()) {
<form [formGroup]="form" (ngSubmit)="assignAndActivate()" class="space-y-4">
<div class="space-y-1.5">
<app-autocomplete
formControlName="dbConnectionId"
label="Database Connection *"
placeholder="Select an active database connection..."
[searchFn]="searchDbConnections"
[displayWith]="displayDbConnection"
[valueWith]="dbConnectionValue"
[submitAttempted]="submitAttempted()"
></app-autocomplete>
<p class="text-xs text-muted flex items-center gap-1">
<i class="ti ti-info-circle text-info"></i>
<span>Only Active, non-replica connections</span>
</p>
</div>
</form>
} @else {
<app-activation-result-display
[result]="activationResult()!"
[retrying]="activating()"
(retryClicked)="retryActivation()"
(doneClicked)="onActivationDone()"
></app-activation-result-display>
}
</modal>
@@ -0,0 +1,185 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal,
untracked
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { finalize, map } from 'rxjs/operators';
import { of } from 'rxjs';
import { Modal } from '../../../../../../shared/components/modal/modal';
import { Button } from '../../../../../../shared/components/button/button';
import { Autocomplete } from '../../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteResolveValueFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../../shared/components/form/autocomplete/autocomplete.types';
import { NotificationService } from '../../../../../../core/services/common/notification.service';
import { DbConnectionLookupDto } from '../../../../../settings/db-connections/models/db-connection.model';
import { OrganizationAwaitingDbService } from '../../data-access/organization-awaiting-db.service';
import {
AssignOrganizationDatabaseRequest,
AwaitingDbOrganizationDto,
OrganizationActivationResultDto
} from '../../models/organization-awaiting-db.model';
import { ActivationResultDisplayComponent } from '../activation-result-display/activation-result-display';
@Component({
selector: 'app-assign-db-modal',
standalone: true,
imports: [
Modal,
ReactiveFormsModule,
Autocomplete,
Button,
ActivationResultDisplayComponent
],
templateUrl: './assign-db-modal.html',
styleUrl: './assign-db-modal.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AssignDbModalComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly awaitingDbApi = inject(OrganizationAwaitingDbService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly tenant = input<AwaitingDbOrganizationDto | null>(null);
readonly closed = output<void>();
readonly activated = output<OrganizationActivationResultDto>();
readonly activating = signal(false);
readonly submitAttempted = signal(false);
readonly activationResult = signal<OrganizationActivationResultDto | null>(null);
readonly dbConnectionOptions = signal<DbConnectionLookupDto[]>([]);
readonly form = this.formBuilder.group({
dbConnectionId: ['', [Validators.required]]
});
readonly modalTitle = computed(() => {
const org = this.tenant();
if (!org) return 'Assign database';
const code = org.code || '';
const name = org.organizationName || org.name || '';
return `Assign database — ${code} ${name}`.trim();
});
readonly searchDbConnections: AutocompleteSearchFn<DbConnectionLookupDto> = (term, limit) => {
return this.awaitingDbApi.getActiveDbConnections(term, limit);
};
readonly displayDbConnection: AutocompleteDisplayFn<DbConnectionLookupDto> = item => {
if (!item) return '';
const name = item.connectionName || item.connectionCode || '';
const db = item.databaseName ? ` (${item.databaseName})` : '';
return `${name}${db}`;
};
readonly dbConnectionValue: AutocompleteValueFn<DbConnectionLookupDto, string> = item => {
return item ? item.id : '';
};
readonly resolveDbConnection: AutocompleteResolveValueFn<DbConnectionLookupDto, string> = value => {
if (!value) return of(null);
const existing = this.dbConnectionOptions().find(o => o.id === value);
if (existing) return of(existing);
return this.awaitingDbApi.getActiveDbConnections('', 50).pipe(
map(items => items.find(o => o.id === value) || null),
takeUntilDestroyed(this.destroyRef)
);
};
constructor() {
effect(() => {
const isOpen = this.open();
if (isOpen) {
untracked(() => this.prepareModal());
}
});
}
prepareModal(): void {
this.submitAttempted.set(false);
this.activating.set(false);
this.activationResult.set(null);
this.form.reset({ dbConnectionId: '' });
this.awaitingDbApi.getActiveDbConnections('', 100).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: options => this.dbConnectionOptions.set(options),
error: () => this.notification.error('Failed to load active database connections.')
});
}
assignAndActivate(): void {
this.submitAttempted.set(true);
if (this.form.invalid || this.activating()) return;
const org = this.tenant();
if (!org?.id) {
this.notification.error('Invalid organization selected.');
return;
}
const rawVal = this.form.getRawValue();
const dbConnectionId = (rawVal.dbConnectionId || '').trim();
if (!dbConnectionId) {
this.notification.error('Please select a Database Connection.');
return;
}
this.executeAssignment(org.id, dbConnectionId);
}
retryActivation(): void {
const org = this.tenant();
const dbConnectionId = this.form.controls.dbConnectionId.value;
if (org?.id && dbConnectionId) {
this.executeAssignment(org.id, dbConnectionId);
}
}
private executeAssignment(tenantId: string, dbConnectionId: string): void {
this.activating.set(true);
const request: AssignOrganizationDatabaseRequest = { dbConnectionId };
this.awaitingDbApi.assignDatabase(tenantId, request).pipe(
finalize(() => this.activating.set(false)),
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: result => {
this.activationResult.set(result);
this.notification.success('Database assigned and organization activated successfully!');
this.activated.emit(result);
},
error: err => {
const msg = err?.error?.message || err?.error?.title || 'Database assignment and activation failed. Please try again.';
this.notification.error(msg);
}
});
}
closeModal(): void {
if (this.activating()) return;
this.closed.emit();
}
onActivationDone(): void {
this.closed.emit();
}
}
@@ -0,0 +1,19 @@
import { buildApiUrl } from '../../../../../core/config/api-url.util';
export const AWAITING_DB_ENDPOINTS = {
dataTable: buildApiUrl(
'masterAdmin',
'/v1/tenants/datatable'
),
assignDatabase: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/tenants/${encodeURIComponent(id)}/assign-database`
),
dbConnectionAutocomplete: buildApiUrl(
'masterAdmin',
'/v1/db-connections/autocomplete'
),
} as const;
@@ -0,0 +1,49 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { DataTableQuery, DataTableResult } from '../../../../../shared/components/data-table/data-table.types';
import { DbConnectionLookupDto } from '../../../../settings/db-connections/models/db-connection.model';
import { AWAITING_DB_ENDPOINTS } from './organization-awaiting-db.endpoints';
import {
AssignOrganizationDatabaseRequest,
AwaitingDbOrganizationDto,
OrganizationActivationResultDto
} from '../models/organization-awaiting-db.model';
@Injectable({
providedIn: 'root'
})
export class OrganizationAwaitingDbService {
private readonly http = inject(HttpClient);
getAwaitingDbOrganizations(query: DataTableQuery): Observable<DataTableResult<AwaitingDbOrganizationDto>> {
const payload = {
...query,
status: 'AwaitingDatabase',
awaitingDatabaseOnly: true
};
return this.http.post<DataTableResult<AwaitingDbOrganizationDto>>(AWAITING_DB_ENDPOINTS.dataTable, payload);
}
assignDatabase(
tenantId: string,
request: AssignOrganizationDatabaseRequest
): Observable<OrganizationActivationResultDto> {
return this.http.post<OrganizationActivationResultDto>(
AWAITING_DB_ENDPOINTS.assignDatabase(tenantId),
request
);
}
getActiveDbConnections(term = '', limit = 50): Observable<DbConnectionLookupDto[]> {
const params = new HttpParams()
.set('term', term)
.set('limit', limit);
return this.http.get<DbConnectionLookupDto[]>(AWAITING_DB_ENDPOINTS.dbConnectionAutocomplete, { params }).pipe(
map(items => (items || []).filter(item => item.isActive !== false && !item.isReadReplica))
);
}
}
@@ -0,0 +1,41 @@
import { DataTableRecord } from '../../../../../shared/components/data-table/data-table.types';
export interface AssignOrganizationDatabaseRequest {
dbConnectionId: string;
}
export interface OrganizationActivationResultDto {
tenantId?: string;
organizationCode?: string;
organizationName?: string;
adminEmail: string;
temporaryPassword: string;
status: string;
message?: string;
}
export interface AwaitingDbOrganizationDto {
id: string;
code: string;
name: string;
organizationName?: string | null;
countryId?: string | null;
countryName?: string | null;
country?: string | null;
wizardFinished?: boolean | string | null;
wizardFinishedOn?: string | null;
status: string | number;
isActive?: boolean;
createdOn?: string;
}
export interface AwaitingDbOrganizationTableRow extends DataTableRecord {
readonly id: string;
readonly code: string;
readonly name: string;
readonly organizationName: string;
readonly country: string;
readonly wizardFinished: string;
readonly status: string;
readonly serialNumber: number;
}
@@ -0,0 +1,29 @@
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[actions]="actions()"
[totalRecords]="tableStore.filteredRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Organizations Awaiting Database & Activate"
[showSearch]="true"
rowColorMode="none"
[showAddButton]="false"
[showFilterButton]="false"
searchPlaceholder="Search organizations..."
[searchDebounceTime]="300"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)"
(actionClicked)="onActionClick($event)"
></app-data-table>
<!-- Assign Database Modal -->
<app-assign-db-modal
[open]="modalOpen()"
[tenant]="selectedTenant()"
(closed)="onModalClosed()"
(activated)="onOrganizationActivated()"
></app-assign-db-modal>
@@ -0,0 +1,3 @@
:host {
display: block;
}
@@ -0,0 +1,128 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { NotificationService } from '../../../../core/services/common/notification.service';
import { DataTable } from '../../../../shared/components/data-table/data-table';
import { DataTableStore } from '../../../../shared/components/data-table/data-table.store';
import {
DataTableAction,
DataTableActionEvent,
DataTableColumn,
} from '../../../../shared/components/data-table/data-table.types';
import { Button } from '../../../../shared/components/button/button';
import {
AwaitingDbOrganizationDto,
AwaitingDbOrganizationTableRow
} from './models/organization-awaiting-db.model';
import { OrganizationAwaitingDbService } from './data-access/organization-awaiting-db.service';
import { AssignDbModalComponent } from './components/assign-db-modal/assign-db-modal';
@Component({
selector: 'app-organization-awaiting-db',
standalone: true,
imports: [
ReactiveFormsModule,
DataTable,
Button,
AssignDbModalComponent
],
providers: [DataTableStore],
templateUrl: './organization-awaiting-db.html',
styleUrl: './organization-awaiting-db.scss',
})
export class OrganizationAwaitingDb implements OnInit {
private readonly notification = inject(NotificationService);
private readonly awaitingDbApi = inject(OrganizationAwaitingDbService);
readonly tableStore = inject(DataTableStore<AwaitingDbOrganizationDto, AwaitingDbOrganizationTableRow>);
readonly modalOpen = signal(false);
readonly selectedTenant = signal<AwaitingDbOrganizationDto | null>(null);
readonly columns = signal<DataTableColumn<AwaitingDbOrganizationTableRow>[]>([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' },
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
{
key: 'organizationName',
label: 'Organization',
header: 'Organization',
sortable: true,
align: 'left',
},
{ key: 'country', label: 'Country', header: 'Country', sortable: true },
{ key: 'wizardFinished', label: 'Wizard Finished', header: 'Wizard Finished', sortable: true },
{
key: 'status',
label: 'Status',
header: 'Status',
sortable: true,
badge: true,
badgeClass: () => 'badge bg-warning/10 text-warning border border-warning/20 font-semibold',
},
]);
readonly actions = signal<DataTableAction<AwaitingDbOrganizationTableRow>[]>([
{
type: 'assign',
label: 'Assign',
icon: 'ti ti-database-import',
className: 'text-primary font-semibold',
},
]);
ngOnInit(): void {
this.tableStore.initialize({
fetcher: query => this.awaitingDbApi.getAwaitingDbOrganizations(query),
mapRow: (item, serialNumber) => {
const resolvedName = item.organizationName || item.name || '—';
const resolvedCountry = item.countryName || item.country || '—';
let wizardStatus = 'Yes';
if (item.wizardFinished === false) {
wizardStatus = 'No';
} else if (item.wizardFinishedOn) {
wizardStatus = `Yes (${item.wizardFinishedOn})`;
}
return {
...item,
id: item.id,
code: item.code || '—',
name: resolvedName,
organizationName: resolvedName,
country: resolvedCountry,
wizardFinished: wizardStatus,
status: 'Awaiting DB',
serialNumber,
};
},
onError: (err: any) => {
const msg = err?.error?.message || err?.error?.title || 'Failed to load organizations awaiting database assignment.';
this.notification.error(msg);
}
});
}
onActionClick(event: DataTableActionEvent<AwaitingDbOrganizationTableRow>): void {
if (event.action.type === 'assign') {
const org: AwaitingDbOrganizationDto = {
id: event.row.id,
code: event.row.code,
name: event.row.name,
organizationName: event.row.organizationName,
countryName: event.row.country,
status: event.row.status
};
this.selectedTenant.set(org);
this.modalOpen.set(true);
}
}
onModalClosed(): void {
this.modalOpen.set(false);
this.selectedTenant.set(null);
}
onOrganizationActivated(): void {
this.tableStore.refresh();
}
}
@@ -1,4 +1,4 @@
import { buildApiUrl } from '../../../../core/config/api-url.util';
import { buildApiUrl } from '../../../../../core/config/api-url.util';
export const ORGANIZATION_ENDPOINTS = {
dataTable: buildApiUrl(
@@ -22,4 +22,3 @@ export const ORGANIZATION_ENDPOINTS = {
'/v1/organizations/dashboard'
),
} as const;
@@ -3,7 +3,7 @@ import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { ORGANIZATION_ENDPOINTS } from './organization.endpoints';
import { DataTableQuery, DataTableResult } from '../../../../shared/components/data-table/data-table.types';
import { DataTableQuery, DataTableResult } from '../../../../../shared/components/data-table/data-table.types';
import {
OrganizationDto,
OrganizationLookupDto,
@@ -1,4 +1,4 @@
import { DataTableRecord } from '../../../../shared/components/data-table/data-table.types';
import { DataTableRecord } from '../../../../../shared/components/data-table/data-table.types';
export enum OrganizationStatus { Trial = 0, Active = 1, Suspended = 2, Cancelled = 3 }
@@ -14,6 +14,8 @@ export interface OrganizationDto {
planId?: string | null;
planName?: string | null;
plan?: string | null;
subscriptionStatus?: number | string | null;
planExpiresOn?: string | null;
expiryDate?: string | null;
expiry?: string | null;
defaultLanguageId?: string | null;
@@ -47,6 +49,7 @@ export interface OrganizationTableRow extends DataTableRecord {
readonly plan: string;
readonly planName: string | null;
readonly status: string | OrganizationStatus;
readonly subscriptionStatus: string;
readonly expiry: string;
readonly expiryDate: string | null;
readonly dataRegion: string | null;
@@ -100,5 +103,3 @@ export interface RecentOrganizationsDashboardDto {
counts: OrganizationStatusCountsDto;
organizations: RecentOrganizationDto[];
}

Some files were not shown because too many files have changed in this diff Show More