Feature/add support to create account cash balances (#3260)

* Add support to create account cash balances

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
pull/3306/head
Bastien Jeannelle 9 months ago committed by GitHub
parent 22d63c6102
commit dfacbed66d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the date range support to the portfolio holdings page - Added the date range support to the portfolio holdings page
- Added support to create an account balance
### Changed ### Changed

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Body,
Post,
Delete, Delete,
HttpException, HttpException,
Inject, Inject,
@ -17,14 +20,50 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service'; import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance') @Controller('account-balance')
export class AccountBalanceController { export class AccountBalanceController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.createAccountBalance)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccountBalance(
@Body() data: CreateAccountBalanceDto
): Promise<AccountBalance> {
const account = await this.accountService.account({
id_userId: {
id: data.accountId,
userId: this.request.user.id
}
});
if (!account) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.createAccountBalance({
Account: {
connect: {
id_userId: {
id: account.id,
userId: account.userId
}
}
},
date: data.date,
value: data.balance
});
}
@HasPermission(permissions.deleteAccountBalance) @HasPermission(permissions.deleteAccountBalance)
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service';
controllers: [AccountBalanceController], controllers: [AccountBalanceController],
exports: [AccountBalanceService], exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule], imports: [ExchangeRateDataModule, PrismaModule],
providers: [AccountBalanceService] providers: [AccountBalanceService, AccountService]
}) })
export class AccountBalanceModule {} export class AccountBalanceModule {}

@ -0,0 +1,12 @@
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
export class CreateAccountBalanceDto {
@IsUUID()
accountId: string;
@IsNumber()
balance: number;
@IsISO8601()
date: string;
}

@ -140,15 +140,33 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onAddAccountBalance({
balance,
date
}: {
balance: number;
date: Date;
}) {
this.dataService
.postAccountBalance({
balance,
date,
accountId: this.data.accountId
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
});
}
public onDeleteAccountBalance(aId: string) { public onDeleteAccountBalance(aId: string) {
this.dataService this.dataService
.deleteAccountBalance(aId) .deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe(() => {
next: () => { this.fetchAccountBalances();
this.fetchAccountBalances(); this.fetchPortfolioPerformance();
this.fetchPortfolioPerformance();
}
}); });
} }

@ -115,6 +115,7 @@
</ng-template> </ng-template>
<gf-account-balances <gf-account-balances
[accountBalances]="accountBalances" [accountBalances]="accountBalances"
[accountCurrency]="currency"
[accountId]="data.accountId" [accountId]="data.accountId"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]=" [showActions]="
@ -122,6 +123,7 @@
hasPermissionToDeleteAccountBalance && hasPermissionToDeleteAccountBalance &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
" "
(accountBalanceCreated)="onAddAccountBalance($event)"
(accountBalanceDeleted)="onDeleteAccountBalance($event)" (accountBalanceDeleted)="onDeleteAccountBalance($event)"
/> />
</mat-tab> </mat-tab>

@ -42,7 +42,11 @@ import { translate } from '@ghostfolio/ui/i18n';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { DataSource, Order as OrderModel } from '@prisma/client'; import {
AccountBalance,
DataSource,
Order as OrderModel
} from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { cloneDeep, groupBy, isNumber } from 'lodash'; import { cloneDeep, groupBy, isNumber } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -611,6 +615,22 @@ export class DataService {
return this.http.post<OrderModel>(`/api/v1/account`, aAccount); return this.http.post<OrderModel>(`/api/v1/account`, aAccount);
} }
public postAccountBalance({
accountId,
balance,
date
}: {
accountId: string;
balance: number;
date: Date;
}) {
return this.http.post<AccountBalance>(`/api/v1/account-balance`, {
accountId,
balance,
date
});
}
public postBenchmark(benchmark: UniqueAsset) { public postBenchmark(benchmark: UniqueAsset) {
return this.http.post(`/api/v1/benchmark`, benchmark); return this.http.post(`/api/v1/benchmark`, benchmark);
} }

@ -7,6 +7,7 @@ export const permissions = {
accessAssistant: 'accessAssistant', accessAssistant: 'accessAssistant',
createAccess: 'createAccess', createAccess: 'createAccess',
createAccount: 'createAccount', createAccount: 'createAccount',
createAccountBalance: 'createAccountBalance',
createOrder: 'createOrder', createOrder: 'createOrder',
createPlatform: 'createPlatform', createPlatform: 'createPlatform',
createTag: 'createTag', createTag: 'createTag',
@ -47,6 +48,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.accessAssistant, permissions.accessAssistant,
permissions.createAccess, permissions.createAccess,
permissions.createAccount, permissions.createAccount,
permissions.createAccountBalance,
permissions.deleteAccountBalance, permissions.deleteAccountBalance,
permissions.createOrder, permissions.createOrder,
permissions.createPlatform, permissions.createPlatform,
@ -75,6 +77,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.accessAssistant, permissions.accessAssistant,
permissions.createAccess, permissions.createAccess,
permissions.createAccount, permissions.createAccount,
permissions.createAccountBalance,
permissions.createOrder, permissions.createOrder,
permissions.deleteAccess, permissions.deleteAccess,
permissions.deleteAccount, permissions.deleteAccount,

@ -1,60 +1,106 @@
<table <form [formGroup]="accountBalanceForm" (ngSubmit)="onSubmitAccountBalance()">
class="gf-table w-100" <table
mat-table class="gf-table w-100"
matSort mat-table
matSortActive="date" matSort
matSortDirection="desc" matSortActive="date"
[dataSource]="dataSource" matSortDirection="desc"
> [dataSource]="dataSource"
<ng-container matColumnDef="date"> >
<th *matHeaderCellDef class="px-2" mat-header-cell mat-sort-header> <ng-container matColumnDef="date">
<ng-container i18n>Date</ng-container> <th *matHeaderCellDef class="px-2" mat-header-cell mat-sort-header>
</th> <ng-container i18n>Date</ng-container>
<td *matCellDef="let element" class="px-2" mat-cell> </th>
<gf-value [isDate]="true" [locale]="locale" [value]="element?.date" /> <td *matCellDef="let element" class="px-2" mat-cell>
</td> <gf-value [isDate]="true" [locale]="locale" [value]="element?.date" />
</ng-container> </td>
<td *matFooterCellDef class="px-2" mat-footer-cell>
<mat-form-field appearance="outline" class="py-1 without-hint">
<input formControlName="date" matInput [matDatepicker]="date" />
<mat-datepicker-toggle matSuffix [for]="date">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
/>
</mat-datepicker-toggle>
<mat-datepicker #date />
</mat-form-field>
</td>
</ng-container>
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-2 text-right" mat-header-cell> <th *matHeaderCellDef class="px-2 text-right" mat-header-cell>
<ng-container i18n>Value</ng-container> <ng-container i18n>Value</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-2" mat-cell> <td *matCellDef="let element" class="px-2" mat-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="element?.Account?.currency" [unit]="element?.Account?.currency"
[value]="element?.value" [value]="element?.value"
/> />
</div> </div>
</td> </td>
</ng-container> <td *matFooterCellDef class="px-2" mat-footer-cell>
<div class="d-flex justify-content-end">
<mat-form-field appearance="outline" class="without-hint">
<input formControlName="balance" matInput type="number" />
<div class="ml-2" matTextSuffix>
{{ accountCurrency }}
</div>
</mat-form-field>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
@if (showActions) { @if (showActions) {
<button
class="mx-1 no-min-width px-2"
mat-button
type="button"
[matMenuTriggerFor]="accountBalanceMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
}
<mat-menu #accountBalanceMenu="matMenu" xPosition="before">
<button
mat-menu-item
type="button"
(click)="onDeleteAccountBalance(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
<td *matFooterCellDef class="px-1 text-center" mat-footer-cell>
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button color="primary"
[matMenuTriggerFor]="accountBalanceMenu" mat-flat-button
(click)="$event.stopPropagation()" type="submit"
[disabled]="accountBalanceForm.invalid"
> >
<ion-icon name="ellipsis-horizontal" /> <span i18n>Add</span>
</button> </button>
} </td>
<mat-menu #accountBalanceMenu="matMenu" xPosition="before"> </ng-container>
<button mat-menu-item (click)="onDeleteAccountBalance(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> <tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[hidden]="!showActions"
></tr>
</table>
</form>

@ -1,3 +1,10 @@
:host { :host {
display: block; display: block;
} }
:host-context(.is-dark-theme) {
input {
color: rgb(var(--light-primary-text));
background-color: rgb(var(--palette-foreground-text-light));
}
}

@ -14,7 +14,17 @@ import {
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {
FormGroup,
FormControl,
Validators,
ReactiveFormsModule
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { DateAdapter } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table';
@ -29,9 +39,13 @@ import { GfValueComponent } from '../value';
CommonModule, CommonModule,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatMenuModule, MatMenuModule,
MatSortModule, MatSortModule,
MatTableModule MatTableModule,
ReactiveFormsModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-account-balances', selector: 'gf-account-balances',
@ -43,24 +57,38 @@ export class GfAccountBalancesComponent
implements OnChanges, OnDestroy, OnInit implements OnChanges, OnDestroy, OnInit
{ {
@Input() accountBalances: AccountBalancesResponse['balances']; @Input() accountBalances: AccountBalancesResponse['balances'];
@Input() accountCurrency: string;
@Input() accountId: string; @Input() accountId: string;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() showActions = true; @Input() showActions = true;
@Output() accountBalanceCreated = new EventEmitter<{
balance: number;
date: Date;
}>();
@Output() accountBalanceDeleted = new EventEmitter<string>(); @Output() accountBalanceDeleted = new EventEmitter<string>();
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public accountBalanceForm = new FormGroup({
balance: new FormControl(0, Validators.required),
date: new FormControl(new Date(), Validators.required)
});
public dataSource: MatTableDataSource< public dataSource: MatTableDataSource<
AccountBalancesResponse['balances'][0] AccountBalancesResponse['balances'][0]
> = new MatTableDataSource(); > = new MatTableDataSource();
public displayedColumns: string[] = ['date', 'value', 'actions']; public displayedColumns: string[] = ['date', 'value', 'actions'];
public Validators = Validators;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor() {} public constructor(private dateAdapter: DateAdapter<any>) {}
public ngOnInit() {} public ngOnInit() {
this.dateAdapter.setLocale(this.locale);
}
public ngOnChanges() { public ngOnChanges() {
if (this.accountBalances) { if (this.accountBalances) {
@ -81,6 +109,10 @@ export class GfAccountBalancesComponent
} }
} }
public onSubmitAccountBalance() {
this.accountBalanceCreated.emit(this.accountBalanceForm.getRawValue());
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

Loading…
Cancel
Save