mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
14 changed files with 556 additions and 361 deletions
@ -0,0 +1,150 @@ |
|||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service'; |
||||
|
import { CacheService } from '@ghostfolio/client/services/cache.service'; |
||||
|
import { DataService } from '@ghostfolio/client/services/data.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; |
||||
|
import { User } from '@ghostfolio/common/interfaces'; |
||||
|
import { |
||||
|
differenceInSeconds, |
||||
|
formatDistanceToNowStrict, |
||||
|
isValid, |
||||
|
parseISO |
||||
|
} from 'date-fns'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
import { takeUntil } from 'rxjs/operators'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'gf-admin-overview', |
||||
|
styleUrls: ['./admin-overview.scss'], |
||||
|
templateUrl: './admin-overview.html' |
||||
|
}) |
||||
|
export class AdminOverviewComponent implements OnDestroy, OnInit { |
||||
|
public dataGatheringInProgress: boolean; |
||||
|
public dataGatheringProgress: number; |
||||
|
public defaultDateFormat = DEFAULT_DATE_FORMAT; |
||||
|
public exchangeRates: { label1: string; label2: string; value: number }[]; |
||||
|
public lastDataGathering: string; |
||||
|
public transactionCount: number; |
||||
|
public userCount: number; |
||||
|
public user: User; |
||||
|
|
||||
|
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
|
/** |
||||
|
* @constructor |
||||
|
*/ |
||||
|
public constructor( |
||||
|
private adminService: AdminService, |
||||
|
private cacheService: CacheService, |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private dataService: DataService, |
||||
|
private userService: UserService |
||||
|
) {} |
||||
|
|
||||
|
/** |
||||
|
* Initializes the controller |
||||
|
*/ |
||||
|
public ngOnInit() { |
||||
|
this.fetchAdminData(); |
||||
|
|
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onFlushCache() { |
||||
|
this.cacheService |
||||
|
.flush() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(() => { |
||||
|
setTimeout(() => { |
||||
|
window.location.reload(); |
||||
|
}, 300); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onGatherMax() { |
||||
|
const confirmation = confirm( |
||||
|
'This action may take some time. Do you want to proceed?' |
||||
|
); |
||||
|
|
||||
|
if (confirmation === true) { |
||||
|
this.adminService |
||||
|
.gatherMax() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(() => { |
||||
|
setTimeout(() => { |
||||
|
window.location.reload(); |
||||
|
}, 300); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public onGatherProfileData() { |
||||
|
this.adminService |
||||
|
.gatherProfileData() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(() => {}); |
||||
|
} |
||||
|
|
||||
|
public formatDistanceToNow(aDateString: string) { |
||||
|
if (aDateString) { |
||||
|
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), { |
||||
|
addSuffix: true |
||||
|
}); |
||||
|
|
||||
|
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) < |
||||
|
60 |
||||
|
? 'just now' |
||||
|
: distanceString; |
||||
|
} |
||||
|
|
||||
|
return ''; |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.unsubscribeSubject.next(); |
||||
|
this.unsubscribeSubject.complete(); |
||||
|
} |
||||
|
|
||||
|
private fetchAdminData() { |
||||
|
this.dataService |
||||
|
.fetchAdminData() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe( |
||||
|
({ |
||||
|
dataGatheringProgress, |
||||
|
exchangeRates, |
||||
|
lastDataGathering, |
||||
|
transactionCount, |
||||
|
userCount |
||||
|
}) => { |
||||
|
this.dataGatheringProgress = dataGatheringProgress; |
||||
|
this.exchangeRates = exchangeRates; |
||||
|
|
||||
|
if (isValid(parseISO(lastDataGathering?.toString()))) { |
||||
|
this.lastDataGathering = formatDistanceToNowStrict( |
||||
|
new Date(lastDataGathering), |
||||
|
{ |
||||
|
addSuffix: true |
||||
|
} |
||||
|
); |
||||
|
} else if (lastDataGathering === 'IN_PROGRESS') { |
||||
|
this.dataGatheringInProgress = true; |
||||
|
} else { |
||||
|
this.lastDataGathering = 'Starting soon...'; |
||||
|
} |
||||
|
|
||||
|
this.transactionCount = transactionCount; |
||||
|
this.userCount = userCount; |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
} |
@ -0,0 +1,107 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="mb-5 row"> |
||||
|
<div class="col"> |
||||
|
<mat-card class="mb-3"> |
||||
|
<mat-card-content> |
||||
|
<div |
||||
|
*ngIf="exchangeRates?.length > 0" |
||||
|
class="align-items-start d-flex my-3" |
||||
|
> |
||||
|
<div class="w-50" i18n>Exchange Rates</div> |
||||
|
<div class="w-50"> |
||||
|
<table> |
||||
|
<tr *ngFor="let exchangeRate of exchangeRates"> |
||||
|
<td class="d-flex"> |
||||
|
<gf-value |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[value]="1" |
||||
|
></gf-value> |
||||
|
</td> |
||||
|
<td class="pl-1">{{ exchangeRate.label1 }}</td> |
||||
|
<td class="px-1">=</td> |
||||
|
<td class="d-flex justify-content-end"> |
||||
|
<gf-value |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[precision]="4" |
||||
|
[value]="exchangeRate.value" |
||||
|
></gf-value> |
||||
|
</td> |
||||
|
<td class="pl-1">{{ exchangeRate.label2 }}</td> |
||||
|
</tr> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="d-flex my-3"> |
||||
|
<div class="w-50" i18n>Data Gathering</div> |
||||
|
<div class="w-50"> |
||||
|
<div> |
||||
|
<ng-container *ngIf="lastDataGathering" |
||||
|
>{{ lastDataGathering }}</ng-container |
||||
|
> |
||||
|
<ng-container *ngIf="dataGatheringInProgress" i18n |
||||
|
>In Progress ({{ dataGatheringProgress | percent : '1.2-2' |
||||
|
}})</ng-container |
||||
|
> |
||||
|
</div> |
||||
|
<div class="mt-2 overflow-hidden"> |
||||
|
<div class="mb-2"> |
||||
|
<button |
||||
|
class="mw-100" |
||||
|
color="accent" |
||||
|
mat-flat-button |
||||
|
(click)="onFlushCache()" |
||||
|
> |
||||
|
<ion-icon |
||||
|
class="mr-1" |
||||
|
name="close-circle-outline" |
||||
|
></ion-icon> |
||||
|
<span i18n>Reset Data Gathering</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="mb-2"> |
||||
|
<button |
||||
|
class="mw-100" |
||||
|
color="warn" |
||||
|
mat-flat-button |
||||
|
[disabled]="dataGatheringInProgress" |
||||
|
(click)="onGatherMax()" |
||||
|
> |
||||
|
<ion-icon class="mr-1" name="warning-outline"></ion-icon> |
||||
|
<span i18n>Gather All Data</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div> |
||||
|
<button |
||||
|
class="mb-2 mr-2 mw-100" |
||||
|
color="accent" |
||||
|
mat-flat-button |
||||
|
(click)="onGatherProfileData()" |
||||
|
> |
||||
|
<ion-icon |
||||
|
class="mr-1" |
||||
|
name="cloud-download-outline" |
||||
|
></ion-icon> |
||||
|
<span i18n>Gather Profile Data</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="d-flex my-3"> |
||||
|
<div class="w-50" i18n>User Count</div> |
||||
|
<div class="w-50">{{ userCount }}</div> |
||||
|
</div> |
||||
|
<div class="d-flex my-3"> |
||||
|
<div class="w-50" i18n>Transaction Count</div> |
||||
|
<div class="w-50"> |
||||
|
<ng-container *ngIf="transactionCount"> |
||||
|
{{ transactionCount }} ({{ transactionCount / userCount | number |
||||
|
: '1.2-2' }} <span i18n>per User</span>) |
||||
|
</ng-container> |
||||
|
</div> |
||||
|
</div> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
@ -0,0 +1,17 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatCardModule } from '@angular/material/card'; |
||||
|
import { CacheService } from '@ghostfolio/client/services/cache.service'; |
||||
|
import { GfValueModule } from '@ghostfolio/ui/value'; |
||||
|
|
||||
|
import { AdminOverviewComponent } from './admin-overview.component'; |
||||
|
|
||||
|
@NgModule({ |
||||
|
declarations: [AdminOverviewComponent], |
||||
|
exports: [], |
||||
|
imports: [CommonModule, GfValueModule, MatButtonModule, MatCardModule], |
||||
|
providers: [CacheService], |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
||||
|
}) |
||||
|
export class AdminOverviewModule {} |
@ -0,0 +1,17 @@ |
|||||
|
@import '~apps/client/src/styles/ghostfolio-style'; |
||||
|
|
||||
|
:host { |
||||
|
display: block; |
||||
|
|
||||
|
.mat-flat-button { |
||||
|
::ng-deep { |
||||
|
.mat-button-wrapper { |
||||
|
display: block; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
width: 100%; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,82 @@ |
|||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
||||
|
import { DataService } from '@ghostfolio/client/services/data.service'; |
||||
|
import { AdminData } from '@ghostfolio/common/interfaces'; |
||||
|
import { |
||||
|
differenceInSeconds, |
||||
|
formatDistanceToNowStrict, |
||||
|
parseISO |
||||
|
} from 'date-fns'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
import { takeUntil } from 'rxjs/operators'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'gf-admin-users', |
||||
|
styleUrls: ['./admin-users.scss'], |
||||
|
templateUrl: './admin-users.html' |
||||
|
}) |
||||
|
export class AdminUsersComponent implements OnDestroy, OnInit { |
||||
|
public users: AdminData['users']; |
||||
|
|
||||
|
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
|
/** |
||||
|
* @constructor |
||||
|
*/ |
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private dataService: DataService |
||||
|
) {} |
||||
|
|
||||
|
/** |
||||
|
* Initializes the controller |
||||
|
*/ |
||||
|
public ngOnInit() { |
||||
|
this.fetchAdminData(); |
||||
|
} |
||||
|
|
||||
|
public formatDistanceToNow(aDateString: string) { |
||||
|
if (aDateString) { |
||||
|
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), { |
||||
|
addSuffix: true |
||||
|
}); |
||||
|
|
||||
|
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) < |
||||
|
60 |
||||
|
? 'just now' |
||||
|
: distanceString; |
||||
|
} |
||||
|
|
||||
|
return ''; |
||||
|
} |
||||
|
|
||||
|
public onDeleteUser(aId: string) { |
||||
|
const confirmation = confirm('Do you really want to delete this user?'); |
||||
|
|
||||
|
if (confirmation) { |
||||
|
this.dataService |
||||
|
.deleteUser(aId) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe({ |
||||
|
next: () => { |
||||
|
this.fetchAdminData(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.unsubscribeSubject.next(); |
||||
|
this.unsubscribeSubject.complete(); |
||||
|
} |
||||
|
|
||||
|
private fetchAdminData() { |
||||
|
this.dataService |
||||
|
.fetchAdminData() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(({ users }) => { |
||||
|
this.users = users; |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,86 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<div class="users"> |
||||
|
<table class="gf-table"> |
||||
|
<thead> |
||||
|
<tr class="mat-header-row"> |
||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th> |
||||
|
<th class="mat-header-cell px-1 py-2" i18n>User</th> |
||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n> |
||||
|
Registration |
||||
|
</th> |
||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n> |
||||
|
Accounts |
||||
|
</th> |
||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n> |
||||
|
Transactions |
||||
|
</th> |
||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n> |
||||
|
Engagement per Day |
||||
|
</th> |
||||
|
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th> |
||||
|
<th class="mat-header-cell px-1 py-2"></th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
<tr *ngFor="let userItem of users; let i = index" class="mat-row"> |
||||
|
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td> |
||||
|
<td class="mat-cell px-1 py-2"> |
||||
|
<div class="d-flex align-items-center"> |
||||
|
<span class="d-none d-sm-inline-block" |
||||
|
>{{ userItem.alias || userItem.id }}</span |
||||
|
> |
||||
|
<span class="d-inline-block d-sm-none" |
||||
|
>{{ userItem.alias || (userItem.id | slice:0:5) + |
||||
|
'...' }}</span |
||||
|
> |
||||
|
<ion-icon |
||||
|
*ngIf="userItem?.subscription?.type === 'Premium'" |
||||
|
class="ml-1 text-muted" |
||||
|
name="diamond-outline" |
||||
|
></ion-icon> |
||||
|
</div> |
||||
|
</td> |
||||
|
<td class="mat-cell px-1 py-2 text-right"> |
||||
|
{{ formatDistanceToNow(userItem.createdAt) }} |
||||
|
</td> |
||||
|
<td class="mat-cell px-1 py-2 text-right"> |
||||
|
{{ userItem.accountCount }} |
||||
|
</td> |
||||
|
<td class="mat-cell px-1 py-2 text-right"> |
||||
|
{{ userItem.transactionCount }} |
||||
|
</td> |
||||
|
<td class="mat-cell px-1 py-2 text-right"> |
||||
|
{{ userItem.engagement | number: '1.0-0' }} |
||||
|
</td> |
||||
|
<td class="mat-cell px-1 py-2"> |
||||
|
{{ formatDistanceToNow(userItem.lastActivity) }} |
||||
|
</td> |
||||
|
<td class="mat-cell px-1 py-2"> |
||||
|
<button |
||||
|
class="mx-1 no-min-width px-2" |
||||
|
mat-button |
||||
|
[matMenuTriggerFor]="accountMenu" |
||||
|
(click)="$event.stopPropagation()" |
||||
|
> |
||||
|
<ion-icon name="ellipsis-vertical"></ion-icon> |
||||
|
</button> |
||||
|
<mat-menu #accountMenu="matMenu" xPosition="before"> |
||||
|
<button |
||||
|
i18n |
||||
|
mat-menu-item |
||||
|
[disabled]="userItem.id === user?.id" |
||||
|
(click)="onDeleteUser(userItem.id)" |
||||
|
> |
||||
|
Delete |
||||
|
</button> |
||||
|
</mat-menu> |
||||
|
</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
@ -0,0 +1,14 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatMenuModule } from '@angular/material/menu'; |
||||
|
|
||||
|
import { AdminUsersComponent } from './admin-users.component'; |
||||
|
|
||||
|
@NgModule({ |
||||
|
declarations: [AdminUsersComponent], |
||||
|
exports: [], |
||||
|
imports: [CommonModule, MatButtonModule, MatMenuModule], |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
||||
|
}) |
||||
|
export class AdminUsersModule {} |
@ -0,0 +1,18 @@ |
|||||
|
@import '~apps/client/src/styles/ghostfolio-style'; |
||||
|
|
||||
|
:host { |
||||
|
display: block; |
||||
|
|
||||
|
.users { |
||||
|
overflow-x: auto; |
||||
|
|
||||
|
table { |
||||
|
min-width: 100%; |
||||
|
|
||||
|
.mat-row, |
||||
|
.mat-header-row { |
||||
|
width: 100%; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,169 +1,26 @@ |
|||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
import { Component, OnDestroy, OnInit } from '@angular/core'; |
||||
import { AdminService } from '@ghostfolio/client/services/admin.service'; |
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service'; |
|
||||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; |
|
||||
import { AdminData, User } from '@ghostfolio/common/interfaces'; |
|
||||
import { |
|
||||
differenceInSeconds, |
|
||||
formatDistanceToNowStrict, |
|
||||
isValid, |
|
||||
parseISO |
|
||||
} from 'date-fns'; |
|
||||
import { Subject } from 'rxjs'; |
import { Subject } from 'rxjs'; |
||||
import { takeUntil } from 'rxjs/operators'; |
|
||||
|
|
||||
@Component({ |
@Component({ |
||||
host: { class: 'mb-5' }, |
|
||||
selector: 'gf-admin-page', |
selector: 'gf-admin-page', |
||||
styleUrls: ['./admin-page.scss'], |
styleUrls: ['./admin-page.scss'], |
||||
templateUrl: './admin-page.html' |
templateUrl: './admin-page.html' |
||||
}) |
}) |
||||
export class AdminPageComponent implements OnDestroy, OnInit { |
export class AdminPageComponent implements OnDestroy, OnInit { |
||||
public dataGatheringInProgress: boolean; |
|
||||
public dataGatheringProgress: number; |
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT; |
|
||||
public exchangeRates: { label1: string; label2: string; value: number }[]; |
|
||||
public lastDataGathering: string; |
|
||||
public transactionCount: number; |
|
||||
public userCount: number; |
|
||||
public user: User; |
|
||||
public users: AdminData['users']; |
|
||||
|
|
||||
private unsubscribeSubject = new Subject<void>(); |
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
/** |
/** |
||||
* @constructor |
* @constructor |
||||
*/ |
*/ |
||||
public constructor( |
public constructor() {} |
||||
private adminService: AdminService, |
|
||||
private cacheService: CacheService, |
|
||||
private changeDetectorRef: ChangeDetectorRef, |
|
||||
private dataService: DataService, |
|
||||
private userService: UserService |
|
||||
) {} |
|
||||
|
|
||||
/** |
/** |
||||
* Initializes the controller |
* Initializes the controller |
||||
*/ |
*/ |
||||
public ngOnInit() { |
public ngOnInit() {} |
||||
this.fetchAdminData(); |
|
||||
|
|
||||
this.userService.stateChanged |
|
||||
.pipe(takeUntil(this.unsubscribeSubject)) |
|
||||
.subscribe((state) => { |
|
||||
if (state?.user) { |
|
||||
this.user = state.user; |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
public onFlushCache() { |
|
||||
this.cacheService |
|
||||
.flush() |
|
||||
.pipe(takeUntil(this.unsubscribeSubject)) |
|
||||
.subscribe(() => { |
|
||||
setTimeout(() => { |
|
||||
window.location.reload(); |
|
||||
}, 300); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
public onGatherMax() { |
|
||||
const confirmation = confirm( |
|
||||
'This action may take some time. Do you want to proceed?' |
|
||||
); |
|
||||
|
|
||||
if (confirmation === true) { |
|
||||
this.adminService |
|
||||
.gatherMax() |
|
||||
.pipe(takeUntil(this.unsubscribeSubject)) |
|
||||
.subscribe(() => { |
|
||||
setTimeout(() => { |
|
||||
window.location.reload(); |
|
||||
}, 300); |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public onGatherProfileData() { |
|
||||
this.adminService |
|
||||
.gatherProfileData() |
|
||||
.pipe(takeUntil(this.unsubscribeSubject)) |
|
||||
.subscribe(() => {}); |
|
||||
} |
|
||||
|
|
||||
public formatDistanceToNow(aDateString: string) { |
|
||||
if (aDateString) { |
|
||||
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), { |
|
||||
addSuffix: true |
|
||||
}); |
|
||||
|
|
||||
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) < |
|
||||
60 |
|
||||
? 'just now' |
|
||||
: distanceString; |
|
||||
} |
|
||||
|
|
||||
return ''; |
|
||||
} |
|
||||
|
|
||||
public onDeleteUser(aId: string) { |
|
||||
const confirmation = confirm('Do you really want to delete this user?'); |
|
||||
|
|
||||
if (confirmation) { |
|
||||
this.dataService |
|
||||
.deleteUser(aId) |
|
||||
.pipe(takeUntil(this.unsubscribeSubject)) |
|
||||
.subscribe({ |
|
||||
next: () => { |
|
||||
this.fetchAdminData(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public ngOnDestroy() { |
public ngOnDestroy() { |
||||
this.unsubscribeSubject.next(); |
this.unsubscribeSubject.next(); |
||||
this.unsubscribeSubject.complete(); |
this.unsubscribeSubject.complete(); |
||||
} |
} |
||||
|
|
||||
private fetchAdminData() { |
|
||||
this.dataService |
|
||||
.fetchAdminData() |
|
||||
.pipe(takeUntil(this.unsubscribeSubject)) |
|
||||
.subscribe( |
|
||||
({ |
|
||||
dataGatheringProgress, |
|
||||
exchangeRates, |
|
||||
lastDataGathering, |
|
||||
transactionCount, |
|
||||
userCount, |
|
||||
users |
|
||||
}) => { |
|
||||
this.dataGatheringProgress = dataGatheringProgress; |
|
||||
this.exchangeRates = exchangeRates; |
|
||||
this.users = users; |
|
||||
|
|
||||
if (isValid(parseISO(lastDataGathering?.toString()))) { |
|
||||
this.lastDataGathering = formatDistanceToNowStrict( |
|
||||
new Date(lastDataGathering), |
|
||||
{ |
|
||||
addSuffix: true |
|
||||
} |
|
||||
); |
|
||||
} else if (lastDataGathering === 'IN_PROGRESS') { |
|
||||
this.dataGatheringInProgress = true; |
|
||||
} else { |
|
||||
this.lastDataGathering = 'Starting soon...'; |
|
||||
} |
|
||||
|
|
||||
this.transactionCount = transactionCount; |
|
||||
this.userCount = userCount; |
|
||||
|
|
||||
this.changeDetectorRef.markForCheck(); |
|
||||
} |
|
||||
); |
|
||||
} |
|
||||
} |
} |
||||
|
@ -1,195 +1,17 @@ |
|||||
<div class="container"> |
<router-outlet></router-outlet> |
||||
<div class="mb-5 row"> |
|
||||
<div class="col"> |
<nav mat-align-tabs="center" mat-tab-nav-bar> |
||||
<h3 class="d-flex justify-content-center mb-3" i18n> |
<a |
||||
Admin Control Panel |
*ngFor="let link of [ |
||||
</h3> |
{ iconName: 'reader-outline', path: 'overview' }, |
||||
<mat-card class="mb-3"> |
{ iconName: 'people-outline', path: 'users' } |
||||
<mat-card-content> |
]" |
||||
<div |
#rla="routerLinkActive" |
||||
*ngIf="exchangeRates?.length > 0" |
mat-tab-link |
||||
class="align-items-start d-flex my-3" |
routerLinkActive |
||||
> |
[active]="rla.isActive" |
||||
<div class="w-50" i18n>Exchange Rates</div> |
[routerLink]="link.path" |
||||
<div class="w-50"> |
> |
||||
<table> |
<ion-icon size="large" [name]="link.iconName"></ion-icon> |
||||
<tr *ngFor="let exchangeRate of exchangeRates"> |
</a> |
||||
<td class="d-flex"> |
</nav> |
||||
<gf-value |
|
||||
[locale]="user?.settings?.locale" |
|
||||
[value]="1" |
|
||||
></gf-value> |
|
||||
</td> |
|
||||
<td class="pl-1">{{ exchangeRate.label1 }}</td> |
|
||||
<td class="px-1">=</td> |
|
||||
<td class="d-flex justify-content-end"> |
|
||||
<gf-value |
|
||||
[locale]="user?.settings?.locale" |
|
||||
[precision]="4" |
|
||||
[value]="exchangeRate.value" |
|
||||
></gf-value> |
|
||||
</td> |
|
||||
<td class="pl-1">{{ exchangeRate.label2 }}</td> |
|
||||
</tr> |
|
||||
</table> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="d-flex my-3"> |
|
||||
<div class="w-50" i18n>Data Gathering</div> |
|
||||
<div class="w-50"> |
|
||||
<div> |
|
||||
<ng-container *ngIf="lastDataGathering" |
|
||||
>{{ lastDataGathering }}</ng-container |
|
||||
> |
|
||||
<ng-container *ngIf="dataGatheringInProgress" i18n |
|
||||
>In Progress ({{ dataGatheringProgress | percent : '1.2-2' |
|
||||
}})</ng-container |
|
||||
> |
|
||||
</div> |
|
||||
<div class="mt-2 overflow-hidden"> |
|
||||
<div class="mb-2"> |
|
||||
<button |
|
||||
class="mw-100" |
|
||||
color="accent" |
|
||||
mat-flat-button |
|
||||
(click)="onFlushCache()" |
|
||||
> |
|
||||
<ion-icon |
|
||||
class="mr-1" |
|
||||
name="close-circle-outline" |
|
||||
></ion-icon> |
|
||||
<span i18n>Reset Data Gathering</span> |
|
||||
</button> |
|
||||
</div> |
|
||||
<div class="mb-2"> |
|
||||
<button |
|
||||
class="mw-100" |
|
||||
color="warn" |
|
||||
mat-flat-button |
|
||||
[disabled]="dataGatheringInProgress" |
|
||||
(click)="onGatherMax()" |
|
||||
> |
|
||||
<ion-icon class="mr-1" name="warning-outline"></ion-icon> |
|
||||
<span i18n>Gather All Data</span> |
|
||||
</button> |
|
||||
</div> |
|
||||
<div> |
|
||||
<button |
|
||||
class="mb-2 mr-2 mw-100" |
|
||||
color="accent" |
|
||||
mat-flat-button |
|
||||
(click)="onGatherProfileData()" |
|
||||
> |
|
||||
<ion-icon |
|
||||
class="mr-1" |
|
||||
name="cloud-download-outline" |
|
||||
></ion-icon> |
|
||||
<span i18n>Gather Profile Data</span> |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="d-flex my-3"> |
|
||||
<div class="w-50" i18n>User Count</div> |
|
||||
<div class="w-50">{{ userCount }}</div> |
|
||||
</div> |
|
||||
<div class="d-flex my-3"> |
|
||||
<div class="w-50" i18n>Transaction Count</div> |
|
||||
<div class="w-50"> |
|
||||
<ng-container *ngIf="transactionCount"> |
|
||||
{{ transactionCount }} ({{ transactionCount / userCount | number |
|
||||
: '1.2-2' }} <span i18n>per User</span>) |
|
||||
</ng-container> |
|
||||
</div> |
|
||||
</div> |
|
||||
</mat-card-content> |
|
||||
</mat-card> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="row"> |
|
||||
<div class="col"> |
|
||||
<h3 class="mb-3 text-center" i18n>Users</h3> |
|
||||
<div class="users"> |
|
||||
<table class="gf-table"> |
|
||||
<thead> |
|
||||
<tr class="mat-header-row"> |
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th> |
|
||||
<th class="mat-header-cell px-1 py-2" i18n>User</th> |
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n> |
|
||||
Registration |
|
||||
</th> |
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n> |
|
||||
Accounts |
|
||||
</th> |
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n> |
|
||||
Transactions |
|
||||
</th> |
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n> |
|
||||
Engagement per Day |
|
||||
</th> |
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th> |
|
||||
<th class="mat-header-cell px-1 py-2"></th> |
|
||||
</tr> |
|
||||
</thead> |
|
||||
<tbody> |
|
||||
<tr *ngFor="let userItem of users; let i = index" class="mat-row"> |
|
||||
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td> |
|
||||
<td class="mat-cell px-1 py-2"> |
|
||||
<div class="d-flex align-items-center"> |
|
||||
<span class="d-none d-sm-inline-block" |
|
||||
>{{ userItem.alias || userItem.id }}</span |
|
||||
> |
|
||||
<span class="d-inline-block d-sm-none" |
|
||||
>{{ userItem.alias || (userItem.id | slice:0:5) + |
|
||||
'...' }}</span |
|
||||
> |
|
||||
<ion-icon |
|
||||
*ngIf="userItem?.subscription?.type === 'Premium'" |
|
||||
class="ml-1 text-muted" |
|
||||
name="diamond-outline" |
|
||||
></ion-icon> |
|
||||
</div> |
|
||||
</td> |
|
||||
<td class="mat-cell px-1 py-2 text-right"> |
|
||||
{{ formatDistanceToNow(userItem.createdAt) }} |
|
||||
</td> |
|
||||
<td class="mat-cell px-1 py-2 text-right"> |
|
||||
{{ userItem.accountCount }} |
|
||||
</td> |
|
||||
<td class="mat-cell px-1 py-2 text-right"> |
|
||||
{{ userItem.transactionCount }} |
|
||||
</td> |
|
||||
<td class="mat-cell px-1 py-2 text-right"> |
|
||||
{{ userItem.engagement | number: '1.0-0' }} |
|
||||
</td> |
|
||||
<td class="mat-cell px-1 py-2"> |
|
||||
{{ formatDistanceToNow(userItem.lastActivity) }} |
|
||||
</td> |
|
||||
<td class="mat-cell px-1 py-2"> |
|
||||
<button |
|
||||
class="mx-1 no-min-width px-2" |
|
||||
mat-button |
|
||||
[matMenuTriggerFor]="accountMenu" |
|
||||
(click)="$event.stopPropagation()" |
|
||||
> |
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon> |
|
||||
</button> |
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before"> |
|
||||
<button |
|
||||
i18n |
|
||||
mat-menu-item |
|
||||
[disabled]="userItem.id === user?.id" |
|
||||
(click)="onDeleteUser(userItem.id)" |
|
||||
> |
|
||||
Delete |
|
||||
</button> |
|
||||
</mat-menu> |
|
||||
</td> |
|
||||
</tr> |
|
||||
</tbody> |
|
||||
</table> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
Loading…
Reference in new issue