Initial commit

This commit is contained in:
Gagan7900
2026-07-13 11:57:09 +05:30
commit ac629ef540
26616 changed files with 795848 additions and 0 deletions
@@ -0,0 +1,10 @@
<apx-chart #chart [id]="id()" [ngClass]="apxClass()" [class]="class()" [title]="chartOptions()?.title ?? { text: '' }"
[subtitle]="chartOptions()?.subtitle ?? {}" [series]="chartOptions()?.series ?? []"
[colors]="chartOptions()?.colors ?? []" [dataLabels]="chartOptions()?.dataLabels ?? {}"
[chart]="chartOptions()?.chart ?? {type:'line'}" [xaxis]="chartOptions()?.xaxis ?? {}"
[tooltip]="chartOptions()?.tooltip ?? {}" [fill]="chartOptions()?.fill ?? {}" [legend]="chartOptions()?.legend ?? {}"
[stroke]="chartOptions()?.stroke ?? {}" [plotOptions]="chartOptions()?.plotOptions ?? {}"
[yaxis]="chartOptions()?.yaxis ?? []" [responsive]="chartOptions()?.responsive ?? []"
[labels]="chartOptions()?.labels ?? [] " [grid]="chartOptions()?.grid ?? {}" [markers]="chartOptions()?.markers ?? {}"
[annotations]="chartOptions()?.annotations ?? {}" [states]="chartOptions()?.states ?? {}"
[theme]="chartOptions()?.theme ??{}" />
@@ -0,0 +1,18 @@
import { NgClass } from '@angular/common';
import { Component, input, ViewChild } from '@angular/core';
import { ApexOptions, ChartComponent, NgApexchartsModule } from 'ng-apexcharts';
@Component({
selector: 'spk-apexcharts',
imports: [NgApexchartsModule, NgClass],
templateUrl: './spk-apexcharts.html',
styleUrl: './spk-apexcharts.scss'
})
export class SpkApexcharts {
@ViewChild("chart") chart!: ChartComponent;
apxClass = input<string>('')
class = input<string>('')
id = input<string>()
chartOptions = input<ApexOptions>();
}
@@ -0,0 +1,3 @@
<canvas baseChart [data]="chartjs()?.data" [options]="chartjs()?.options" [type]="chartjs()?.type ?? 'line'"
class="chartjs-chart" [height]="height()">
</canvas>
@@ -0,0 +1,16 @@
import { Component, input } from '@angular/core';
import { BaseChartDirective } from 'ng2-charts';
import { ChartConfiguration } from 'chart.js';
@Component({
selector: 'spk-chartjs',
imports: [BaseChartDirective],
templateUrl: './spk-chartjs.html',
styleUrl: './spk-chartjs.scss'
})
export class SpkChartjs {
canvasClass = input<string>();
chartjs = input<ChartConfiguration>();
height = input<number>();
id = input<string>()
}
@@ -0,0 +1 @@
<ngx-particles [id]="id()" [options]="options()" />
@@ -0,0 +1,22 @@
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)
});
}
}
@@ -0,0 +1,40 @@
<table class="{{tableClass()}}">
@if(headercaption()){
<caption [class]="captionheaderclass()">{{captionheaderContent()}}</caption>
}
<thead class="{{tableHead()}}">
<tr [class]="trHeadClass()">
@if(showCheckbox()){
<th class="{{checkboxClass()}}">
<input class="form-check-input" type="checkbox" [checked]="allTasksChecked" (change)="onToggleSelectAll($event)"
aria-label="Select all" />
@if(CheckboxText()){
{{CheckboxText()}}
}
</th>
}
@for(column of columns(); track $index){
<th [class]="column.tableHeadColumn">{{ column.header }}</th>
}
</tr>
</thead>
<tbody class="{{tableBody()}}">
<ng-content />
</tbody>
@if(showFooter()){
<tfoot class="{{tableFooter()}}">
@if(footerData()){
@for(column of footerData(); track $index){
<th>{{ column }}</th>
}
}
<ng-content select="[footer]" />
</tfoot>
}
@if(footercaption()){
<caption [class]="captionfooterclass()">{{captionfooterContent()}}</caption>
}
</table>
@@ -0,0 +1,65 @@
import { Component, ContentChild, ElementRef, EventEmitter, input, Input, output, Output } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
interface column {
tableHeadColumn?: string;
header: string;
}
@Component({
selector: 'spk-reusable-tables',
standalone: true,
imports: [FormsModule, ReactiveFormsModule],
templateUrl: './spk-reusable-tables.html',
styleUrl: './spk-reusable-tables.scss'
})
export class SpkReusableTables {
columns = input<column[]>([])
tableClass = input<string>('');
tableHead = input<string>('');
trHeadClass = input<string>('');
tableFooter = input<string>('');
tableBody = input<string>('');
trClass = input<string>('');
checkboxClass = input<string>('');
CheckboxText = input<string>('');
tableFoot = input<string>('');
tableHeadColumn = input<string>('');
data = input<any[]>([]);
title = input<any[]>([]);
footerData = input<any[]>([]);
showFooter = input<boolean>(false);
showCheckbox = input<boolean>(false);
headercaption = input<boolean>(false);
footercaption = input<boolean>(false);
captionheaderclass = input<string>();
captionfooterclass = input<string>();
captionheaderContent = input<string>();
captionfooterContent = input<string>();
rows = input<{ checked: boolean;[key: string]: any }[]>([]);
allTasksChecked!: boolean;
// Converted output signals
toggleSelectAll = output<boolean>();
openDetails = output<any>();
// Toggle select/deselect all checkboxes
onToggleSelectAll(event: any) {
this.toggleSelectAll.emit(event.target.checked);
}
toggleRowChecked(row: any) {
row.checked = !row.checked;
this.allTasksChecked = this.data().every(row => row.checked);
}
// Update the "Select All" checkbox based on row selections
updateSelectAllCheckbox(): void {
this.allTasksChecked = this.data().every(row => row.checked); // Check if all rows are selected
}
}
+40
View File
@@ -0,0 +1,40 @@
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { RouterOutlet, provideRouter, withRouterConfig } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { App_Route } from './app.routes';
import { AngularFireModule } from '@angular/fire/compat';
import { environment } from '../environments/environment';
import { AngularFireAuthModule } from '@angular/fire/compat/auth';
import { AngularFireDatabaseModule } from '@angular/fire/compat/database';
import { AngularFirestoreModule } from '@angular/fire/compat/firestore';
import { ToastrModule } from 'ngx-toastr';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';
import { loadingInterceptor } from './core/interceptors/loading-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(App_Route, withRouterConfig({ onSameUrlNavigation: 'reload' })),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor,loadingInterceptor])),
RouterOutlet,
provideAnimations(),
AngularFireAuthModule,
AngularFirestoreModule,
AngularFireDatabaseModule,
AngularFireModule,
provideCharts(withDefaultRegisterables()),
importProvidersFrom(
AngularFireModule.initializeApp(environment.firebase),
ToastrModule.forRoot({
timeOut: 1500,
closeButton: true,
progressBar: true,
}),
),
],
};
+3
View File
@@ -0,0 +1,3 @@
<router-outlet />
+23
View File
@@ -0,0 +1,23 @@
import { Route } from '@angular/router';
import { ContentLayout } from './shell/layouts/content-layout/content-layout';
import { AuthenticationLayout } from './shell/layouts/authentication-layout/authentication-layout';
import { authGuard } from './core/guards/auth/auth.guard';
export const App_Route: Route[] = [
{ path: '', redirectTo: 'auth/login', pathMatch: 'full' },
{
path: 'auth',
component: AuthenticationLayout,
loadChildren: () => import('./shell/routes/auth.routes').then((m) => m.authen),
},
{
path: '',
component: ContentLayout,
canActivateChild: [authGuard],
loadChildren: () => import('./shell/routes/content.routes').then((m) => m.content),
},
{
path: '**',
loadComponent: () => import('./features/errors/error404/error404').then((m) => m.Error404),
},
];
View File
+26
View File
@@ -0,0 +1,26 @@
import { Component, signal, inject } from '@angular/core';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { AppStateService } from './core/services/common/app-state.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
private router = inject(Router);
private appState=inject(AppStateService)
constructor() {
this.appState.updateState();
}
protected readonly title = signal('Ynex-Tailwind');
ngOnInit() {
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
setTimeout(() => window.HSStaticMethods.autoInit(), 100);
}
});
}
}
+85
View File
@@ -0,0 +1,85 @@
<div
class="grid grid-cols-12 h-screen min-h-screen overflow-hidden bg-bodybg dark:bg-bodybg text-defaulttextcolor text-defaultsize">
<div class="hidden xl:block xl:col-span-6 h-screen"> <img src="./assets/images/authentication/auth-cover.png"
class="w-full h-full object-cover object-left" alt="login cover" /> </div>
<div class="xl:col-span-6 col-span-12 min-h-[100dvh] flex items-center justify-center px-6 bg-bodybg dark:bg-bodybg">
<div class="authentication authentication-basic w-full max-w-[420px]">
<div class="my-[2.5rem] flex justify-center">
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-logo">
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-dark">
</div>
<div class="box !mb-0">
<div class="box-body !p-[3rem]">
<p class="h5 font-semibold mb-2 text-center">Sign In</p>
<p class="mb-4 text-[#8c9097] dark:text-white/50 opacity-[0.7] font-normal text-center">
Welcome back !
</p>
<form [formGroup]="adminLoginForm" (ngSubmit)="login()">
@if (loginError) {
<div class="text-danger mb-3">{{ loginError }}</div>
}
<div class="grid grid-cols-12 gap-y-4">
<div class="xl:col-span-12 col-span-12">
<label for="signin-username" class="form-label text-default">User Name</label>
<input type="text" class="form-control form-control-lg w-full !rounded-md"
id="signin-username" placeholder="user name" formControlName="username"
autocomplete="username">
</div>
<div class="xl:col-span-12 col-span-12 mb-2">
<label for="signin-password" class="form-label text-default block">Password</label>
<a routerLink="/authentication/reset-password/basic"
class="ltr:float-right rtl:float-left text-danger">
Forgot password ?
</a>
<div class="input-group">
<input class="form-control form-control-lg !rounded-s-md" id="signin-password"
placeholder="password" formControlName="password"
autocomplete="current-password"
[type]="visibilityMap['Angular'] ? 'text' : 'password'">
<button aria-label="button" class="ti-btn ti-btn-light !rounded-s-none !mb-0"
type="button" (click)="toggleVisibility('Angular')" id="button-addon2">
<i class="{{ iconMap['Angular'] }} align-middle"></i>
</button>
</div>
<div class="mt-5">
<div class="form-check !ps-0">
<input class="form-check-input" type="checkbox" id="defaultCheck1"
formControlName="rememberMe">
<label class="form-check-label text-[#8c9097] dark:text-white/50 font-normal"
for="defaultCheck1">
Remember password ?
</label>
</div>
</div>
</div>
<div class="xl:col-span-12 col-span-12 grid mt-2">
<button type="submit" [disabled]="adminLoginForm.invalid || isSubmitting"
class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium inline-flex items-center justify-center gap-2">
<span [style.display]="isSubmitting ? '' : 'none'" class="ti-spinner text-white"
role="status" aria-label="loading"></span>
<span [style.display]="isSubmitting ? '' : 'none'">Signing In...</span>
<span [style.display]="isSubmitting ? 'none' : ''">Sign In</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
::ng-deep{
.firebase {
width: 30px;
height: 30px;
}
.rounded-lg{
width: 120px;
justify-content: center;
margin: auto;
border-radius: 0.75rem;
}
}
+122
View File
@@ -0,0 +1,122 @@
import { ChangeDetectorRef, Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthService } from '../../core/services/auth/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';
@Component({
selector: 'app-login',
standalone: true,
imports: [RouterModule, ReactiveFormsModule],
templateUrl: './login.html',
styleUrl: './login.scss'
})
export class Login {
private readonly formBuilder = inject(FormBuilder);
private readonly cdr = inject(ChangeDetectorRef);
private readonly fallbackRoute = '/dashboards/crm';
public readonly adminLoginForm = this.formBuilder.nonNullable.group({
username: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
rememberMe: [false]
});
public isSubmitting = false;
public loginError = '';
public visibilityMap: Record<string, boolean> = {
Angular: false
};
public iconMap: Record<string, string> = {
Angular: 'fe fe-eye-off'
};
constructor(
public authservice: AuthService,
private appContextService: AppContextService,
private route: ActivatedRoute,
private router: Router,
private toastr: ToastrService
) { }
login() {
this.loginError = '';
if (this.adminLoginForm.invalid) {
this.adminLoginForm.markAllAsTouched();
return;
}
if (this.isSubmitting) {
return;
}
this.isSubmitting = true;
const { username, password, rememberMe } = this.adminLoginForm.getRawValue();
this.authservice.login({ email: username, password }, !!rememberMe)
.pipe(
switchMap(() =>
this.appContextService.ensureMenuInitialized(true).pipe(
catchError(() => {
this.authservice.logout();
this.loginError = 'Unable to load application context.';
return of(null);
})
)
),
finalize(() => {
this.isSubmitting = false;
this.cdr.detectChanges();
})
)
.subscribe({
next: () => {
if (this.loginError) {
this.toastr.error(this.loginError);
return;
}
void this.router.navigateByUrl(this.getSafeReturnUrl());
this.toastr.success('Login successful', username);
},
error: (error) => {
this.loginError =
error?.error?.detail ||
error?.error?.message ||
'Invalid email or password';
this.toastr.error(this.loginError);
}
});
}
toggleVisibility(tab: string): void {
this.visibilityMap[tab] = !this.visibilityMap[tab];
this.iconMap[tab] = this.visibilityMap[tab] ? 'fe fe-eye' : 'fe fe-eye-off';
}
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;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { environment } from '../../../environments/environment';
export const API_HOSTS = {
identity: environment.api.identity,
masterAdmin: environment.api.masterAdmin,
} as const;
export type ApiHost = keyof typeof API_HOSTS;
+13
View File
@@ -0,0 +1,13 @@
import { API_HOSTS, ApiHost } from './api-host';
export function buildApiUrl(
host: ApiHost,
endpoint: string
): string {
const baseUrl = API_HOSTS[host].replace(/\/+$/, '');
const normalizedEndpoint = endpoint.startsWith('/')
? endpoint
: `/${endpoint}`;
return `${baseUrl}${normalizedEndpoint}`;
}
+6
View File
@@ -0,0 +1,6 @@
export const API_CONFIG = {
baseUrl: '/api',
endpoints: {
auth: '/auth'
}
};
@@ -0,0 +1,8 @@
import { buildApiUrl } from '../../config/api-url.util';
export const AUTH_ENDPOINTS = {
login: buildApiUrl('identity', '/v1/auth/login'),
refresh: buildApiUrl('identity', '/v1/auth/refresh'),
logout: buildApiUrl('identity', '/v1/auth/logout'),
currentUser: buildApiUrl('identity', '/v1/auth/me'),
} as const;
@@ -0,0 +1,38 @@
import { buildApiUrl } from '../../config/api-url.util';
export const COUNTRY_ENDPOINTS = {
dataTable: buildApiUrl(
'masterAdmin',
'/v1/countries/datatable'
),
create: buildApiUrl(
'masterAdmin',
'/v1/countries'
),
getById: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/countries/${encodeURIComponent(id)}`
),
update: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/countries/${encodeURIComponent(id)}`
),
delete: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/countries/${encodeURIComponent(id)}`
),
changeStatus: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/countries/${encodeURIComponent(id)}/status`
),
} as const;
@@ -0,0 +1,38 @@
import { buildApiUrl } from '../../config/api-url.util';
export const STATE_ENDPOINTS = {
dataTable: buildApiUrl(
'masterAdmin',
'/v1/states/datatable'
),
create: buildApiUrl(
'masterAdmin',
'/v1/states'
),
getById: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/states/${encodeURIComponent(id)}`
),
update: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/states/${encodeURIComponent(id)}`
),
delete: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/states/${encodeURIComponent(id)}`
),
changeStatus: (id: string) =>
buildApiUrl(
'masterAdmin',
`/v1/states/${encodeURIComponent(id)}/status`
),
} as const;
+37
View File
@@ -0,0 +1,37 @@
import { inject } from '@angular/core';
import { CanActivateChildFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../../services/auth/auth.service';
import { TokenStorageService } from '../../services/auth/token-storage.service';
export const authGuard: CanActivateChildFn = (_childRoute, state) => {
const authService = inject(AuthService);
const tokenStorage = inject(TokenStorageService);
const router = inject(Router);
const loginUrlTree = router.createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url },
});
if (!authService.accessToken || !authService.currentUser) {
authService.logout();
return loginUrlTree;
}
if (!tokenStorage.isAccessTokenExpired()) {
return true;
}
if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
authService.logout();
return loginUrlTree;
}
return authService.refreshAccessToken().pipe(
map(() => true),
catchError(() => {
authService.logout();
return of(loginUrlTree);
})
);
};
+53
View File
@@ -0,0 +1,53 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../../services/auth/auth.service';
import { TokenStorageService } from '../../services/auth/token-storage.service';
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 targetUrlTree = router.createUrlTree([targetUrl]);
if (!authService.accessToken || !authService.currentUser) {
return true;
}
if (!tokenStorage.isAccessTokenExpired()) {
return targetUrlTree;
}
if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
authService.logout();
return true;
}
return authService.refreshAccessToken().pipe(
map(() => targetUrlTree),
catchError(() => {
authService.logout();
return of(true);
})
);
};
@@ -0,0 +1,62 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth/auth.service';
import { API_CONFIG } from '../config/api.config';
const RETRY_HEADER = 'X-Auth-Retry';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const router = inject(Router);
const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`;
const isAuthRequest = req.url.includes(authBaseUrl);
const isRefreshRequest = req.url.includes(`${authBaseUrl}/refresh`);
if (isAuthRequest) {
return next(req);
}
const token = authService.accessToken;
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status !== 401 || !authService.accessToken || isRefreshRequest || req.headers.has(RETRY_HEADER)) {
return throwError(() => error);
}
return authService.refreshAccessToken().pipe(
switchMap(() => {
const refreshedToken = authService.accessToken;
if (!refreshedToken) {
return throwError(() => error);
}
const retriedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${refreshedToken}`,
[RETRY_HEADER]: 'true',
},
});
return next(retriedRequest);
}),
catchError((refreshError) => {
authService.logout();
const currentUrl = router.url;
const safeReturnUrl = currentUrl.startsWith('/') ? currentUrl : '/';
void router.navigate(['/auth/login'], {
queryParams: { returnUrl: safeReturnUrl },
});
return throwError(() => refreshError);
})
);
})
);
};
@@ -0,0 +1,23 @@
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';
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`);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (!isLoginRequest && !isRefreshRequest && error.status !== 401) {
const message = error.error?.detail ?? error.error?.message ?? error.message ?? 'Request failed';
toastr.error(message, 'Request failed');
}
return throwError(() => error);
})
);
};
@@ -0,0 +1,25 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs';
import { LoadingService } from '../services/common/loading.service';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loadingService = inject(LoadingService);
const skipLoaderHeader = req.headers.get('X-Skip-Loader');
const shouldSkipLoader = skipLoaderHeader?.toLowerCase() === 'true';
const request = req.headers.has('X-Skip-Loader')
? req.clone({ headers: req.headers.delete('X-Skip-Loader') })
: req;
if (!shouldSkipLoader) {
loadingService.show();
}
return next(request).pipe(
finalize(() => {
if (!shouldSkipLoader) {
loadingService.hide();
}
})
);
};
@@ -0,0 +1,12 @@
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
}
export interface ProblemDetails {
title?: string;
status?: number;
detail?: string;
errors?: Record<string, string[]>;
}
+24
View File
@@ -0,0 +1,24 @@
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken?: string;
accessTokenExpiresOn?: string;
refreshTokenExpiresOn?: string;
expiresIn?: number;
user?: UserProfile;
userId?: string;
email?: string;
roles?: string[];
}
export interface UserProfile {
id: string;
email: string;
displayName?: string;
roles?: string[];
tenantId?: string;
}
@@ -0,0 +1,33 @@
import { UserProfile } from '../auth/auth.model';
import { Menu } from '../../services/common/nav.service';
export interface CurrentUserContext extends UserProfile {
fullName?: string;
defaultLandingPage?: string;
}
export interface TenantContext {
tenantId?: string;
companyId?: string;
companyName?: string;
tenantName?: string;
defaultLandingPage?: string;
}
export interface PermissionContext {
roles: string[];
permissions: string[];
defaultLandingPage?: string;
}
export interface MenuContext {
items: Menu[];
defaultLandingPage?: string;
}
export interface AppContextState {
user: CurrentUserContext;
tenant: TenantContext;
permissions: PermissionContext;
menu: MenuContext;
}
@@ -0,0 +1,11 @@
export interface CountryDto {
id: string;
iso2: string;
iso3: string;
name: string;
phoneCode: string | null;
defaultCurrencyId: string | null;
isActive?: boolean;
}
export type CountryModalMode = 'create' | 'edit';
+133
View File
@@ -0,0 +1,133 @@
import { computed, Injectable, inject, signal } from '@angular/core';
import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { LoginRequest, LoginResponse, UserProfile } from '../../models/auth/auth.model';
import { TokenStorageService } from './token-storage.service';
import { AppContextService } from '../../services/context/app-context.service';
import { AUTH_ENDPOINTS } from '../../../core/end-points/auth/auth.endpoints';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly tokenStorage = inject(TokenStorageService);
private readonly appContextService = inject(AppContextService);
private readonly userSubject = new BehaviorSubject<UserProfile | null>(null);
readonly user$ = this.userSubject.asObservable();
readonly currentUserSignal = signal<UserProfile | null>(null);
private readonly accessTokenSignal = signal<string | null>(null);
readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal());
private refreshRequest$: Observable<LoginResponse> | null = null;
constructor() {
this.restoreAuthState();
}
login(payload: LoginRequest, rememberMe: boolean): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${AUTH_ENDPOINTS.login}`, payload).pipe(
map((response) => {
const user = this.normalizeUserProfile(response);
this.tokenStorage.saveAuth(response, rememberMe);
this.setAuthState(user, this.tokenStorage.getAccessToken());
return response;
})
);
}
refreshAccessToken(): Observable<LoginResponse> {
if (this.refreshRequest$) {
return this.refreshRequest$;
}
const refreshToken = this.tokenStorage.getRefreshToken();
if (!refreshToken) {
this.logout();
return throwError(() => new HttpErrorResponse({ status: 401, statusText: 'Refresh token missing' }));
}
this.refreshRequest$ = this.http.post<LoginResponse>(`${AUTH_ENDPOINTS.refresh}`, { refreshToken }).pipe(
map((response) => {
const user = this.normalizeUserProfile(response, this.currentUserSignal());
const storageType = this.tokenStorage.getStorageType();
const rememberMe = storageType === 'local';
this.tokenStorage.saveAuth(response, rememberMe);
this.setAuthState(user, this.tokenStorage.getAccessToken());
return response;
}),
catchError((error) => {
this.logout();
return throwError(() => error);
}),
finalize(() => {
this.refreshRequest$ = null;
}),
shareReplay(1)
);
return this.refreshRequest$;
}
logout(): void {
this.tokenStorage.clearAuth();
this.appContextService.clearContext();
this.setAuthState(null, null);
this.refreshRequest$ = null;
}
get accessToken(): string | null {
return this.tokenStorage.getAccessToken();
}
get refreshToken(): string | null {
return this.tokenStorage.getRefreshToken();
}
get isAuthenticated(): boolean {
return this.isAuthenticatedSignal();
}
get isLoggedIn(): boolean {
return this.isAuthenticatedSignal();
}
get currentUser(): UserProfile | null {
return this.currentUserSignal();
}
private restoreAuthState(): void {
const storedUser = this.tokenStorage.getUser();
const storedAccessToken = this.tokenStorage.getAccessToken();
if (storedUser && storedAccessToken) {
this.setAuthState(storedUser, storedAccessToken);
return;
}
this.setAuthState(null, storedAccessToken ?? null);
}
private setAuthState(user: UserProfile | null, token: string | null): void {
this.userSubject.next(user);
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;
}
}
@@ -0,0 +1,154 @@
import { DOCUMENT } from '@angular/common';
import { DestroyRef, effect, inject, Injectable, OnDestroy, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { merge, fromEvent, Subscription } from 'rxjs';
import { throttleTime } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
import { AuthService } from '../auth/auth.service';
@Injectable()
export class SessionTimeoutService implements OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly router = inject(Router);
private readonly authService = inject(AuthService);
private readonly destroyRef = inject(DestroyRef);
readonly showWarning = signal(false);
readonly remainingSeconds = signal(0);
private readonly warningAfterMs = environment.sessionTimeout?.warningAfterMs ?? 25 * 60 * 1000;
private readonly logoutAfterMs = environment.sessionTimeout?.logoutAfterMs ?? 30 * 60 * 1000;
private activitySubscription: Subscription | null = null;
private warningTimer: ReturnType<typeof setTimeout> | null = null;
private logoutTimer: ReturnType<typeof setTimeout> | null = null;
private countdownTimer: ReturnType<typeof setInterval> | null = null;
private logoutDeadline = 0;
private trackingEnabled = false;
constructor() {
effect(() => {
if (this.authService.currentUserSignal()) {
this.start();
return;
}
this.stop();
});
}
start(): void {
if (this.trackingEnabled) {
this.resetTimers();
return;
}
this.trackingEnabled = true;
this.bindActivityTracking();
this.resetTimers();
}
stop(): void {
this.trackingEnabled = false;
this.showWarning.set(false);
this.remainingSeconds.set(0);
this.clearTimers();
this.activitySubscription?.unsubscribe();
this.activitySubscription = null;
}
staySignedIn(): void {
if (!this.trackingEnabled) {
return;
}
this.resetTimers();
}
logoutNow(): void {
this.handleTimeoutLogout();
}
ngOnDestroy(): void {
this.stop();
}
private bindActivityTracking(): void {
if (this.activitySubscription) {
return;
}
this.activitySubscription = merge(
fromEvent(this.document, 'mousemove'),
fromEvent(this.document, 'keydown'),
fromEvent(this.document, 'click'),
fromEvent(window, 'scroll')
)
.pipe(throttleTime(1000), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
if (!this.trackingEnabled || !this.authService.currentUserSignal()) {
return;
}
this.resetTimers();
});
}
private resetTimers(): void {
if (!this.trackingEnabled) {
return;
}
this.clearTimers();
this.showWarning.set(false);
this.remainingSeconds.set(0);
const safeWarningDelay = Math.max(Math.min(this.warningAfterMs, this.logoutAfterMs), 0);
const safeLogoutDelay = Math.max(this.logoutAfterMs, 0);
this.warningTimer = setTimeout(() => {
this.showWarning.set(true);
this.logoutDeadline = Date.now() + Math.max(safeLogoutDelay - safeWarningDelay, 0);
this.updateRemainingSeconds();
this.countdownTimer = setInterval(() => {
this.updateRemainingSeconds();
}, 1000);
}, safeWarningDelay);
this.logoutTimer = setTimeout(() => {
this.handleTimeoutLogout();
}, safeLogoutDelay);
}
private updateRemainingSeconds(): void {
const remainingMs = Math.max(this.logoutDeadline - Date.now(), 0);
this.remainingSeconds.set(Math.ceil(remainingMs / 1000));
}
private handleTimeoutLogout(): void {
const returnUrl = this.router.url.startsWith('/auth') ? '/' : this.router.url;
this.stop();
this.authService.logout();
void this.router.navigate(['/auth/login'], {
queryParams: returnUrl && returnUrl !== '/' ? { returnUrl } : undefined,
});
}
private clearTimers(): void {
if (this.warningTimer) {
clearTimeout(this.warningTimer);
this.warningTimer = null;
}
if (this.logoutTimer) {
clearTimeout(this.logoutTimer);
this.logoutTimer = null;
}
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
}
}
@@ -0,0 +1,152 @@
import { Injectable } from '@angular/core';
import { LoginResponse, UserProfile } from '../../models/auth/auth.model';
type StorageType = 'local' | 'session';
@Injectable({ providedIn: 'root' })
export class TokenStorageService {
private readonly accessTokenKey = 'master-admin-access-token';
private readonly refreshTokenKey = 'master-admin-refresh-token';
private readonly accessTokenExpiresOnKey = 'master-admin-access-token-expires-on';
private readonly refreshTokenExpiresOnKey = 'master-admin-refresh-token-expires-on';
private readonly userKey = 'master-admin-user';
saveAuth(response: LoginResponse, rememberMe: boolean): void {
this.clearAuth();
const selectedStorage = rememberMe ? localStorage : sessionStorage;
if (response.accessToken) {
selectedStorage.setItem(this.accessTokenKey, response.accessToken);
}
if (response.refreshToken) {
selectedStorage.setItem(this.refreshTokenKey, response.refreshToken);
}
if (response.accessTokenExpiresOn) {
selectedStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn);
}
if (response.refreshTokenExpiresOn) {
selectedStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn);
}
const user = this.buildUserProfile(response);
if (user) {
selectedStorage.setItem(this.userKey, JSON.stringify(user));
}
}
clearAuth(): void {
this.removeFromStorage(localStorage);
this.removeFromStorage(sessionStorage);
}
getAccessToken(): string | null {
return this.getByPriority(this.accessTokenKey);
}
getRefreshToken(): string | null {
return this.getByPriority(this.refreshTokenKey);
}
getAccessTokenExpiresOn(): string | null {
return this.getByPriority(this.accessTokenExpiresOnKey);
}
getRefreshTokenExpiresOn(): string | null {
return this.getByPriority(this.refreshTokenExpiresOnKey);
}
getUser(): UserProfile | null {
const raw = this.getByPriority(this.userKey);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as UserProfile;
} catch {
this.clearAuth();
return null;
}
}
getStorageType(): 'local' | 'session' | null {
if (this.hasAnyAuthKey(sessionStorage)) {
return 'session';
}
if (this.hasAnyAuthKey(localStorage)) {
return 'local';
}
return null;
}
isAccessTokenExpired(): boolean {
return this.isExpired(this.getAccessTokenExpiresOn());
}
isRefreshTokenExpired(): boolean {
return this.isExpired(this.getRefreshTokenExpiresOn());
}
private getByPriority(key: string): string | null {
const fromSession = sessionStorage.getItem(key);
if (fromSession !== null) {
return fromSession;
}
return localStorage.getItem(key);
}
private hasAnyAuthKey(storage: Storage): boolean {
return [
this.accessTokenKey,
this.refreshTokenKey,
this.accessTokenExpiresOnKey,
this.refreshTokenExpiresOnKey,
this.userKey,
].some((key) => storage.getItem(key) !== null);
}
private removeFromStorage(storage: Storage): void {
storage.removeItem(this.accessTokenKey);
storage.removeItem(this.refreshTokenKey);
storage.removeItem(this.accessTokenExpiresOnKey);
storage.removeItem(this.refreshTokenExpiresOnKey);
storage.removeItem(this.userKey);
}
private isExpired(expiresOn: string | null): boolean {
debugger;
if (!expiresOn) {
return true;
}
const parsedExpiry = Date.parse(expiresOn);
if (Number.isNaN(parsedExpiry)) {
return true;
}
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;
}
}
@@ -0,0 +1,321 @@
import { DOCUMENT, ElementRef, inject, Injectable, Renderer2 } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
interface StateType {
direction: string;
theme: string;
navigationStyles: string, // vertical, horizontal
menuStyles: string, // menu-click, menu-hover, icon-click, icon-hover
layoutStyles: string, // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu
pageStyles: string, // regular, classic, modern
widthStyles: string, // fullwidth, boxed
menuPosition: string, // fixed, scrollable
headerPosition: string, // fixed, scrollable
menuColor: string, // light, dark, color, gradient, transparent
headerColor: string, // light, dark, color, gradient, transparent
themePrimary: string, // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90'
themeBackground: string,
backgroundImage: string,
};
@Injectable({
providedIn: 'root'
})
export class AppStateService {
private readonly localStorageKey = 'Ynex-ng'; // Customize this key
private initialState: StateType = {
theme: 'light', // light, dark
direction: 'ltr', // ltr, rtl
navigationStyles: 'vertical', // vertical, horizontal
menuStyles: '', // menu-click, menu-hover, icon-click, icon-hover
layoutStyles: 'default', // double-menu, detached, icon-overlay, icontext-menu, closed-menu, default-menu
pageStyles: 'regular', // regular, classic, modern
widthStyles: 'fullwidth', // fullwidth, boxed
menuPosition: 'fixed', // fixed, scrollable
headerPosition: 'fixed', // fixed, scrollable
menuColor: 'dark', // light, dark, color, gradient, transparent
headerColor: 'light', // light, dark, color, gradient, transparent
themePrimary: '', // '58, 88, 146', '92, 144, 163', '161, 90, 223', '78, 172, 76', '223, 90, 90'
themeBackground: '',
backgroundImage: '', // bgimg1, bgimg2, bgimg3, bgimg4, bgimg5
} // Store initial state
private stateSubject = new BehaviorSubject<StateType>(this.initialState); // Use any for initial null value
state$ = this.stateSubject.asObservable();
private document = inject(DOCUMENT);
navigationStyles: any;
private html = this.document.documentElement;
constructor() {
const initialState: StateType = this.getInitialStateFromLocalStorage();
// this.initializeState();
this.stateSubject.next(initialState);
}
private getInitialStateFromLocalStorage(): StateType {
try {
const storedState = localStorage.getItem(this.localStorageKey);
if (storedState) {
return JSON.parse(storedState);
}
} catch (error) {
console.error('Error retrieving initial state from local storage:', error);
}
return this.initialState;
}
getupdateState() {
const currentState = this.stateSubject.getValue();
return currentState
}
updateState(newState?: Partial<any>) { // Use any for partial updates
const currentState = this.stateSubject.getValue(); // Get current state
if (!currentState) {
// Handle initial update case (no state emitted yet)
this.updateStateAndEmit(newState);
return;
}
if (newState) {
const updatedState = { ...currentState, ...newState }; // Merge updates
this.updateStateAndEmit(updatedState); // Update and emit combined state
} else {
this.updateStateAndEmit(currentState);
return;
}
}
private state: { [key: string]: any } = {};
getState(menuStyles: string): any {
return this.state[menuStyles];
}
private applyThemeBackgroundSpecificChanges(background: any) {
this.html?.style.setProperty('--color-bodybg', background.main);
this.html?.style.setProperty('--color-bodybg2', background.secondary);
this.html?.style.setProperty('--color-light', background.accent);
this.html?.style.setProperty('--color-formcontrolbg', `rgba(${background.accent})`);
this.html?.style.setProperty('--color-inputborder', background.overlay);
this.html?.style.setProperty('--color-gray3', background.primary);
this.applythemeSpecificChanges(background.theme);
}
private applyDirectionSpecificChanges(direction: string) {
this.html?.setAttribute('dir', direction);
}
private applythemeSpecificChanges(theme: string) {
this.html?.setAttribute('class', theme); //setting theme style
this.html?.setAttribute('data-header-styles', theme); //setting header style
}
private applyNavigationStylesSpecificChanges(navigationStyles: string) {
this.html?.setAttribute('data-nav-layout', navigationStyles);
if (navigationStyles == 'horizontal') {
this.html?.setAttribute('data-nav-style', 'menu-click');
this.html?.removeAttribute('data-vertical-style');
}
}
private applyMenuStylesSpecificChanges(menuStyles: string) {
this.html?.setAttribute('data-nav-style', menuStyles);
this.html?.setAttribute('data-toggled', menuStyles + '-closed');
this.html?.removeAttribute('data-vertical-style');
}
private applyLayoutStylesSpecificChanges(layoutStyles: string) {
this.html?.setAttribute('data-vertical-style', layoutStyles);
this.html?.removeAttribute('data-nav-style');
switch (layoutStyles) {
case 'default':
this.html?.setAttribute('data-vertical-style', 'overlay');
this.html?.setAttribute('data-toggled', '');
break;
case 'closed':
this.html?.setAttribute('data-toggled', 'close-menu-close');
break;
case 'icontext':
this.html?.setAttribute('data-toggled', 'icon-text-close');
break;
case 'overlay':
this.html?.setAttribute('data-toggled', 'icon-overlay-close');
break;
case 'detached':
this.html?.setAttribute('data-toggled', 'detached-close');
break;
case 'doublemenu':
this.html?.setAttribute('data-toggled', 'double-menu-open');
break;
}
if (layoutStyles === 'icon-text') {
this.html?.setAttribute('icon-text', 'open');
} else {
// If not 'icon-text', remove the icon-text attribute
this.html?.removeAttribute('icon-text');
}
}
private applypageStylesSpecificChanges(pageStyles: string) {
this.html?.setAttribute('data-page-style', pageStyles);
const slideRight = document.querySelector('.slide-right') as HTMLElement | null;
if (slideRight) {
// If the element exists, toggle the 'd-none' class
if (slideRight.classList.contains('d-none')) {
slideRight.classList.remove('d-none');
} else {
slideRight.classList.add('d-none');
}
} else {
// If the element does not exist (is null), create a safe fallback by adding 'd-none'
const dummySlideRight = document.createElement('div');
dummySlideRight.classList.add('slide-right', 'd-none'); // Add classes to the new element
document.body.appendChild(dummySlideRight); // Append it to the DOM as a fallback
}
}
private applywidthStylesSpecificChanges(widthStyles: string) {
this.html?.setAttribute('data-width', widthStyles);
}
private applymenuPositionSpecificChanges(menuPosition: string) {
this.html?.setAttribute('data-menu-position', menuPosition);
}
private applyheaderPositionSpecificChanges(headerPosition: string) {
this.html?.setAttribute('data-header-position', headerPosition);
}
private applyheaderColorSpecificChanges(headerColor: string) {
this.html?.setAttribute('data-header-styles', headerColor);
}
private applymenuColorSpecificChanges(menuColor: string) {
this.html?.setAttribute('data-menu-styles', menuColor);
}
private applyPrimarySpecificChanges(primary: string) {
this.html?.style.setProperty('--color-primaryrgb', primary);
this.html?.style.setProperty('--color-primary', primary);
}
private applybackgroundImageSpecificChanges(backgroundImage: string) {
this.html?.setAttribute('bg-img', backgroundImage);
}
public applyReset() {
if (this.html) {
this.html?.style.removeProperty('--color-bodybg');
this.html?.style.removeProperty('--color-gray3');
this.html?.style.removeProperty('--color-bodybg2');
this.html?.style.removeProperty('--color-light');
this.html?.style.removeProperty('--color-formcontrolbg');
this.html?.style.removeProperty('--color-inputborder');
this.html?.style.removeProperty('--color-primary');
this.html?.style.removeProperty('--color-primaryrgb');
}
this.html?.removeAttribute('bg-img');
this.html?.setAttribute('data-vertical-style', 'overlay');
this.stateSubject.next(this.initialState);
this.updateStateAndEmit(this.initialState);
localStorage.clear();
if (window.innerWidth <= 992) {
this.html?.setAttribute('data-toggled', 'close');
}
}
private updateStateAndEmit(state: any) {
// Conditional logic based on direction changes
const currentState = this.stateSubject.getValue(); // Get current state
// Conditional logic based on theme changes
if (state['theme']) {
this.applythemeSpecificChanges(state['theme']);
}
if (state['direction']) {
this.applyDirectionSpecificChanges(state['direction']);
}
// Conditional logic based on theme changes
if (state['navigationStyles']) {
this.applyNavigationStylesSpecificChanges(state['navigationStyles']);
}
// Conditional logic based on theme changes
if (state['menuStyles'] && !state['layoutStyles']) {
this.applyMenuStylesSpecificChanges(state['menuStyles']);
}
if (state['layoutStyles'] && !state['menuStyles']) {
this.applyLayoutStylesSpecificChanges(state['layoutStyles']);
}
if (state['pageStyles']) {
this.applypageStylesSpecificChanges(state['pageStyles']);
}
if (state['widthStyles']) {
this.applywidthStylesSpecificChanges(state['widthStyles']);
}
if (state['menuPosition']) {
this.applymenuPositionSpecificChanges(state['menuPosition']);
}
if (state['headerPosition']) {
this.applyheaderPositionSpecificChanges(state['headerPosition']);
}
if (state['themePrimary']) {
this.applyPrimarySpecificChanges(state['themePrimary']);
}
if (state['themeBackground']) {
this.applyThemeBackgroundSpecificChanges(state['themeBackground']);
}
if (state['headerColor']) {
this.applyheaderColorSpecificChanges(state['headerColor']);
}
if (state['menuColor']) {
this.applymenuColorSpecificChanges(state['menuColor']);
}
if (state['backgroundImage']) {
this.applybackgroundImageSpecificChanges(state['backgroundImage']);
}
this.stateSubject.next(state);
this.updateLocalStorage(state);
}
private updateLocalStorage(state: any) {
try {
localStorage.setItem(this.localStorageKey, JSON.stringify(state));
} catch (error) {
console.error('Error saving state to local storage:', error);
}
}
}
@@ -0,0 +1,33 @@
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}`);
}
}
@@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { AngularFireModule } from '@angular/fire/compat';
import { AngularFirestoreModule } from '@angular/fire/compat/firestore';
import { AngularFireDatabaseModule } from '@angular/fire/compat/database';
import { AngularFireAuthModule } from '@angular/fire/compat/auth';
import { environment } from '../../../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class FirebaseService {
constructor() {
AngularFireModule.initializeApp(environment.firebase);
}
getFirestore() {
return AngularFirestoreModule;
}
getDatabase() {
return AngularFireDatabaseModule;
}
getAuth() {
return AngularFireAuthModule;
}
}
@@ -0,0 +1,24 @@
import { Injectable, signal} from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class LoadingService {
private requestCount = 0;
readonly isLoading = signal(false);
show(): void {
this.requestCount++;
this.isLoading.set(true);
}
hide(): void {
this.requestCount--;
if (this.requestCount <= 0) {
this.requestCount = 0;
this.isLoading.set(false);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import { MenuContext } from '../../models/context/context.model';
export const SAAS_MENU_DATA: MenuContext = {
defaultLandingPage: '/dashboards/crm',
items: [
{ headTitle: 'MAIN' },
{
title: 'Dashboards',
icon: '<i class="bx bx-home side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false },
],
},
{ headTitle: 'SAAS ADMIN' },
{
title: 'Management',
icon: '<i class="bx bx-buildings side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
{
title: 'Global Master',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ 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 },
],
},
{
title: 'Configuration',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/localization', title: 'Localization', type: 'link', dirchange: false },
{
title: 'Branding',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/theming', title: 'Theming', type: 'link', dirchange: false },
{ path: '/platform', title: 'Platform', type: 'link', dirchange: false },
],
},
],
},
],
},
{
title: 'Operations',
icon: '<i class="bx bx-line-chart side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/billing', title: 'Billing', type: 'link', dirchange: false },
{ path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false },
],
},
],
};
@@ -0,0 +1,42 @@
import { Injectable, 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';
@Injectable({ providedIn: 'root' })
export class MenuService {
readonly menuContext = signal<MenuContext | null>(null);
loadMenu(): Observable<MenuContext> {
const context = this.cloneMenuContext(SAAS_MENU_DATA);
return of(context).pipe(
tap((menuContext) => {
this.menuContext.set(menuContext);
})
);
}
getNavigationMenu(): Menu[] {
return this.cloneMenuItems(this.menuContext()?.items ?? []);
}
getDefaultLandingPage(): string | null {
return this.menuContext()?.defaultLandingPage ?? null;
}
clear(): void {
this.menuContext.set(null);
}
private cloneMenuContext(context: MenuContext): MenuContext {
return {
defaultLandingPage: context.defaultLandingPage,
items: this.cloneMenuItems(context.items),
};
}
private cloneMenuItems(items: Menu[]): Menu[] {
return JSON.parse(JSON.stringify(items)) as Menu[];
}
}
+102
View File
@@ -0,0 +1,102 @@
import { Injectable, OnDestroy } from '@angular/core';
import { Subject, BehaviorSubject, fromEvent } from 'rxjs';
import { takeUntil, debounceTime } from 'rxjs/operators';
import { Router } from '@angular/router';
// Menu
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
}
@Injectable({
providedIn: 'root',
})
export class NavService implements OnDestroy {
private unsubscriber: Subject<any> = new Subject();
public screenWidth: BehaviorSubject<number> = new BehaviorSubject(
window.innerWidth
);
// Search Box
public search = false;
// Language
public language = false;
// Mega Menu
public megaMenu = false;
public levelMenu = false;
public megaMenuColapse: boolean = window.innerWidth < 1199 ? true : false;
// Collapse Sidebar
public collapseSidebar: boolean = window.innerWidth < 991 ? true : false;
// For Horizontal Layout Mobile
public horizontal: boolean = window.innerWidth < 991 ? false : true;
// Full screen
public fullScreen = false;
active: any;
constructor(private router: Router) {
this.setScreenWidth(window.innerWidth);
fromEvent(window, 'resize')
.pipe(debounceTime(1000), takeUntil(this.unsubscriber))
.subscribe((evt: any) => {
this.setScreenWidth(evt.target.innerWidth);
if (evt.target.innerWidth < 991) {
this.collapseSidebar = true;
this.megaMenu = false;
this.levelMenu = false;
}
if (evt.target.innerWidth < 1199) {
this.megaMenuColapse = true;
}
});
if (window.innerWidth < 991) {
// Detect Route change sidebar close
this.router.events.subscribe((event) => {
this.collapseSidebar = true;
this.megaMenu = false;
this.levelMenu = false;
});
}
}
ngOnDestroy() {
this.unsubscriber.next;
this.unsubscriber.complete();
}
private setScreenWidth(width: number): void {
this.screenWidth.next(width);
}
items = new BehaviorSubject<Menu[]>([]);
setMenuItems(menuItems: Menu[]): void {
this.items.next(menuItems);
}
clearMenuItems(): void {
this.items.next([]);
}
}
@@ -0,0 +1,33 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { map, Observable, tap } from 'rxjs';
import { PermissionContext } from '../../models/context/context.model';
interface PermissionResponse {
roles?: string[];
permissions?: string[];
defaultLandingPage?: string;
}
@Injectable({ providedIn: 'root' })
export class PermissionService {
private readonly http = inject(HttpClient);
readonly permissionContext = signal<PermissionContext | null>(null);
loadPermissions(): Observable<PermissionContext> {
return this.http.get<PermissionResponse>(``).pipe(
map((response) => ({
roles: response.roles ?? [],
permissions: response.permissions ?? [],
defaultLandingPage: response.defaultLandingPage?.trim() || undefined,
})),
tap((context) => {
this.permissionContext.set(context);
})
);
}
clear(): void {
this.permissionContext.set(null);
}
}
@@ -0,0 +1,104 @@
import { Injectable, inject } from '@angular/core';
import { finalize, forkJoin, Observable, of, shareReplay, tap } from 'rxjs';
import { AppContextState, MenuContext } from '../../models/context/context.model';
import { MenuService } from '../common/menu.service';
import { PermissionService } from '../common/permission.service';
import { TenantContextService } from './tenant-context.service';
import { UserContextService } from './user-context.service';
import { NavService } from '../common/nav.service';
@Injectable({ providedIn: 'root' })
export class AppContextService {
private readonly userContextService = inject(UserContextService);
private readonly tenantContextService = inject(TenantContextService);
private readonly permissionService = inject(PermissionService);
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();
if (currentMenu && !forceReload) {
this.navService.setMenuItems(this.menuService.getNavigationMenu());
return of(currentMenu);
}
if (this.menuLoadRequest$ && !forceReload) {
return this.menuLoadRequest$;
}
this.menuLoadRequest$ = this.menuService.loadMenu().pipe(
tap(() => {
this.navService.setMenuItems(this.menuService.getNavigationMenu());
}),
finalize(() => {
this.menuLoadRequest$ = null;
}),
shareReplay(1)
);
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,22 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { TenantContext } from '../../models/context/context.model';
@Injectable({ providedIn: 'root' })
export class TenantContextService {
private readonly http = inject(HttpClient);
readonly tenantContext = signal<TenantContext | null>(null);
loadTenantContext(): Observable<TenantContext> {
return this.http.get<TenantContext>(``).pipe(
tap((context) => {
this.tenantContext.set(context);
})
);
}
clear(): void {
this.tenantContext.set(null);
}
}
@@ -0,0 +1,31 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { map, Observable, tap } from 'rxjs';
import { CurrentUserContext } from '../../models/context/context.model';
@Injectable({ providedIn: 'root' })
export class UserContextService {
private readonly http = inject(HttpClient);
readonly currentUserContext = signal<CurrentUserContext | null>(null);
loadCurrentUserProfile(): Observable<CurrentUserContext> {
return this.http.get<Partial<CurrentUserContext>>(``).pipe(
map((response) => ({
id: response.id?.trim() ?? '',
email: response.email?.trim() ?? '',
displayName: response.displayName?.trim() || undefined,
fullName: response.fullName?.trim() || undefined,
tenantId: response.tenantId?.trim() || undefined,
roles: response.roles ?? [],
defaultLandingPage: response.defaultLandingPage?.trim() || undefined,
})),
tap((profile) => {
this.currentUserContext.set(profile);
})
);
}
clear(): void {
this.currentUserContext.set(null);
}
}
@@ -0,0 +1,17 @@
import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { COUNTRY_ENDPOINTS } from "../../../core/end-points/country/country.endpoints";
import { Observable } from "rxjs";
import { DataTableQuery, DataTableResult } from "../../../shared/components/data-table/data-table.types";
@Injectable({
providedIn: 'root'
})
export class CountryService {
private readonly http = inject(HttpClient);
getCountryDataTable(query: DataTableQuery): Observable<DataTableResult<any>> {
return this.http.post<DataTableResult<any>>(`${COUNTRY_ENDPOINTS.dataTable}`, query);
}
}
@@ -0,0 +1,17 @@
import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { STATE_ENDPOINTS } from "../../end-points/state/state.endpoints"
import { Observable } from "rxjs";
import { DataTableQuery, DataTableResult } from "../../../shared/components/data-table/data-table.types";
@Injectable({
providedIn: 'root'
})
export class StateService {
private readonly http = inject(HttpClient);
getStateDataTable(query: DataTableQuery, countryId: string): Observable<DataTableResult<any>> {
return this.http.post<DataTableResult<any>>(`${STATE_ENDPOINTS.dataTable}`, { ...query, countryId });
}
}
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const billingRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/billing-list/billing-list').then((m) => m.BillingList),
data: { childTitle: 'Billing', parentTitle: 'Platform', subParentTitle: 'Subscriptions' },
},
];
@@ -0,0 +1,5 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Billing</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Plans, subscriptions, and tenant subscription upgrades will
be implemented here.</p>
</div>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-billing-list',
standalone: true,
imports: [CommonModule],
templateUrl: './billing-list.html',
styleUrl: './billing-list.scss',
})
export class BillingList {}
+529
View File
@@ -0,0 +1,529 @@
<div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
<div>
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome back,
Json Taylor !</p>
<p class="font-normal text-[#8c9097] dark:text-white/50 text-[0.813rem]">Track your sales activity, leads and deals
here.</p>
</div>
<div class="btn-list md:mt-0 mt-2">
<button type="button"
class="ti-btn bg-primary text-white btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
<i class="ri-filter-3-fill inline-block"></i>Filters
</button>
<button type="button"
class="ti-btn ti-btn-outline-secondary btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
<i class="ri-upload-cloud-line inline-block"></i>Export
</button>
</div>
</div>
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-9 xl:col-span-12 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-4 xl:col-span-4 col-span-12">
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box crm-highlight-card">
<div class="box-body">
<div class="flex items-center justify-between">
<div>
<div class="font-semibold text-[1.125rem] text-white mb-2">Your target is incomplete</div>
<span class="block text-[0.75rem] text-white"><span class="opacity-[0.7]">You have
completed</span>
<span class="font-semibold text-warning"> 48%</span> <span class="opacity-[0.7]"> of the given
target, you can also check your status</span>.</span>
<span class="block font-semibold mt-[0.125rem]"><a class="text-white text-[0.813rem]"
href="javascript:void(0);"><u>Click
here</u></a></span>
</div>
<div>
<spk-apexcharts id="crm-main" [chartOptions]="YourtargetisincompleteChart" />
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header flex justify-between">
<div class="box-title">
Top Deals
</div>
<div class="hs-dropdown ti-dropdown">
<a aria-label="anchor" href="javascript:void(0);"
class="flex items-center justify-center w-[1.75rem] h-[1.75rem] !text-[0.8rem] !py-1 !px-2 rounded-sm bg-light border-light shadow-none !font-medium"
aria-expanded="false">
<i class="fe fe-more-vertical text-[0.8rem]"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Month</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Year</a></li>
</ul>
</div>
</div>
<div class="box-body">
<ul class="list-none crm-top-deals mb-0">
@for (deal of topDeals; track $index) {
<li [class]="!$last ?'mb-[0.9rem]':''">
<div class="flex items-start flex-wrap">
<div class="me-2">
<span class="inline-flex items-center justify-center">
@if (deal.avatarImg) {
<img [src]="deal.avatarImg" [alt]="deal.name"
class="w-[1.75rem] h-[1.75rem] leading-[1.75rem] rounded-full">
} @else {
<span
[class]="`inline-flex items-center justify-center !w-[1.75rem] !h-[1.75rem] leading-[1.75rem] text-[0.65rem] rounded-full font-semibold bg-${deal.color}/10 text-${deal.color}`">
{{ deal.initials }}
</span>
}
</span>
</div>
<div class="flex-grow">
<p class="font-semibold mb-[1.4px] text-[0.813rem]">
{{ deal.name }}
</p>
<p class="text-[#8c9097] dark:text-white/50 text-[0.75rem]">
{{ deal.email }}
</p>
</div>
<div class="font-semibold text-[0.9375rem]">
{{ deal.amount }}
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">Profit Earned</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="px-2 font-normal text-[0.75rem] text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body !py-0 !ps-0">
<spk-apexcharts [chartOptions]="profitEarnedChart" id="crm-profits-earned" />
</div>
</div>
</div>
</div>
<div class="2xl:col-span-8 xl:col-span-8 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
@for (item of crmCards; track $index) {
<div class="2xl:col-span-6 xl:col-span-6 col-span-12">
<div class="box overflow-hidden">
<div class="box-body">
<div class="flex items-top justify-between">
<div>
<span
[class]="`!text-[0.8rem] !w-[2.5rem] !h-[2.5rem] !leading-[2.5rem] !rounded-full inline-flex items-center justify-center bg-${item.color}`">
<i [class]="`${item.icon} text-[1rem] text-white`"></i>
</span>
</div>
<div class="flex-grow ms-4">
<div class="flex items-center justify-between flex-wrap">
<div>
<p class="text-[#8c9097] dark:text-white/50 text-[0.813rem] mb-0">{{item.title}}</p>
<h4 class="font-semibold text-[1.5rem] !mb-2 ">{{item.number}}</h4>
</div>
<spk-apexcharts [id]="item.chartId" [chartOptions]="item.chartoptions" />
</div>
<div class="flex items-center justify-between !mt-1">
<div>
<a [class]="`text-${item.color} text-[0.813rem]`" href="javascript:void(0);">View All<i
class="ti ti-arrow-narrow-right ms-2 font-semibold inline-block"></i></a>
</div>
<div class="text-end">
<p [class]="`mb-0 ${item.percentageColor} text-[0.813rem] font-semibold`">{{item.percentage}}
</p>
<p class="text-[#8c9097] dark:text-white/50 opacity-[0.7] text-[0.6875rem]">this month</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header !gap-0 !m-0 justify-between">
<div class="box-title">
Revenue Analytics
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);"
class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50" aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body !py-5">
<spk-apexcharts [id]="'crm-revenue-analytics'" [chartOptions]="RevenueAnalytics" />
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box custom-card">
<div class="box-header justify-between">
<div class="box-title">
Deals Statistics
</div>
<div class="flex flex-wrap gap-2">
<div>
<input class="ti-form-control form-control-sm" type="text" placeholder="Search Here"
aria-label=".form-control-sm example">
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);"
class="ti-btn ti-btn-primary !bg-primary !text-white !py-1 !px-2 !text-[0.75rem] !m-0 !gap-0 !font-medium"
aria-expanded="false">
Sort By<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">New</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Popular</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Relevant</a></li>
</ul>
</div>
</div>
</div>
<div class="box-body">
<div class="overflow-x-auto">
<div class="table-responsive">
<spk-reusable-tables [columns]="SalesRepData.columns" [showCheckbox]="true"
tableClass="table min-w-full whitespace-nowrap table-hover border table-bordered">
@for (row of SalesRepData.rows; track row.id) {
<tr class="border !border-defaultborder border-solid hover:bg-gray-100 dark:!border-defaultborder/10 dark:hover:!bg-light">
<td scope="row" class="">
<input class="form-check-input" type="checkbox" [checked]="row.checked" aria-label="...">
</td>
<td>
<div class="flex items-center font-semibold">
<span class="!me-2 inline-flex justify-center items-center">
<img [src]="row.img" alt="img" class="w-[1.75rem] h-[1.75rem] rounded-full">
</span>
{{ row.name }}
</div>
</td>
<td>{{ row.category }}</td>
<td>{{ row.mail }}</td>
<td>
<span
[class]="'inline-flex text-' + row.locationClass + ' !py-[0.15rem] !px-[0.45rem] rounded-sm !font-semibold !text-[0.75em] bg-' + row.locationClass + '/10'">
{{ row.location }}
</span>
</td>
<td>{{ row.date }}</td>
<td>
<div class="flex flex-row items-center !gap-2 text-[0.9375rem]">
<a aria-label="anchor" href="javascript:void(0);"
class="ti-btn ti-btn-icon ti-btn-wave !gap-0 !m-0 !h-[1.75rem] !w-[1.75rem] text-[0.8rem] bg-success/10 text-success hover:bg-success hover:!text-white hover:!border-success">
<i class="ri-download-2-line"></i>
</a>
<a aria-label="anchor" href="javascript:void(0);"
class="ti-btn ti-btn-icon ti-btn-wave !gap-0 !m-0 !h-[1.75rem] !w-[1.75rem] text-[0.8rem] bg-primary/10 text-primary hover:bg-primary hover:!text-white hover:!border-primary">
<i class="ri-edit-line"></i>
</a>
</div>
</td>
</tr>
}
</spk-reusable-tables>
</div>
</div>
</div>
<div class="box-footer">
<div class="sm:flex items-center">
<div class="text-defaulttextcolor dark:text-defaulttextcolor/70">
Showing 5 Entries <i class="bi bi-arrow-right ms-2 font-semibold"></i>
</div>
<div class="ms-auto">
<nav aria-label="Page navigation" class="pagination-style-4">
<ul class="ti-pagination mb-0">
<li class="page-item disabled">
<a class="page-link" href="javascript:void(0);">
Prev
</a>
</li>
<li class="page-item"><a class="page-link active" href="javascript:void(0);">1</a></li>
<li class="page-item"><a class="page-link" href="javascript:void(0);">2</a></li>
<li class="page-item">
<a class="page-link !text-primary" href="javascript:void(0);">
next
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="2xl:col-span-3 xl:col-span-12 col-span-12">
<div class="grid grid-cols-12 gap-x-6">
<div class="2xl:col-span-12 xl:col-span-12 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Leads By Source
</div>
<div class="hs-dropdown ti-dropdown">
<a aria-label="anchor" href="javascript:void(0);"
class="flex items-center justify-center w-[1.75rem] h-[1.75rem] ! !text-[0.8rem] !py-1 !px-2 rounded-sm bg-light border-light shadow-none !font-medium"
aria-expanded="false">
<i class="fe fe-more-vertical text-[0.8rem]"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Month</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Year</a></li>
</ul>
</div>
</div>
<div class="box-body !p-6 mt-4">
<div class="leads-source-chart flex items-center justify-center">
<spk-chartjs [id]="'leads-source'" [class]="'chartjs-chart w-full'" [height]="250" [chartjs]="LeadsBySourceChart" />
<div class="lead-source-value ">
<span class="block text-[0.875rem] ">Total</span>
<span class="block text-[1.5625rem] font-bold">4,145</span>
</div>
</div>
</div>
<div class="grid grid-cols-4 mt-4 border-t border-dashed border-defaultborder dark:border-defaultborder/10">
@for (item of deviceLeads; track item.label) {
<div class="col !p-0">
<div [class]="item.containerClass">
<span class="text-[#8c9097] dark:text-white/50 text-[0.75rem] mb-1 crm-lead-legend inline-block"
[class]="item.legendClass">
{{ item.label }}
</span>
<div>
<span class="text-[1rem] font-semibold">{{ item.value }}</span>
</div>
</div>
</div>
}
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-6 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Deals Status
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body">
<div class="flex items-center mb-[0.8rem]">
<h4 class="font-bold mb-0 text-[1.5rem] ">4,289</h4>
<div class="ms-2">
<span
class="py-[0.18rem] px-[0.45rem] rounded-sm text-success !font-medium !text-[0.75em] bg-success/10">1.02<i
class="ri-arrow-up-s-fill align-mmiddle ms-1"></i></span>
<span class="text-[#8c9097] dark:text-white/50 text-[0.813rem] ms-1">compared to last week</span>
</div>
</div>
<div class="flex w-full h-[0.3125rem] mb-6 rounded-full overflow-hidden">
<div class="flex flex-col justify-center rounded-s-[0.625rem] overflow-hidden bg-primary w-[21%]"
aria-valuenow="21" aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-none overflow-hidden bg-info w-[26%]" aria-valuenow="26"
aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-none overflow-hidden bg-warning w-[35%]"
aria-valuenow="35" aria-valuemin="0" aria-valuemax="100">
</div>
<div class="flex flex-col justify-center rounded-e-[0.625rem] overflow-hidden bg-success w-[18%]"
aria-valuenow="18" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<ul class="list-none mb-0 pt-2 crm-deals-status">
@for (deal of dealStats; track deal.title) {
<li [class]="deal.liClass">
<div class="flex items-center text-[0.813rem] justify-between">
<div>{{ deal.title }}</div>
<div class="text-[0.75rem] text-[#8c9097] dark:text-white/50">
{{ deal.count }}
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
<div class="2xl:col-span-12 xl:col-span-6 col-span-12">
<div class="box">
<div class="box-header justify-between">
<div class="box-title">
Recent Activity
</div>
<div class="hs-dropdown ti-dropdown">
<a href="javascript:void(0);" class="text-[0.75rem] px-2 font-normal text-[#8c9097] dark:text-white/50"
aria-expanded="false">
View All<i class="ri-arrow-down-s-line align-middle ms-1 inline-block"></i>
</a>
<ul class="hs-dropdown-menu ti-dropdown-menu hidden" role="menu">
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Today</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">This Week</a></li>
<li><a class="ti-dropdown-item !py-2 !px-[0.9375rem] !text-[0.8125rem] !font-medium block"
href="javascript:void(0);">Last Week</a></li>
</ul>
</div>
</div>
<div class="box-body">
<div>
<ul class="list-none mb-0 crm-recent-activity">
@for (item of recentActivity; track $index) {
<li class="crm-recent-activity-content text-defaultsize">
<div class="flex items-start">
<div class="me-4">
<span
class="w-[1.25rem] h-[1.25rem] inline-flex items-center justify-center font-medium leading-[1.25rem] text-[0.65rem] rounded-full"
[class]="item.statusClass">
<i class="bi bi-circle-fill text-[0.5rem]"></i>
</span>
</div>
<div class="crm-timeline-content">
<span [class.font-semibold]="item.boldMain">{{ item.mainText }}</span>
@if (item.linkText) {
<a href="javascript:void(0);" [class]="item.linkClass">{{ item.linkText }}</a>
}
@if (item.boldText) {
<span class="font-semibold">{{ item.boldText }}</span>
}
@if (item.highlightText) {
<span [class]="item.highlightClass">{{ item.highlightText }}</span>
}
@if (item.tagText) {
<span [class]="item.tagClass">{{ item.tagText }}</span>
}
@if (item.afterText) {
<span>{{ item.afterText }}</span>
}
@if (item.showAddIcon) {
<span
class="w-[1.25rem] h-[1.25rem] leading-[1.25rem] text-[0.65rem] inline-flex items-center justify-center font-medium bg-purple/10 rounded-full ms-1">
<i class="ri-add-fill text-purple text-[0.75rem]"></i>
</span>
}
@if (item.showCheckIcon) {
<i class="ri-checkbox-circle-line text-success text-[1rem] align-middle ms-1"></i>
}
@if (item.subText) {
<span class="block text-[0.75rem] text-[#8c9097] dark:text-white/50">
{{ item.subText }}
</span>
}
</div>
<div class="flex-grow text-end">
<span class="block text-[#8c9097] dark:text-white/50 text-[0.6875rem] opacity-[0.7]">
{{ item.time }}
</span>
</div>
</div>
</li>
}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
+618
View File
@@ -0,0 +1,618 @@
import { Chart, ChartConfiguration, Plugin } from 'chart.js';
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SpkApexcharts } from "../../../@spk/charts/charts/spk-apexcharts/spk-apexcharts";
import { ApexOptions } from 'ng-apexcharts';
import { SpkReusableTables } from "../../../@spk/tables/spk-reusable-tables/spk-reusable-tables";
import { SpkChartjs } from "../../../@spk/charts/charts/spk-chartjs/spk-chartjs";
@Component({
selector: 'app-crm',
standalone: true,
imports: [RouterModule, SpkApexcharts, SpkReusableTables, SpkChartjs],
templateUrl: './crm.html',
styleUrl: './crm.scss'
})
export class Crm {
YourtargetisincompleteChart: ApexOptions = {
chart: {
height: 127,
width: 100,
type: 'radialBar',
},
series: [48],
// colors: ['#fff'],
plotOptions: {
radialBar: {
hollow: {
margin: 0,
size: '55%',
background: '#fff',
},
dataLabels: {
name: {
offsetY: -10,
color: '#4b9bfa',
fontSize: '.625rem',
show: false,
},
value: {
offsetY: 5,
color: '#4b9bfa',
fontSize: '.875rem',
show: true,
fontWeight: 600,
},
},
},
},
stroke: {
lineCap: 'round',
},
labels: ['Status'],
colors: ['#fff']
}
topDeals = [
{
name: 'Michael Jordan',
email: 'michael.jordan@example.com',
amount: '$2,893',
avatarImg: './assets/images/faces/10.jpg'
},
{
name: 'Emigo Kiaren',
email: 'emigo.kiaren@gmail.com',
amount: '$4,289',
initials: 'EK',
color: 'warning', // Used for text-warning and bg-warning/10
},
{
name: 'Randy Origoan',
email: 'randy.origoan@gmail.com',
amount: '$6,347',
avatarImg: './assets/images/faces/12.jpg'
},
{
name: 'George Pieterson',
email: 'george.pieterson@gmail.com',
amount: '$3,894',
initials: 'GP',
color: 'success',
},
{
name: 'Kiara Advain',
email: 'kiaraadvain214@gmail.com',
amount: '$2,679',
initials: 'KA',
color: 'primary',
}
];
profitEarnedChart: ApexOptions = {
series: [
{
name: 'Profit Earned',
data: [44, 42, 57, 86, 58, 55, 70],
},
{
name: 'Total Sales',
data: [34, 22, 37, 56, 21, 35, 60],
},
],
chart: {
type: 'bar',
height: 180,
toolbar: {
show: false,
},
},
grid: {
borderColor: '#f1f1f1',
strokeDashArray: 3,
},
colors: ['rgb(132, 90, 223)', '#e4e7ed'],
plotOptions: {
bar: {
colors: {
ranges: [
{
from: -100,
to: -46,
color: '#ebeff5',
},
{
from: -45,
to: 0,
color: '#ebeff5',
},
],
},
columnWidth: '60%',
borderRadius: 5,
},
},
dataLabels: {
enabled: false,
},
stroke: {
show: true,
width: 2,
colors: undefined,
},
legend: {
show: false,
position: 'top',
},
yaxis: {
title: {
style: {
color: '#adb5be',
fontSize: '13px',
fontFamily: 'poppins, sans-serif',
fontWeight: 600,
cssClass: 'apexcharts-yaxis-label',
},
},
labels: {
formatter: function (y: number) {
return y.toFixed(0) + '';
},
},
},
xaxis: {
type: 'category',
categories: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
axisBorder: {
show: true,
color: 'rgba(119, 119, 142, 0.05)',
offsetX: 0,
offsetY: 0,
},
axisTicks: {
show: true,
borderType: 'solid',
color: 'rgba(119, 119, 142, 0.05)',
offsetX: 0,
offsetY: 0,
},
labels: {
rotate: -90,
},
},
}
crmCardsChartOptions({ series, colors }: { colors: string[], series: { data: number[], name: string }[] }): ApexOptions {
return {
chart: {
type: 'line',
height: 40,
width: 100,
sparkline: {
enabled: true
}
},
dataLabels: {
enabled: false
},
stroke: {
show: true,
curve: 'smooth',
lineCap: 'butt',
colors: undefined,
width: 1.5,
dashArray: 0,
},
fill: {
type: 'gradient',
gradient: {
opacityFrom: 0.9,
opacityTo: 0.9,
stops: [0, 98],
}
},
series: series,
yaxis: {
min: 0,
show: false,
axisBorder: {
show: false
},
},
xaxis: {
labels: {
show: false,
},
axisBorder: {
show: false
},
},
tooltip: {
enabled: true,
},
colors: colors,
}
}
crmCards = [
{
color: 'primary',
icon: 'ti ti-users',
title: 'Total Customers',
number: '1,02,890',
chartId: 'crm-total-customers',
chartoptions: this.crmCardsChartOptions({
series: [{
name: 'Value',
data: [20, 14, 19, 10, 23, 20, 22, 9, 12]
}], colors: ["rgb(132, 90, 223)"],
}),
viewallTextColor: 'text-primary',
percentage: '+40%',
percentageColor: 'text-success',
},
{
color: 'secondary',
icon: 'ti ti-wallet',
title: 'Total Revenue',
number: '$56,562',
chartId: 'crm-total-revenue',
chartoptions: this.crmCardsChartOptions({
series: [
{
name: 'Value',
data: [20, 14, 20, 22, 9, 12, 19, 10, 25],
},
], colors: ['rgb(35, 183, 229)'],
}),
viewallTextColor: 'text-secondary',
percentage: '+25%',
percentageColor: 'text-success',
},
{
color: 'success',
icon: 'ti ti-wave-square',
title: 'Conversion Ratio',
number: '12.08%',
chartId: 'crm-conversion-ratio',
chartoptions: this.crmCardsChartOptions({
series: [
{
name: 'Value',
data: [20, 20, 22, 9, 14, 19, 10, 25, 12],
},
], colors: ['rgb(38, 191, 148)'],
}),
viewallTextColor: 'text-success',
percentage: '-12%',
percentageColor: 'text-danger',
},
{
color: 'warning',
icon: 'ti ti-briefcase',
title: 'Total Deals',
number: '2,543',
chartId: 'crm-total-deals',
chartoptions: this.crmCardsChartOptions({
series: [
{
name: 'Value',
data: [20, 20, 22, 9, 12, 14, 19, 10, 25],
},
], colors: ['rgb(245, 184, 73)'],
}),
viewallTextColor: 'text-warning',
percentage: '+19%',
percentageColor: 'text-success',
}
];
RevenueAnalytics: ApexOptions = {
series: [
{
type: 'line',
name: 'Profit',
data: [
{ x: 'Jan', y: 100 },
{ x: 'Feb', y: 210 },
{ x: 'Mar', y: 180 },
{ x: 'Apr', y: 454 },
{ x: 'May', y: 230 },
{ x: 'Jun', y: 320 },
{ x: 'Jul', y: 656 },
{ x: 'Aug', y: 830 },
{ x: 'Sep', y: 350 },
{ x: 'Oct', y: 350 },
{ x: 'Nov', y: 210 },
{ x: 'Dec', y: 410 },
],
},
{
type: 'line',
name: 'Revenue',
data: [
{ x: 'Jan', y: 180 },
{ x: 'Feb', y: 620 },
{ x: 'Mar', y: 476 },
{ x: 'Apr', y: 220 },
{ x: 'May', y: 520 },
{ x: 'Jun', y: 780 },
{ x: 'Jul', y: 435 },
{ x: 'Aug', y: 515 },
{ x: 'Sep', y: 738 },
{ x: 'Oct', y: 454 },
{ x: 'Nov', y: 525 },
{ x: 'Dec', y: 230 },
],
},
{
type: 'area',
name: 'Sales',
data: [
{ x: 'Jan', y: 200 },
{ x: 'Feb', y: 530 },
{ x: 'Mar', y: 110 },
{ x: 'Apr', y: 130 },
{ x: 'May', y: 480 },
{ x: 'Jun', y: 520 },
{ x: 'Jul', y: 780 },
{ x: 'Aug', y: 435 },
{ x: 'Sep', y: 475 },
{ x: 'Oct', y: 738 },
{ x: 'Nov', y: 454 },
{ x: 'Dec', y: 480 },
],
},
],
chart: {
type: 'line',
height: 350,
animations: {
speed: 500,
},
toolbar: {
show: true
},
zoom: {
enabled: true,
},
dropShadow: {
enabled: false,
enabledOnSeries: undefined,
top: 8,
left: 0,
blur: 3,
color: '#000',
opacity: 0.1,
},
},
colors: ["rgb(132, 90, 223)", "rgba(35, 183, 229, 0.85)", "rgba(119, 119, 142, 0.05)"],
dataLabels: {
enabled: false,
},
grid: {
padding:{
left:0,
right:0,
top:0,
bottom:0
},
borderColor: '#f1f1f1',
strokeDashArray: 3,
yaxis: {
lines: {
show: false
}
},
},
stroke: {
curve: 'smooth',
width: [2, 2, 0],
dashArray: [0, 5, 0],
},
xaxis: {
axisTicks: {
show: false,
},
},
yaxis: {
labels: {
formatter: (value: number) => `$${value}`,
},
},
tooltip: {
y: [
{
formatter: (value: number) => `$${value}`,
},
{
formatter: (value: number) => `$${value.toFixed(0)}`,
},
{
formatter: (value: number) => `$${value.toFixed(0)}`,
},
],
},
legend: {
show: true,
offsetY: 15,
customLegendItems: ['Profit', 'Revenue', 'Sales'],
inverseOrder: true,
markers: {
size: 5
}
},
title: {
text: 'Revenue Analytics with sales & profit (USD)',
align: 'left',
style: {
fontSize: '.8125rem',
fontWeight: 'semibold',
color: '#8c9097',
},
},
markers: {
hover: {
sizeOffset: 5,
},
},
}
SalesRepData = {
columns: [
{ header: 'Sales Rep', tableHeadColumn: '!text-start !text-[0.85rem] min-w-[200px]' },
{ header: 'Category', tableHeadColumn: '!text-start !text-[0.85rem]' },
{ header: 'Mail', tableHeadColumn: '!text-start !text-[0.85rem]' },
{ header: 'Location', tableHeadColumn: '!text-start !text-[0.85rem]' },
{ header: 'Date', tableHeadColumn: '!text-start !text-[0.85rem]' },
{ header: 'Action', tableHeadColumn: '!text-start !text-[0.85rem]' }
],
rows: [
{ id: 1, name: 'Mayor Kelly', img: './assets/images/faces/4.jpg', category: 'Manufacture', mail: 'mayorkelly@gmail.com', location: 'Germany', locationClass: 'info', date: 'Sep 15 - Oct 12, 2023', checked: false },
{ id: 2, name: 'Andrew Garfield', img: './assets/images/faces/15.jpg', category: 'Development', mail: 'andrewgarfield@gmail.com', location: 'Canada', locationClass: 'primary', date: 'Apr 10 - Dec 12, 2023', checked: true },
{ id: 3, name: 'Simon Cowel', img: './assets/images/faces/11.jpg', category: 'Service', mail: 'simoncowel234@gmail.com', location: 'Europe', locationClass: 'danger', date: 'Sep 15 - Oct 12, 2023', checked: false },
{ id: 4, name: 'Mirinda Hers', img: './assets/images/faces/8.jpg', category: 'Marketing', mail: 'mirindahers@gmail.com', location: 'USA', locationClass: 'warning', date: 'Apr 14 - Dec 14, 2023', checked: true },
{ id: 5, name: 'Jacob Smith', img: './assets/images/faces/9.jpg', category: 'Social Plataform', mail: 'jacobsmith@gmail.com', location: 'Singapore', locationClass: 'success', date: 'Feb 25 - Nov 25, 2023', checked: true }
]
};
LeadsBySourceChart: ChartConfiguration<'doughnut'> = {
type: 'doughnut',
data: {
datasets: [{
data: [32, 27, 25, 16],
backgroundColor: [
'rgb(132, 90, 223)', // Purple
'rgb(35, 183, 229)', // Blue
'rgb(245, 184, 73)', // Orange
'rgb(38, 191, 148)', // Green
],
// 1. INCREASE SPACING: This creates the clear gap between segments
spacing: -20,
// 2. ROUNDED BORDER: 10-20 is usually the sweet spot for this thinness
borderRadius: 20,
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
// 3. THINNESS: Ensure this is high enough (80% to 90%)
cutout: '86%',
plugins: {
legend: { display: false },
tooltip: { enabled: true }
}
}
};
recentActivity = [
{
statusClass: 'text-primary bg-primary/10',
time: '4:45PM',
mainText: 'Update of calendar events & ',
boldMain: true,
linkText: 'Added new events in next week.',
linkClass: 'text-primary font-semibold'
},
{
statusClass: 'text-secondary bg-secondary/10',
time: '3 hrs',
mainText: 'New theme for ',
boldText: 'Spruko Website',
afterText: ' completed',
subText: 'Lorem ipsum, dolor sit amet.'
},
{
statusClass: 'text-success bg-success/10',
time: '22 hrs',
mainText: 'Created a ',
highlightText: 'New Task',
highlightClass: 'text-success font-semibold',
afterText: ' today',
showAddIcon: true
},
{
statusClass: 'text-pink bg-pink/10',
time: 'Today',
mainText: 'New member ',
tagText: '@andreas gurrero',
tagClass: 'py-[0.2rem] px-[0.45rem] font-semibold rounded-sm text-pink text-[0.75em] bg-pink/10',
afterText: ' added today to AI Summit.'
},
{
statusClass: 'text-warning bg-warning/10',
time: '22 hrs',
mainText: '32 New people joined summit.'
},
{
statusClass: 'text-info bg-info/10',
time: '12 hrs',
mainText: 'Neon Tarly added ',
highlightText: 'Robert Bright',
highlightClass: 'text-info font-semibold',
afterText: ' to AI summit project.'
},
{
statusClass: 'text-[#232323] dark:text-white bg-[#232323]/10 dark:bg-white/20',
time: '4 hrs',
mainText: 'Replied to new support request ',
showCheckIcon: true
},
{
statusClass: 'text-purple bg-purple/10',
time: '4 hrs',
mainText: 'Completed documentation of ',
linkText: 'AI Summit.',
linkClass: 'text-purple underline font-semibold'
}
];
deviceLeads = [
{
label: 'Mobile',
value: '1,624',
legendClass: 'mobile',
containerClass: '!ps-4 p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
},
{
label: 'Desktop',
value: '1,267',
legendClass: 'desktop',
containerClass: 'p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
},
{
label: 'Laptop',
value: '1,153',
legendClass: 'laptop',
containerClass: 'p-[0.95rem] text-center border-e border-dashed border-defaultborder dark:border-defaultborder/10'
},
{
label: 'Tablet',
value: '679',
legendClass: 'tablet',
containerClass: '!pe-4 p-[0.95rem] text-center'
}
];
dealStats = [
{ title: 'Successful Deals', count: '987 deals', liClass: 'primary' },
{ title: 'Pending Deals', count: '1,073 deals', liClass: 'info' },
{ title: 'Rejected Deals', count: '1,674 deals', liClass: 'warning' },
{ title: 'Upcoming Deals', count: '921 deals', liClass: 'success' }
];
}
@@ -0,0 +1,12 @@
import { Routes } from '@angular/router';
export const dashboardRoutingModule: Routes = [
{
path: 'crm',
loadComponent: () => import('./crm/crm').then((m) => m.Crm),
title: 'YNEX - Crm',
},
];
+15
View File
@@ -0,0 +1,15 @@
import { Routes } from '@angular/router';
export const errorRoutingModule: Routes = [
{
path: 'error', children: [
{
path: 'error404',
loadComponent: () => import('./error404/error404').then( (m) => m.Error404 ),
title: 'YNEX - Error 404'
},
]
}
];
@@ -0,0 +1,27 @@
<div class="page error-bg dark:!bg-bodybg" id="particles-js">
<!-- 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">
<div class="flex items-center justify-center h-full !text-defaulttextcolor">
<div class="xl:col-span-3"></div>
<div class="xl:col-span-6 col-span-12">
<p class="error-text sm:mb-0 mb-2">404</p>
<p class="text-[1.125rem] font-semibold mb-4">Oops 😭,The page you are looking for is not available.</p>
<div class="flex justify-center items-center mb-[3rem]">
<div class="xl:col-span-6 w-[50%]">
<p class="mb-0 opacity-[0.7]">We are sorry for the inconvenience,The page you are trying to access has
been removed or never been existed.</p>
</div>
</div>
<a routerLink="/dashboards/crm" class="ti-btn bg-primary text-white font-semibold"><i
class="ri-arrow-left-line align-middle inline-block"></i>BACK TO HOME</a>
</div>
<div class="xl:col-span-3"></div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import {RouterModule} from'@angular/router';
import { SpkParticles } from "../../../@spk/plugins&reusable/spk-particles/spk-particles";
import {particlesOptions} from "../particleoptions"
@Component({
selector: 'app-error404',
standalone: true,
imports: [RouterModule, SpkParticles],
templateUrl: './error404.html',
styleUrls: ['./error404.scss']
})
export class Error404 {
particlesOptions=particlesOptions
}
@@ -0,0 +1,36 @@
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"
}
}
};
@@ -0,0 +1 @@
<p>city-list works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'city-list',
imports: [],
templateUrl: './city-list.html',
styleUrl: './city-list.scss',
})
export class CityList {
}
@@ -0,0 +1,102 @@
<app-data-table [columns]="columns()" [rows]="countries()" [actions]="actions()" [loading]="loading()"
(addClicked)="onAddCountry()" [totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()" [pageSizeOptions]="[5, 10, 20, 50]" tableTitle="Countries" buttonTitle="Add"
[showSearch]="true" [showAddButton]="true" searchPlaceholder="Search countries..." [searchDebounceTime]="300"
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)">
<ng-template appDataTableCell="name" let-row let-value="value">
<div class="flex items-center gap-2">
@if (getFlagUrl(row.iso2); as flagUrl) {
<img [src]="flagUrl" [alt]="value + ' flag'" class="w-6 h-[18px] object-cover rounded-sm shrink-0"
(error)="onFlagError($event)" />
}
<span class="font-semibold">
{{ value }}
</span>
</div>
</ng-template>
</app-data-table>
<!-- Start:: New Deal -->
<modal [open]="showCountryModal()" [title]="countryModalTitle()" [subtitle]="countryModalSubtitle()" size="md"
[submitAction]="countrySubmitAction()" [submitLabel]="countrySubmitLabel()" [loadingLabel]="countryLoadingLabel()"
[loading]="saving()" [submitDisabled]="countryForm.invalid" (closed)="closeCountryModal()"
(submitted)="saveCountry()">
<form [formGroup]="countryForm" (ngSubmit)="saveCountry()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<div class="col-span-6">
<app-form-input formControlName="name" inputId="country-name" label="Country Name"
placeholder="Enter country name" autocomplete="off" [required]="true" [maxLength]="150" [validationMessages]="{
required: 'Country Name is required.',
maxlength: 'Country Name cannot exceed 150 characters.'
}" />
</div>
<div class="col-span-6">
<app-form-input formControlName="defaultCurrencyId" inputId="country-defaultCurrencyId" label="Default Currency"
placeholder="Enter country currency" autocomplete="off" [required]="true" [maxLength]="16" [validationMessages]="{
required: 'Country Currency is required.',
}" [showPlaceholder]="false"/>
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="iso2" inputId="country-iso2" label="ISO2 Code" placeholder="For example: IN"
inputClass="uppercase" autocomplete="off" [required]="true" [minLength]="2" [maxLength]="2"
[validationMessages]="{
required: 'ISO2 Code is required.',
minlength: 'ISO2 Code must contain exactly 2 letters.',
maxlength: 'ISO2 Code must contain exactly 2 letters.',
pattern: 'ISO2 Code can contain letters only.'
}" />
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="iso3" inputId="country-iso3" label="ISO3 Code" placeholder="For example: IND"
inputClass="uppercase" autocomplete="off" [required]="true" [minLength]="3" [maxLength]="3"
[validationMessages]="{
required: 'ISO3 Code is required.',
minlength: 'ISO3 Code must contain exactly 3 letters.',
maxlength: 'ISO3 Code must contain exactly 3 letters.',
pattern: 'ISO3 Code can contain letters only.'
}" />
</div>
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="phoneCode" inputId="country-phone-code" label="Phone Code" type="tel"
inputMode="tel" placeholder="For example: +91" autocomplete="off" [maxLength]="20" [validationMessages]="{
maxlength: 'Phone Code cannot exceed 20 characters.',
pattern: 'Phone Code can contain a plus sign and digits only.'
}" />
</div>
<div class="col-span-12 md:col-span-6">
<!-- <app-form-field
label="Default Currency"
inputId="country-default-currency"
[control]="countryForm.controls.defaultCurrencyId"
>
<select
id="country-default-currency"
class="form-control"
formControlName="defaultCurrencyId"
>
<option [ngValue]="null">
Select default currency
</option>
@for (
currency of currencyOptions();
track currency.value
) {
<option [value]="currency.value">
{{ currency.label }}
</option>
}
</select>
</app-form-field> -->
</div>
</div>
</form>
</modal>
<!-- End:: New Deal -->
@@ -0,0 +1,343 @@
import { Component, ElementRef, viewChild } from '@angular/core';
import { inject, signal, computed } from '@angular/core';
import type { CountryModalMode } from '../../../../../core/models/country/country.model';
import { CountryService } from '../../../../../core/services/country/country.service';
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
import { DataTablePageEvent, DataTableSortEvent, DataTableQuery, DataTableColumn, DataTableAction, DataTableActionEvent } from '../../../../../shared/components/data-table/data-table.types';
import { DataTable } from '../../../../../shared/components/data-table/data-table';
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
import { finalize } from 'rxjs/operators';
import { Modal } from '../../../../../shared/components/modal/modal';
import {
FormBuilder,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { Button } from '../../../../../shared/components/button/button';
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { CountryDto } from '../../../../../core/models/country/country.model';
@Component({
selector: 'country-list',
standalone: true,
imports: [DataTable, DataTableCellDirective, Modal, ReactiveFormsModule, Button,
FormInput],
templateUrl: './country-list.html',
styleUrl: './country-list.scss',
})
export class CountryList {
private readonly countryApi: CountryService = inject(CountryService);
private readonly formBuilder = inject(FormBuilder);
readonly queryState = new DataTableQueryState();
readonly countries = signal<any[]>([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly loading = signal(false);
readonly modalMode = signal<CountryModalMode>('create');
readonly showCountryModal = signal(false);
readonly countryModalMode = signal<CountryModalMode>('create');
readonly selectedCountryId = signal<string | null>(null);
readonly saving = signal(false);
readonly countryModalTitle = computed(() =>
this.countryModalMode() === 'create'
? 'Add Country'
: 'Edit Country'
);
readonly countryModalSubtitle = computed(() =>
this.countryModalMode() === 'create'
? 'Enter the country details below.'
: 'Update the country details below.'
);
readonly countrySubmitLabel = computed(() =>
this.countryModalMode() === 'create'
? 'Save Country'
: 'Update Country'
);
readonly countryLoadingLabel = computed(() =>
this.countryModalMode() === 'create'
? 'Saving Country...'
: 'Updating Country...'
);
readonly countrySubmitAction = computed<'save' | 'update'>(() =>
this.countryModalMode() === 'create'
? 'save'
: 'update'
);
readonly countryForm = this.formBuilder.nonNullable.group({
name: [
'',
[
Validators.required,
Validators.maxLength(150)
]
],
iso2: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z]{2}$/)
]
],
iso3: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z]{3}$/)
]
],
phoneCode: [
'',
[
Validators.maxLength(20),
Validators.pattern(/^\+?[0-9]*$/)
]
],
currency: [
'',
[
Validators.maxLength(3),
Validators.pattern(/^[A-Za-z]{3}$/)
]
],
defaultCurrencyId:
this.formBuilder.control<string | null>(null)
});
readonly columns = signal<DataTableColumn[]>([
{ key: 'serialNumber', label: 'Sr.No.', header: 'Sr.No.', sortable: false, width: '100px' },
{ key: 'name', label: 'Name', header: 'Name', sortable: true, align: 'left' },
{ key: 'iso2', label: 'ISO2', header: 'ISO2', sortable: true },
{ key: 'iso3', label: 'ISO3', header: 'ISO3', sortable: true },
{ key: 'phoneCode', label: 'Phone Code', header: 'Phone Code', sortable: true },
{
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[]>([
{
type: 'edit',
label: 'Edit',
icon: 'ti ti-edit ti-btn-info',
className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-info me-2'
},
{
type: 'delete',
label: 'Delete',
icon: 'ti ti-trash ti-btn-danger',
className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-danger me-2',
visible: (row: any) => row.isActive
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check ti-btn-success',
className: 'ti-btn ti-btn-icon ti-btn-sm ti-btn-success me-2',
visible: (row: any) => !row.isActive
},
]);
ngOnInit(): void {
this.loadCountries(this.queryState.getQuery());
}
loadCountries(query: DataTableQuery): void {
this.loading.set(true);
this.countryApi
.getCountryDataTable(query)
.pipe(
finalize(() => {
this.loading.set(false);
})
)
.subscribe({
next: (response: any) => {
console.log('Country data loaded:', response);
if (response.draw !== this.queryState.getQuery().draw) {
return;
}
// Add serial numbers to countries
const countriesWithSerialNumbers = response.rows.map((country: any, index: number) => ({
...country,
serialNumber: (query.page - 1) * query.pageSize + index + 1
}));
this.countries.set(countriesWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
},
error: (error: any) => {
console.error('Unable to load countries.', error);
this.countries.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
});
}
onSearch(value: string): void {
const query = this.queryState.setSearch(value.trim());
this.loadCountries(query);
}
onPageChange(event: DataTablePageEvent): void {
const query = this.queryState.setPage(event);
this.loadCountries(query);
}
onSortChange(event: DataTableSortEvent): void {
const query = this.queryState.setSort(event);
this.loadCountries(query);
}
onRefresh(): void {
const currentQuery = this.queryState.getQuery();
const query: DataTableQuery = {
...currentQuery,
draw: currentQuery.draw + 1
};
this.loadCountries(query);
}
onReset(): void {
const query = this.queryState.reset();
this.loadCountries(query);
}
onActionClick(event: DataTableActionEvent): void {
const action = event.action.type;
const country = event.row;
switch (action) {
case 'view':
this.viewCountry(country);
break;
case 'edit':
this.openEditCountry(country);
break;
case 'delete':
this.deleteCountry(country);
break;
case 'activate':
this.activateCountry(country);
break;
}
}
onAddCountry(): void {
this.countryModalMode.set('create');
this.selectedCountryId.set(null);
this.countryForm.reset({
name: '',
iso2: '',
iso3: '',
phoneCode: '',
defaultCurrencyId: null
});
this.showCountryModal.set(true);
}
closeCountryModal(): void {
if (this.saving()) {
return;
}
this.showCountryModal.set(false);
this.selectedCountryId.set(null);
}
saveCountry(): void {
if (this.countryForm.invalid) {
this.countryForm.markAllAsTouched();
return;
}
this.saving.set(true);
const request = this.countryForm.getRawValue();
// Replace with the actual API request.
console.log(request);
this.saving.set(false);
this.showCountryModal.set(false);
}
private viewCountry(country: any): void {
console.log('Viewing country:', country);
// TODO: Implement view logic (open modal, navigate to details page, etc.)
}
openEditCountry(country: CountryDto): void {
this.countryModalMode.set('edit');
this.selectedCountryId.set(country.id);
this.countryForm.reset({
name: country.name ?? '',
iso2: country.iso2 ?? '',
iso3: country.iso3 ?? '',
phoneCode: country.phoneCode ?? '',
defaultCurrencyId: country.defaultCurrencyId ?? null
});
this.showCountryModal.set(true);
}
private deleteCountry(country: any): void {
console.log('Deleting country:', country);
// TODO: Implement delete logic (API call to delete country)
}
private activateCountry(country: any): void {
console.log('Activating country:', country);
// TODO: Implement activate logic (API call to activate country)
}
getFlagUrl(iso2: string | null | undefined): string {
const code = iso2?.trim().toLowerCase();
return code && /^[a-z]{2}$/.test(code)
? `https://flagcdn.com/24x18/${code}.png`
: '';
}
onFlagError(event: Event): void {
const image = event.target as HTMLImageElement;
image.style.display = 'none';
}
}
@@ -0,0 +1,19 @@
import { Routes } from '@angular/router';
export const globalMastersRoutes: Routes = [
{
path: 'countries',
loadComponent: () => import('./countries/pages/country-list/country-list').then((m) => m.CountryList),
data: { childTitle: 'Country Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
},
{
path: 'states',
loadComponent: () => import('./states/pages/state-list/state-list').then((m) => m.StateList),
data: { childTitle: 'State Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
},
{
path: 'cities',
loadComponent: () => import('./cities/pages/city-list/city-list').then((m) => m.CityList),
data: { childTitle: 'City Management', parentTitle: 'Platform', subParentTitle: 'Configuration' },
},
];
@@ -0,0 +1,19 @@
<app-data-table
[columns]="columns()"
[rows]="states()"
[actions]="actions()"
[loading]="loading()"
[totalRecords]="totalRecords()"
[pageIndex]="queryState.pageIndex()"
[pageSize]="queryState.pageSize()"
[pageSizeOptions]="[5, 10, 20, 50]"
title="States"
[showSearch]="true"
searchPlaceholder="Search states..."
[searchDebounceTime]="300"
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onActionClick($event)">
</app-data-table>
@@ -0,0 +1,181 @@
import { Component } from '@angular/core';
import { inject, signal } from '@angular/core';
import { StateService } from '../../../../../core/services/state/state.service';
import { DataTableQueryState } from '../../../../../shared/components/data-table/data-table-query.state';
import { DataTablePageEvent, DataTableSortEvent, DataTableQuery, DataTableColumn, DataTableAction, DataTableActionEvent } from '../../../../../shared/components/data-table/data-table.types';
import { DataTable } from '../../../../../shared/components/data-table/data-table';
import { finalize} from 'rxjs/operators';
@Component({
selector: 'state-list',
imports: [DataTable],
templateUrl: './state-list.html',
styleUrl: './state-list.scss',
})
export class StateList {
private readonly stateApi: StateService = inject(StateService);
readonly queryState = new DataTableQueryState();
readonly states = signal<any[]>([]);
readonly totalRecords = signal(0);
readonly filteredRecords = signal(0);
readonly loading = signal(false);
readonly columns = signal<DataTableColumn[]>([
{ key: 'serialNumber', label: 'Sr.No.', header: 'Sr.No.', sortable: false, width: '60px' },
{ key: 'name', header: 'Name', label: 'Name', sortable: true },
{ key: 'code', header: 'Code', label: 'Code', sortable: true },
{ 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',
width: '100px',
formatter: (value) => value ? 'Active' : 'Inactive'
}
]);
readonly actions = signal<DataTableAction[]>([
{
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',
visible: (row: any) => row.isActive
},
{
type: 'activate',
label: 'Activate',
icon: 'ti ti-check',
className: 'text-success',
visible: (row: any) => !row.isActive
}
]);
ngOnInit(): void {
this.loadStates(this.queryState.getQuery(), 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
}
loadStates(query: DataTableQuery, countryId: any): void {
this.loading.set(true);
this.stateApi
.getStateDataTable(query, countryId)
.pipe(
finalize(() => {
this.loading.set(false);
})
)
.subscribe({
next: (response: any) => {
console.log('State data loaded:', response);
if (response.draw !== this.queryState.getQuery().draw) {
return;
}
// Add serial numbers to states
const statesWithSerialNumbers = response.rows.map((state: any, index: number) => ({
...state,
serialNumber: (query.page - 1) * query.pageSize + index + 1
}));
this.states.set(statesWithSerialNumbers);
this.totalRecords.set(response.total);
this.filteredRecords.set(response.filtered);
},
error: (error: any) => {
console.error('Unable to load states.', error);
this.states.set([]);
this.totalRecords.set(0);
this.filteredRecords.set(0);
}
});
}
onSearch(value: string): void {
const query = this.queryState.setSearch(value.trim());
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
}
onPageChange(event: DataTablePageEvent): void {
const query = this.queryState.setPage(event);
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
}
onSortChange(event: DataTableSortEvent): void {
const query = this.queryState.setSort(event);
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
}
onRefresh(): void {
const currentQuery = this.queryState.getQuery();
const query: DataTableQuery = {
...currentQuery,
draw: currentQuery.draw + 1
};
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
}
onReset(): void {
const query = this.queryState.reset();
this.loadStates(query, 'a9d18090-f76a-489f-bfc7-9d97d3d2d7da');
}
onActionClick(event: DataTableActionEvent): void {
const action = event.action.type;
const state = event.row;
switch (action) {
case 'view':
this.viewState(state);
break;
case 'edit':
this.editState(state);
break;
case 'delete':
this.deleteState(state);
break;
case 'activate':
this.activateState(state);
break;
}
}
private viewState(state: any): void {
console.log('Viewing state:', state);
// TODO: Implement view logic (open modal, navigate to details page, etc.)
}
private editState(state: any): void {
console.log('Editing state:', state);
// TODO: Implement edit logic (open modal, navigate to edit page, etc.)
}
private deleteState(state: any): void {
console.log('Deleting state:', state);
// TODO: Implement delete logic (API call to delete state)
}
private activateState(state: any): void {
console.log('Activating state:', state);
// TODO: Implement activate logic (API call to activate state)
}
}
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const localizationRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/localization-list/localization-list').then((m) => m.LocalizationList),
data: { childTitle: 'Localization', parentTitle: 'Platform', subParentTitle: 'Translations' },
},
];
@@ -0,0 +1,4 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Localization</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Translation browser, editor, import/export, and missing translation reports will be added here.</p>
</div>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-localization-list',
standalone: true,
imports: [CommonModule],
templateUrl: './localization-list.html',
styleUrl: './localization-list.scss',
})
export class LocalizationList {}
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const monitoringRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/monitoring-dashboard/monitoring-dashboard').then((m) => m.MonitoringDashboard),
data: { childTitle: 'Monitoring', parentTitle: 'Platform', subParentTitle: 'Observability' },
},
];
@@ -0,0 +1,4 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Tenant counts, subscription status, login audits, and recent failed logins will be added here.</p>
</div>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-monitoring-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './monitoring-dashboard.html',
styleUrl: './monitoring-dashboard.scss',
})
export class MonitoringDashboard {}
@@ -0,0 +1,4 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Database connections, testing, and tenant assignments will be added here.</p>
</div>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-platform-list',
standalone: true,
imports: [CommonModule],
templateUrl: './platform-list.html',
styleUrl: './platform-list.scss',
})
export class PlatformList {}
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const platformRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/platform-list/platform-list').then((m) => m.PlatformList),
data: { childTitle: 'Platform', parentTitle: 'Platform', subParentTitle: 'Infrastructure' },
},
];
@@ -0,0 +1,53 @@
<app-data-table
title="Tenant List"
[columns]="columns"
[rows]="tenants"
[actions]="tableActions"
[loading]="loading"
emptyMessage="No tenants found"
[showSearch]="true"
searchPlaceholder="Search tenants..."
[searchDebounceTime]="300"
[totalRecords]="totalRecords"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 20, 50]"
[showPaginator]="true"
[allowedPermissions]="allowedPermissions"
tableHeadClass=""
tableBodyClass=""
trHeadClass="border-b border-defaultborder"
trBodyClass="border-b border-defaultborder hover:bg-light cursor-pointer"
defaultThClass="text-start"
defaultTdClass=""
actionHeaderClass="text-center"
actionCellClass="text-center"
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onTableAction($event)"
(rowClicked)="onRowClick($event)"
>
<ng-template appDataTableCell="tenantName" let-row let-value="value">
<div class="flex items-center">
<span class="avatar avatar-xs me-2 avatar-rounded">
<img [src]="row.logo" [alt]="value">
</span>
<span class="font-semibold">
{{ value }}
</span>
</div>
</ng-template>
</app-data-table>
@@ -0,0 +1,168 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DataTable } from '../../../../shared/components/data-table/data-table';
import { DataTableColumn, DataTableAction } from '../../../../shared/components/data-table/data-table.types';
import { DataTableQueryState } from '../../../../shared/components/data-table/data-table-query.state';
import { DataTablePageEvent, DataTableSortEvent } from '../../../../shared/components/data-table/data-table.types';
import { DataTableCellDirective } from '../../../../shared/directives/data-table-cell.directive';
@Component({
selector: 'tenant-list',
imports: [CommonModule, DataTable, DataTableCellDirective],
templateUrl: './tenant-list.html',
styleUrl: './tenant-list.scss',
})
export class TenantList {
tableQuery = new DataTableQueryState();
loading = false;
pageIndex = 1;
pageSize = 10;
totalRecords = 3;
searchText = '';
allowedPermissions: string[] = [
'tenant.view',
'tenant.edit',
'tenant.delete'
];
columns: DataTableColumn[] = [
{ key: 'id', header: 'Id', label: 'Id', sortable: true, headerClass: 'text-center', cellClass: 'text-center' },
{ key: 'tenantName', header: 'Tenant Name', label: 'Tenant Name', sortable: true },
{ key: 'companyCode', header: 'Company Code', label: 'Company Code', sortable: true },
{ key: 'email', header: 'Email', label: 'Email' },
{ key: 'country', header: 'Country', label: 'Country', sortable: true },
{
key: 'status',
header: 'Status',
label: 'Status',
sortable: true,
cellClass: 'text-center',
badge: true,
badgeClass: value =>
value === 'Active'
? 'badge bg-success/10 text-success'
: 'badge bg-danger/10 text-danger'
}
];
tenants = [
{
id: 1,
tenantName: 'Syscom Corporation',
companyCode: 'SYSCOM',
email: 'admin@syscom.com',
country: 'UAE',
status: 'Active',
logo: 'assets/images/brand-logos/erp-logo-icon.png'
},
{
id: 2,
tenantName: 'Biz360 Demo',
companyCode: 'BIZ360',
email: 'demo@biz360.com',
country: 'India',
status: 'Active',
logo:'assets/images/brand-logos/erp-logo-icon.png'
},
{
id: 3,
tenantName: 'Test Tenant',
companyCode: 'TEST',
email: 'test@test.com',
country: 'India',
status: 'Inactive'
}
];
tenants1 = [
{
id: 1,
tenantName: 'Syscom Corporation',
companyCode: 'SYSCOM',
email: 'admin@syscom.com',
country: 'UAE',
status: 'Active'
},
{
id: 2,
tenantName: 'Biz360 Demo',
companyCode: 'BIZ360',
email: 'demo@biz360.com',
country: 'India',
status: 'Active'
},
{
id: 3,
tenantName: 'Test Tenant',
companyCode: 'TEST',
email: 'test@test.com',
country: 'India',
status: 'Inactive'
}
];
tableActions: DataTableAction[] = [
{
type: 'download',
label: 'Download',
icon: 'ri-download-2-line !mb-0',
className: 'ti-btn ti-btn-sm ti-btn-success !rounded-full',
permission: 'tenant.view'
},
{
type: 'edit',
label: 'Edit',
icon: 'ri-edit-line !mb-0',
className: 'ti-btn ti-btn-sm ti-btn-info !rounded-full',
permission: 'tenant.edit'
},
{
type: 'delete',
label: 'Delete',
icon: 'ri-delete-bin-line',
className: 'ti-btn ti-btn-sm ti-btn-danger !rounded-full',
permission: 'tenant.delete',
visible: row => row.status !== 'Active'
}
];
onSearch(searchText: string): void {
this.searchText = searchText;
this.pageIndex = 1;
if (searchText.trim() === '') {
this.tenants = [...this.tenants1];
} else {
this.tenants = this.tenants.filter(tenant =>
tenant.tenantName.toLowerCase().includes(searchText.toLowerCase()) ||
tenant.companyCode.toLowerCase().includes(searchText.toLowerCase()) ||
tenant.email.toLowerCase().includes(searchText.toLowerCase()) ||
tenant.country.toLowerCase().includes(searchText.toLowerCase()) ||
tenant.status.toLowerCase().includes(searchText.toLowerCase())
);
}
}
onPageChange(event: DataTablePageEvent): void {
const query = this.tableQuery.setPage(event);
//this.loadTenants(query);
}
onSortChange(event: DataTableSortEvent): void {
const query = this.tableQuery.setSort(event);
//this.loadTenants(query);
}
onTableAction(event: any): void {
console.log('Action:', event.action.type, event.row);
}
onRowClick(row: any): void {
console.log('Row clicked:', row);
}
}
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const tenantsRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/tenant-list/tenant-list').then((m) => m.TenantList),
data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' },
},
];
@@ -0,0 +1,4 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Theming</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Tenant theme editor, colors, fonts, logos, and theme preview will be implemented here.</p>
</div>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-theming-list',
standalone: true,
imports: [CommonModule],
templateUrl: './theming-list.html',
styleUrl: './theming-list.scss',
})
export class ThemingList {}
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const themingRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/theming-list/theming-list').then((m) => m.ThemingList),
data: { childTitle: 'Theming', parentTitle: 'Platform', subParentTitle: 'Branding' },
},
];
@@ -0,0 +1,4 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform users</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Users, roles, assignment, deactivation, and password reset will be implemented here.</p>
</div>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-users-list',
standalone: true,
imports: [CommonModule],
templateUrl: './users-list.html',
styleUrl: './users-list.scss',
})
export class UsersList {}
+9
View File
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const usersRoutes: Routes = [
{
path: '',
loadComponent: () => import('./pages/users-list/users-list').then((m) => m.UsersList),
data: { childTitle: 'Platform Users', parentTitle: 'Platform', subParentTitle: 'Security' },
},
];
@@ -0,0 +1,56 @@
<button
[type]="resolvedType()"
[class]="buttonClass()"
[disabled]="isDisabled()"
[attr.aria-label]="
ariaLabel() ||
resolvedLabel() ||
title() ||
'Button'
"
[attr.aria-busy]="loading() ? true : null"
(click)="onClick($event)"
>
<span class="inline-flex items-center justify-center gap-1">
@if (loading()) {
<span
class="inline-block size-4 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
></span>
}
@if (
!loading() &&
showIcon() &&
resolvedIcon() &&
iconPosition() === 'left'
) {
<i
[class]="resolvedIconClass()"
aria-hidden="true"
></i>
}
@if (!iconOnly()) {
<span>
@if (loading() && loadingLabel()) {
{{ loadingLabel() }}
} @else {
{{ resolvedLabel() }}
}
</span>
}
@if (
!loading() &&
showIcon() &&
resolvedIcon() &&
iconPosition() === 'right'
) {
<i
[class]="resolvedIconClass()"
aria-hidden="true"
></i>
}
</span>
</button>

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