new screens added for Plan, Db connections, and code refactor
This commit is contained in:
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -26,4 +26,4 @@
|
||||
[maxDate]="maxDate()!"
|
||||
[disabled]="disabled"
|
||||
[readonly]="readonly()"
|
||||
/>
|
||||
/>
|
||||
+1
-1
@@ -117,4 +117,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isEndpointRequest(requestUrl: string, endpointUrl: string): boolean {
|
||||
return requestUrl === endpointUrl || requestUrl.startsWith(`${endpointUrl}?`);
|
||||
}
|
||||
@@ -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' },
|
||||
}
|
||||
];
|
||||
+107
@@ -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>
|
||||
+229
@@ -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;
|
||||
}
|
||||
+41
@@ -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()"
|
||||
/>
|
||||
+369
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
};
|
||||
+7
-7
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-7
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-7
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+144
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
+326
@@ -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';
|
||||
+119
@@ -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()" />
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
+272
@@ -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' },
|
||||
}
|
||||
];
|
||||
|
||||
+7
-8
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+190
@@ -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';
|
||||
+7
-7
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-7
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -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 {
|
||||
|
||||
+9
-1
@@ -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 {
|
||||
|
||||
+24
-5
@@ -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;
|
||||
|
||||
|
||||
+14
-6
@@ -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,
|
||||
};
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+5
-5
@@ -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[]>([]);
|
||||
})
|
||||
);
|
||||
|
||||
+6
-6
@@ -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)
|
||||
|
||||
+4
-4
@@ -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' },
|
||||
}
|
||||
];
|
||||
];
|
||||
|
||||
+100
@@ -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>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.activation-result-container {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
+46
@@ -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();
|
||||
}
|
||||
}
|
||||
+40
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
+185
@@ -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();
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
+49
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
+29
@@ -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>
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
+128
@@ -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
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
+4
-3
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -69,4 +69,3 @@
|
||||
</form>
|
||||
</ng-template>
|
||||
</app-data-table>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user