Browse Source

Add account detail dialog

pull/1047/head
Thomas 3 years ago
parent
commit
4c10e2cb73
  1. 33
      apps/api/src/app/order/order.controller.ts
  2. 3
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.scss
  3. 112
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  4. 65
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  5. 29
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts
  6. 5
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  7. 9
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  8. 10
      apps/client/src/app/components/accounts-table/accounts-table.component.ts
  9. 32
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  10. 1
      apps/client/src/app/pages/accounts/accounts-page.html
  11. 4
      apps/client/src/app/pages/accounts/accounts-page.module.ts
  12. 14
      apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts
  13. 83
      apps/client/src/app/services/data.service.ts
  14. 3
      libs/common/src/lib/types/account-with-value.type.ts

33
apps/api/src/app/order/order.controller.ts

@ -4,6 +4,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -17,6 +18,7 @@ import {
Param,
Post,
Put,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
@ -66,8 +68,36 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId
@Headers('impersonation-id') impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Activities> {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
@ -76,6 +106,7 @@ export class OrderController {
const userCurrency = this.request.user.Settings.currency;
let activities = await this.orderService.getOrders({
filters,
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id

3
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

112
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -0,0 +1,112 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AccountType } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'd-flex flex-column h-100' },
selector: 'gf-account-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'account-detail-dialog.html',
styleUrls: ['./account-detail-dialog.component.scss']
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: AccountType;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
public user: User;
public valueInBaseCurrency: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit(): void {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
this.accountType = accountType;
this.name = name;
this.platformName = Platform?.name;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.orders = activities;
this.changeDetectorRef.markForCheck();
});
}
public onClose(): void {
this.dialogRef.close();
}
public onExport() {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
})
)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${this.name
.replace(/\s+/g, '-')
.toLowerCase()}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
format: 'json'
});
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

65
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -0,0 +1,65 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="name"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<div class="container p-0">
<div class="row">
<div class="col-12 d-flex justify-content-center mb-3">
<gf-value
size="large"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="valueInBaseCurrency"
></gf-value>
</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value
label="Account Type"
size="medium"
[value]="accountType"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Platform"
size="medium"
[value]="platformName"
></gf-value>
</div>
</div>
<div *ngIf="orders?.length > 0" class="row">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<gf-activities-table
[activities]="orders"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"
[showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

29
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts

@ -0,0 +1,29 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AccountDetailDialog } from './account-detail-dialog.component';
@NgModule({
declarations: [AccountDetailDialog],
exports: [],
imports: [
CommonModule,
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
NgxSkeletonLoaderModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAccountDetailDialogModule {}

5
apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts

@ -0,0 +1,5 @@
export interface AccountDetailDialogParams {
accountId: string;
deviceType: string;
hasImpersonationId: boolean;
}

9
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -65,7 +65,7 @@
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<span class="d-block d-sm-none">#</span>
<span class="d-none d-sm-block" i18n>Transactions</span>
<span class="d-none d-sm-block" i18n>Activities</span>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{
@ -212,7 +212,12 @@
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
[ngClass]="{ 'cursor-pointer': hasPermissionToShowValues }"
(click)="hasPermissionToShowValues && onOpenAccountDetailDialog(row.id)"
></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row

10
apps/client/src/app/components/accounts-table/accounts-table.component.ts

@ -9,6 +9,7 @@ import {
Output
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@ -22,6 +23,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() accounts: AccountModel[];
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToShowValues = true;
@Input() locale: string;
@Input() showActions: boolean;
@Input() totalBalanceInBaseCurrency: number;
@ -39,7 +41,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public constructor(private router: Router) {}
public ngOnInit() {}
@ -75,6 +77,12 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
}
}
public onOpenAccountDetailDialog(accountId: string) {
this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true }
});
}
public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount);
}

32
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -3,6 +3,8 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -27,6 +29,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean;
public hasPermissionToCreateAccount: boolean;
public hasPermissionToDeleteAccount: boolean;
public hasPermissionToShowValues: boolean;
public routeQueryParams: Subscription;
public totalBalanceInBaseCurrency = 0;
public totalValueInBaseCurrency = 0;
@ -48,7 +51,12 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog'] && this.hasPermissionToCreateAccount) {
if (params['accountId'] && params['accountDetailDialog']) {
this.openAccountDetailDialog(params['accountId']);
} else if (
params['createDialog'] &&
this.hasPermissionToCreateAccount
) {
this.openCreateAccountDialog();
} else if (params['editDialog']) {
if (this.accounts) {
@ -72,6 +80,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
this.hasPermissionToShowValues = !this.hasImpersonationId;
});
this.userService.stateChanged
@ -197,6 +207,26 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openAccountDetailDialog(aAccountId) {
const dialogRef = this.dialog.open(AccountDetailDialog, {
autoFocus: false,
data: <AccountDetailDialogParams>{
accountId: aAccountId,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openCreateAccountDialog(): void {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: {

1
apps/client/src/app/pages/accounts/accounts-page.html

@ -7,6 +7,7 @@
[accounts]="accounts"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToShowValues]="hasPermissionToShowValues"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"

4
apps/client/src/app/pages/accounts/accounts-page.module.ts

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfAccountDetailDialogModule } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.module';
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
@ -10,16 +11,15 @@ import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-
@NgModule({
declarations: [AccountsPageComponent],
exports: [],
imports: [
AccountsPageRoutingModule,
CommonModule,
GfAccountDetailDialogModule,
GfAccountsTableModule,
GfCreateOrUpdateAccountDialogModule,
MatButtonModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AccountsPageModule {}

14
apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts

@ -111,12 +111,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}
});
this.fetchOrders();
this.fetchActivities();
}
public fetchOrders() {
public fetchActivities() {
this.dataService
.fetchOrders()
.fetchActivities({})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.activities = activities;
@ -139,7 +139,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchOrders();
this.fetchActivities();
}
});
}
@ -298,7 +298,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchOrders();
this.fetchActivities();
}
});
}
@ -332,7 +332,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}
private handleImportSuccess() {
this.fetchOrders();
this.fetchActivities();
this.snackBar.open('✅ Import has been completed', undefined, {
duration: 3000
@ -376,7 +376,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
if (transaction) {
this.dataService.postOrder(transaction).subscribe({
next: () => {
this.fetchOrders();
this.fetchActivities();
}
});
}

83
apps/client/src/app/services/data.service.ts

@ -59,10 +59,81 @@ export class DataService {
});
}
public fetchAccount(aAccountId: string) {
return this.http.get<Accounts>('/api/v1/account').pipe(
map((response) => {
return response.accounts.find((account) => {
return account.id === aAccountId;
});
})
);
}
public fetchAccounts() {
return this.http.get<Accounts>('/api/v1/account');
}
public fetchActivities({
filters
}: {
filters?: Filter[];
}): Observable<Activities> {
let params = new HttpParams();
if (filters?.length > 0) {
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount) {
params = params.append(
'accounts',
filtersByAccount
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByAssetClass) {
params = params.append(
'assetClasses',
filtersByAssetClass
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByTag) {
params = params.append(
'tags',
filtersByTag
.map(({ id }) => {
return id;
})
.join(',')
);
}
}
return this.http.get<any>('/api/v1/order', { params }).pipe(
map(({ activities }) => {
for (const activity of activities) {
activity.createdAt = parseISO(activity.createdAt);
activity.date = parseISO(activity.date);
}
return { activities };
})
);
}
public fetchAdminData() {
return this.http.get<AdminData>('/api/v1/admin');
}
@ -179,18 +250,6 @@ export class DataService {
);
}
public fetchOrders(): Observable<Activities> {
return this.http.get<any>('/api/v1/order').pipe(
map(({ activities }) => {
for (const activity of activities) {
activity.createdAt = parseISO(activity.createdAt);
activity.date = parseISO(activity.date);
}
return { activities };
})
);
}
public fetchPortfolioDetails({ filters }: { filters?: Filter[] }) {
let params = new HttpParams();

3
libs/common/src/lib/types/account-with-value.type.ts

@ -1,7 +1,8 @@
import { Account as AccountModel } from '@prisma/client';
import { Account as AccountModel, Platform } from '@prisma/client';
export type AccountWithValue = AccountModel & {
balanceInBaseCurrency: number;
Platform?: Platform;
transactionCount: number;
value: number;
valueInBaseCurrency: number;

Loading…
Cancel
Save