Compare commits

..

15 Commits

Author SHA1 Message Date
91ec9aa0a4 Release 1.1.0 (#79) 2021-05-11 18:14:49 +02:00
565e920f1b Add link to retrieve manually the current market price (#74)
* feat: add link to retrieve manually the current market price from data source

* Add icon

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-11 18:01:46 +02:00
5d24adfa75 Feature/improve transaction filtering (#76)
* add multi-filter support for transaction filtering with auto completion

* update changelog

* fix table for transaction for accounts without platform

* simplify readme file since docker compose build is not required (#75)

* simplify readme file since docker compose build is not required

* add anchor navigation in README.md

* Improve UI

* Refactoring

* Refactoring

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Bugfix/keep current menu item active (#78)

* Keep current menu item active

* Update changelog

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Keep current menu item active

* Update changelog

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>

* add multi-filter support for transaction filtering with auto completion

* update changelog

* fix table for transaction for accounts without platform

* Improve UI

* Refactoring

* Refactoring

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-11 17:55:55 +02:00
1dc94c0027 Bugfix/keep current menu item active (#78)
* Keep current menu item active

* Update changelog

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Keep current menu item active

* Update changelog

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>
2021-05-11 17:49:35 +02:00
ebae2f4ec9 Feature/travis (#77)
* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml
2021-05-11 17:43:35 +02:00
7099edc591 simplify readme file since docker compose build is not required (#75)
* simplify readme file since docker compose build is not required

* add anchor navigation in README.md
2021-05-09 20:11:10 +02:00
de973d6bda Add filterPredicate on transactions table to filter by account name (#73)
* fix: add filterPredicate on transactions table to filter by account name

* Minor refactoring

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-06 22:57:15 +02:00
993a491d24 Release 1.0.0 (#70) 2021-05-05 12:05:43 +02:00
631efff7ae Add duplicate action on transactions table (#68)
* feat: add duplicate action on transactions table

* fix: review changes

* fix: add type and dataSource
2021-05-05 12:01:56 +02:00
a3d1ac2ce4 Feature/update icon and add google play badge (#69)
* Add maskable icons

* Add Google Play badge

* Update changelog
2021-05-04 21:48:51 +02:00
4484c21757 Clean up platform id (#67) 2021-05-04 17:49:47 +02:00
87cd3ef33f Release 0.99.0 (#65) 2021-05-03 21:25:39 +02:00
163f4a3d3f Feature/allow to delete users (#64)
* Allow to delete users

* Update changelog
2021-05-03 21:23:00 +02:00
a84256dc03 Feature/eliminate platform from order (#63)
* Eliminate platform from order

* Update changelog
2021-05-03 21:19:56 +02:00
cf82066976 Fix test (#62) 2021-05-02 22:38:42 +02:00
37 changed files with 877 additions and 552 deletions

9
.travis.yml Normal file
View File

@ -0,0 +1,9 @@
language: node_js
git:
depth: false
node_js:
- 14
before_script:
- yarn
script:
- yarn format:check

View File

@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.1.0 - 11.05.2021
### Added
- Added a button to fetch the current market price in the create or edit transaction dialog
### Changed
- Improved the transaction filtering with multi filter support
### Fixed
- Fixed the filtering by account name in the transactions table
- Fixed the active menu item state when a modal has opened
## 1.0.0 - 05.05.2021
### Added
- Added the functionality to clone a transaction
- Added a _Google Play_ badge on the landing page
### Changed
- Changed to maskable icons
## 0.99.0 - 03.05.2021
### Added
- Added support for deleting users in the admin control panel
### Changed
- Eliminated the platform attribute from the transaction model
## 0.98.0 - 02.05.2021
### Added

View File

@ -68,19 +68,18 @@ The frontend is built with [Angular](https://angular.io).
### Setup
1. Run `yarn install`
2. Run `cd docker`
3. Run `docker compose build`
4. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
5. Run `cd -` to go back to the project root directory
6. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
7. Start server and client (see _Development_)
8. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
9. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
10. Press _Sign out_ and check out the _Live Demo_
1. Run `cd docker`
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `cd -` to go back to the project root directory
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
1. Press _Sign out_ and check out the _Live Demo_
## Development
Please make sure you have completed the instructions from _Setup_
Please make sure you have completed the instructions from [_Setup_](#Setup)
### Start server

View File

@ -17,10 +17,6 @@ export class CreateOrderDto {
@IsNumber()
fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsNumber()
quantity: number;

View File

@ -1,3 +1,5 @@
import { Account, Order } from '@prisma/client';
import { Account, Order, Platform } from '@prisma/client';
export type OrderWithAccount = Order & { Account?: Account };
type AccountWithPlatform = Account & { Platform?: Platform };
export type OrderWithAccount = Order & { Account?: AccountWithPlatform };

View File

@ -125,41 +125,19 @@ export class OrderController {
const accountId = data.accountId;
delete data.accountId;
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.orderService.createOrder(
{
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
return this.orderService.createOrder(
{
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
this.request.user.id
);
} else {
delete data.platformId;
return this.orderService.createOrder(
{
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
@Put(':id')
@ -196,60 +174,26 @@ export class OrderController {
const accountId = data.accountId;
delete data.accountId;
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
return this.orderService.updateOrder(
{
data: {
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
}
},
this.request.user.id
);
} else {
// platformId is null, remove it
delete data.platformId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
Platform: originalOrder.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
}
}

View File

@ -17,10 +17,6 @@ export class UpdateOrderDto {
@IsNumber()
fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsString()
id: string;

View File

@ -3,6 +3,7 @@ import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
@ -15,6 +16,7 @@ import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface';
@ -30,6 +32,27 @@ export class UserController {
private readonly userService: UserService
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteUser
) ||
id === this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.userService.deleteUser({
id
});
}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> {

View File

@ -163,6 +163,28 @@ export class UserService {
}
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prisma.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
await this.prisma.account.deleteMany({
where: { userId: where.id }
});
await this.prisma.analytics.delete({
where: { userId: where.id }
});
await this.prisma.order.deleteMany({
where: { userId: where.id }
});
try {
await this.prisma.settings.delete({
where: { userId: where.id }
});
} catch {}
return this.prisma.user.delete({
where
});

View File

@ -149,7 +149,6 @@ describe('Portfolio', () => {
fee: 0,
date: new Date(),
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
platformId: null,
quantity: 1,
symbol: 'BTCUSD',
type: Type.BUY,
@ -170,19 +169,7 @@ describe('Portfolio', () => {
const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
BTCUSD: {
currency: Currency.USD,
exchange: 'Other',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
platforms: {
accounts: {
Other: {
/*current: exchangeRateDataService.toCurrency(
1 * 49631.24,
@ -196,11 +183,23 @@ describe('Portfolio', () => {
)
}
},
currency: Currency.USD,
exchange: 'Other',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
quantity: 1,
// shareCurrent: 0.9999999559148652,
shareInvestment: 1,
symbol: 'BTCUSD',
transactionCount: 0,
transactionCount: 1,
type: 'Cryptocurrency'
}
});
@ -251,7 +250,6 @@ describe('Portfolio', () => {
fee: 0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
type: Type.BUY,
@ -272,18 +270,7 @@ describe('Portfolio', () => {
const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
ETHUSD: {
currency: Currency.USD,
exchange: 'Other',
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
name: 'Ethereum USD',
platforms: {
accounts: {
Other: {
/*current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
@ -297,6 +284,17 @@ describe('Portfolio', () => {
)
}
},
currency: Currency.USD,
exchange: 'Other',
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
name: 'Ethereum USD',
quantity: 0.2,
// shareCurrent: 1,
shareInvestment: 1,
@ -347,7 +345,6 @@ describe('Portfolio', () => {
fee: 0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
type: Type.BUY,
@ -364,7 +361,6 @@ describe('Portfolio', () => {
fee: 0,
date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.3,
symbol: 'ETHUSD',
type: Type.BUY,
@ -425,7 +421,6 @@ describe('Portfolio', () => {
date: new Date(getUtc('2017-08-16')),
fee: 2.99,
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
platformId: null,
quantity: 0.05614682,
symbol: 'BTCUSD',
type: Type.BUY,
@ -442,7 +437,6 @@ describe('Portfolio', () => {
fee: 2.99,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
type: Type.BUY,
@ -516,7 +510,6 @@ describe('Portfolio', () => {
fee: 1.0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
type: Type.BUY,
@ -533,7 +526,6 @@ describe('Portfolio', () => {
fee: 1.0,
date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.1,
symbol: 'ETHUSD',
type: Type.SELL,
@ -550,7 +542,6 @@ describe('Portfolio', () => {
fee: 1.0,
date: new Date(getUtc('2018-01-31')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.2,
symbol: 'ETHUSD',
type: Type.BUY,

View File

@ -57,9 +57,8 @@ export class AppComponent implements OnDestroy, OnInit {
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe((test) => {
.subscribe(() => {
this.currentRoute = this.router.url.toString().substring(1);
// this.initializeTheme();
});
this.tokenStorageService

View File

@ -9,7 +9,7 @@
[routerLink]="['/']"
i18n
mat-flat-button
[color]="currentRoute === 'home' ? 'primary' : null"
[color]="currentRoute?.startsWith('home') ? 'primary' : null"
>Overview</a
>
<a
@ -17,7 +17,7 @@
[routerLink]="['/analysis']"
i18n
mat-flat-button
[color]="currentRoute === 'analysis' ? 'primary' : null"
[color]="currentRoute?.startsWith('analysis') ? 'primary' : null"
>Analysis</a
>
<a
@ -25,7 +25,7 @@
[routerLink]="['/report']"
i18n
mat-flat-button
[color]="currentRoute === 'report' ? 'primary' : null"
[color]="currentRoute?.startsWith('report') ? 'primary' : null"
>X-ray</a
>
<a
@ -33,7 +33,7 @@
[routerLink]="['/transactions']"
i18n
mat-flat-button
[color]="currentRoute === 'transactions' ? 'primary' : null"
[color]="currentRoute?.startsWith('transactions') ? 'primary' : null"
>Transactions</a
>
<a
@ -41,7 +41,7 @@
[routerLink]="['/accounts']"
i18n
mat-flat-button
[color]="currentRoute === 'accounts' ? 'primary' : null"
[color]="currentRoute?.startsWith('accounts') ? 'primary' : null"
>Accounts</a
>
<a
@ -50,7 +50,7 @@
[routerLink]="['/admin']"
i18n
mat-flat-button
[color]="currentRoute === 'admin' ? 'primary' : null"
[color]="currentRoute?.startsWith('admin') ? 'primary' : null"
>Admin Control</a
>
<a
@ -58,7 +58,7 @@
[routerLink]="['/resources']"
i18n
mat-flat-button
[color]="currentRoute === 'resources' ? 'primary' : null"
[color]="currentRoute?.startsWith('resources') ? 'primary' : null"
>Resources</a
>
<a
@ -66,7 +66,7 @@
[routerLink]="['/about']"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
>About</a
>
<button
@ -130,7 +130,7 @@
[routerLink]="['/analysis']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('analysis') }"
>Analysis</a
>
<a
@ -138,7 +138,7 @@
[routerLink]="['/report']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('report') }"
>X-ray</a
>
<a
@ -146,7 +146,9 @@
[routerLink]="['/transactions']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'transactions' }"
[ngClass]="{
'font-weight-bold': currentRoute?.startsWith('transactions')
}"
>Transactions</a
>
<a
@ -154,7 +156,7 @@
[routerLink]="['/accounts']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('accounts') }"
>Accounts</a
>
<a
@ -162,7 +164,7 @@
[routerLink]="['/account']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('account') }"
>Ghostfolio Account</a
>
<a
@ -171,7 +173,7 @@
[routerLink]="['/admin']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('admin') }"
>Admin Control</a
>
<hr class="m-0" />
@ -180,7 +182,9 @@
[routerLink]="['/resources']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'resources' }"
[ngClass]="{
'font-weight-bold': currentRoute?.startsWith('resources')
}"
>Resources</a
>
<a
@ -188,7 +192,7 @@
[routerLink]="['/about']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('about') }"
>About Ghostfolio</a
>
<hr class="d-block d-sm-none m-0" />
@ -210,7 +214,7 @@
[routerLink]="['/about']"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
>About</a
>
<a

View File

@ -1,12 +1,37 @@
<mat-form-field appearance="outline" class="w-100">
<input
#input
autocomplete="off"
matInput
placeholder="Search for transactions..."
(keyup)="applyFilter($event)"
/>
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
<mat-chip-list #chipList aria-label="Search keywords">
<mat-chip
*ngFor="let searchKeyword of searchKeywords"
matChipRemove
[removable]="true"
(removed)="removeKeyword(searchKeyword)"
>
{{ searchKeyword }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip>
<input
#searchInput
name="close-outline"
placeholder="Search for account, currency, symbol or type..."
[formControl]="searchControl"
[matAutocomplete]="autocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addKeyword($event)"
/>
</mat-chip-list>
<mat-autocomplete
#autocomplete="matAutocomplete"
(optionSelected)="keywordSelected($event)"
>
<mat-option
*ngFor="let transaction of filteredTransactions | async"
[value]="transaction"
>
{{ transaction }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
<table
@ -177,6 +202,9 @@
<button i18n mat-menu-item (click)="onUpdateTransaction(element)">
Edit
</button>
<button i18n mat-menu-item (click)="onCloneTransaction(element)">
Clone
</button>
<button i18n mat-menu-item (click)="onDeleteTransaction(element.id)">
Delete
</button>

View File

@ -7,6 +7,10 @@
}
}
.mat-chip {
cursor: pointer;
}
.mat-table {
td {
border: 0;

View File

@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
@ -13,12 +14,21 @@ import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { OrderWithAccount } from '@ghostfolio/api/app/order/interfaces/order-with-account.type';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
import { Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { FormControl } from '@angular/forms';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { MatChipInputEvent } from '@angular/material/chips';
const SEARCH_STRING_SEPARATOR = ',';
@Component({
selector: 'gf-transactions-table',
@ -32,19 +42,30 @@ export class TransactionsTableComponent
@Input() deviceType: string;
@Input() locale: string;
@Input() showActions: boolean;
@Input() transactions: OrderModel[];
@Input() transactions: OrderWithAccount[];
@Output() transactionDeleted = new EventEmitter<string>();
@Output() transactionToUpdate = new EventEmitter<OrderModel>();
@Output() transactionToClone = new EventEmitter<OrderWithAccount>();
@Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<OrderModel> = new MatTableDataSource();
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public displayedColumns = [];
public filteredTransactions$: Subject<string[]> = new BehaviorSubject([]);
public filteredTransactions: Observable<
string[]
> = this.filteredTransactions$.asObservable();
public isLoading = true;
public routeQueryParams: Subscription;
public searchControl = new FormControl();
public searchKeywords: string[] = [];
public separatorKeysCodes: number[] = [ENTER, COMMA];
private allFilteredTransactions: string[];
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -62,6 +83,49 @@ export class TransactionsTableComponent
});
}
});
this.searchControl.valueChanges.subscribe((keyword) => {
if (keyword) {
const filterValue = keyword.toLowerCase();
this.filteredTransactions$.next(
this.allFilteredTransactions.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
)
);
} else {
this.filteredTransactions$.next(this.allFilteredTransactions);
}
});
}
public addKeyword({ input, value }: MatChipInputEvent): void {
if (value?.trim()) {
this.searchKeywords.push(value.trim());
this.updateFilter();
}
// Reset the input value
if (input) {
input.value = '';
}
this.searchControl.setValue(null);
}
public removeKeyword(keyword: string): void {
const index = this.searchKeywords.indexOf(keyword);
if (index >= 0) {
this.searchKeywords.splice(index, 1);
this.updateFilter();
}
}
public keywordSelected(event: MatAutocompleteSelectedEvent): void {
this.searchKeywords.push(event.option.viewValue);
this.updateFilter();
this.searchInput.nativeElement.value = '';
this.searchControl.setValue(null);
}
public ngOnInit() {}
@ -86,17 +150,23 @@ export class TransactionsTableComponent
if (this.transactions) {
this.dataSource = new MatTableDataSource(this.transactions);
this.dataSource.filterPredicate = (data, filter) => {
const dataString = TransactionsTableComponent.getFilterableValues(data)
.join(' ')
.toLowerCase();
let contains = true;
for (const singleFilter of filter.split(SEARCH_STRING_SEPARATOR)) {
contains =
contains && dataString.includes(singleFilter.trim().toLowerCase());
}
return contains;
};
this.dataSource.sort = this.sort;
this.updateFilter();
this.isLoading = false;
}
}
public applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
}
public onDeleteTransaction(aId: string) {
const confirmation = confirm(
'Do you really want to delete this transaction?'
@ -119,10 +189,14 @@ export class TransactionsTableComponent
});
}
public onUpdateTransaction(aTransaction: OrderModel) {
public onUpdateTransaction(aTransaction: OrderWithAccount) {
this.transactionToUpdate.emit(aTransaction);
}
public onCloneTransaction(aTransaction: OrderWithAccount) {
this.transactionToClone.emit(aTransaction);
}
public openPositionDialog({
symbol,
title
@ -152,4 +226,40 @@ export class TransactionsTableComponent
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private updateFilter() {
this.dataSource.filter = this.searchKeywords.join(SEARCH_STRING_SEPARATOR);
const lowercaseSearchKeywords = this.searchKeywords.map((keyword) =>
keyword.trim().toLowerCase()
);
this.allFilteredTransactions = TransactionsTableComponent.getSearchableFieldValues(
this.transactions
).filter((item) => {
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
});
this.filteredTransactions$.next(this.allFilteredTransactions);
}
private static getSearchableFieldValues(
transactions: OrderWithAccount[]
): string[] {
const fieldValues = new Set<string>();
for (const transaction of transactions) {
this.getFilterableValues(transaction, fieldValues);
}
return [...fieldValues].filter((item) => item != undefined).sort();
}
private static getFilterableValues(
transaction,
fieldValues: Set<string> = new Set<string>()
): string[] {
fieldValues.add(transaction.currency);
fieldValues.add(transaction.symbol);
fieldValues.add(transaction.type);
fieldValues.add(transaction.Account?.name);
fieldValues.add(transaction.Account?.Platform?.name);
return [...fieldValues].filter((item) => item != undefined);
}
}

View File

@ -13,6 +13,9 @@ import { GfPositionDetailDialogModule } from '../position/position-detail-dialog
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { TransactionsTableComponent } from './transactions-table.component';
import { MatChipsModule } from '@angular/material/chips';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [TransactionsTableComponent],
@ -23,12 +26,15 @@ import { TransactionsTableComponent } from './transactions-table.component';
GfSymbolIconModule,
GfSymbolModule,
GfValueModule,
MatAutocompleteModule,
MatButtonModule,
MatChipsModule,
MatInputModule,
MatMenuModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule,
RouterModule
],
providers: [],

View File

@ -1,8 +1,10 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { AdminData } from '@ghostfolio/api/app/admin/interfaces/admin-data.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
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 { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns';
import { Subject } from 'rxjs';
@ -20,6 +22,7 @@ export class AdminPageComponent implements OnInit {
public lastDataGathering: string;
public transactionCount: number;
public userCount: number;
public user: User;
public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>();
@ -31,46 +34,24 @@ export class AdminPageComponent implements OnInit {
private adminService: AdminService,
private cacheService: CacheService,
private cd: ChangeDetectorRef,
private dataService: DataService
private dataService: DataService,
private tokenStorageService: TokenStorageService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.dataService
.fetchAdminData()
this.fetchAdminData();
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
exchangeRates,
lastDataGathering,
transactionCount,
userCount,
users
}) => {
this.exchangeRates = exchangeRates;
this.users = users;
if (isValid(parseISO(lastDataGathering?.toString()))) {
this.lastDataGathering = formatDistanceToNow(
new Date(lastDataGathering),
{
addSuffix: true
}
);
} else if (lastDataGathering === 'IN_PROGRESS') {
this.dataGatheringInProgress = true;
} else {
this.lastDataGathering = '-';
}
this.transactionCount = transactionCount;
this.userCount = userCount;
this.cd.markForCheck();
}
);
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
});
});
}
public onFlushCache() {
@ -112,8 +93,56 @@ export class AdminPageComponent implements OnInit {
return '';
}
public onDeleteUser(aId: string) {
const confirmation = confirm('Do you really want to delete this user?');
if (confirmation) {
this.dataService.deleteUser(aId).subscribe({
next: () => {
this.fetchAdminData();
}
});
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminData() {
this.dataService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
exchangeRates,
lastDataGathering,
transactionCount,
userCount,
users
}) => {
this.exchangeRates = exchangeRates;
this.users = users;
if (isValid(parseISO(lastDataGathering?.toString()))) {
this.lastDataGathering = formatDistanceToNow(
new Date(lastDataGathering),
{
addSuffix: true
}
);
} else if (lastDataGathering === 'IN_PROGRESS') {
this.dataGatheringInProgress = true;
} else {
this.lastDataGathering = '-';
}
this.transactionCount = transactionCount;
this.userCount = userCount;
this.cd.markForCheck();
}
);
}
}

View File

@ -82,6 +82,7 @@
<th class="mat-header-cell pr-2 py-2" i18n>Transactions</th>
<th class="mat-header-cell pr-2 py-2" i18n>Engagement</th>
<th class="mat-header-cell pr-3 py-2" i18n>Last Activitiy</th>
<th class="mat-header-cell pr-3 py-2"></th>
</tr>
</thead>
<tbody>
@ -102,6 +103,26 @@
<td class="mat-cell pr-3 py-2">
{{ formatDistanceToNow(userItem.Analytics?.updatedAt) }}
</td>
<td class="mat-cell pr-3 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>

View File

@ -2,6 +2,7 @@ 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 { MatMenuModule } from '@angular/material/menu';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { AdminPageRoutingModule } from './admin-page-routing.module';
@ -14,7 +15,8 @@ import { AdminPageComponent } from './admin-page.component';
AdminPageRoutingModule,
CommonModule,
MatButtonModule,
MatCardModule
MatCardModule,
MatMenuModule
],
providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -156,4 +156,13 @@
</div>
</div>
</div>
<div class="downloads my-5 row justify-content-center">
<a
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
title="Get Ghostfolio on Google Play"
>
<img alt="Google Play Badge" src="assets/badge-en-google-play.png" />
</a>
</div>
</div>

View File

@ -18,6 +18,12 @@
background-color: var(--light-background);
}
}
.downloads {
img {
height: 2.5rem;
}
}
}
:host-context(.is-dark-theme) {

View File

@ -30,6 +30,7 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
})
export class CreateOrUpdateTransactionDialog {
public currencies: Currency[] = [];
public currentMarketPrice = null;
public filteredLookupItems: Observable<LookupItem[]>;
public isLoading = false;
public platforms: { id: string; name: string }[];
@ -65,6 +66,20 @@ export class CreateOrUpdateTransactionDialog {
return [];
})
);
if (this.data.transaction.symbol) {
this.dataService
.fetchSymbolItem(this.data.transaction.symbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
this.currentMarketPrice = marketPrice;
this.cd.markForCheck();
});
}
}
public applyCurrentMarketPrice() {
this.data.transaction.unitPrice = this.currentMarketPrice;
}
public onCancel(): void {
@ -81,7 +96,7 @@ export class CreateOrUpdateTransactionDialog {
.subscribe(({ currency, dataSource, marketPrice }) => {
this.data.transaction.currency = currency;
this.data.transaction.dataSource = dataSource;
this.data.transaction.unitPrice = marketPrice;
this.currentMarketPrice = marketPrice;
this.isLoading = false;

View File

@ -81,7 +81,13 @@
[matDatepicker]="date"
[(ngModel)]="data.transaction.date"
/>
<mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle>
<mat-datepicker-toggle matSuffix [for]="date">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
></ion-icon>
</mat-datepicker-toggle>
<mat-datepicker #date disabled="false"></mat-datepicker>
</mat-form-field>
</div>
@ -110,7 +116,7 @@
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-form-field appearance="outline" class="unit-price w-100">
<mat-label i18n>Unit Price</mat-label>
<input
matInput
@ -119,6 +125,15 @@
type="number"
[(ngModel)]="data.transaction.unitPrice"
/>
<button
*ngIf="currentMarketPrice"
mat-icon-button
matSuffix
title="Apply current market price"
(click)="applyCurrentMarketPrice()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
</mat-form-field>
</div>
<div>

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
@ -30,6 +30,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
MatSelectModule,
ReactiveFormsModule
],
providers: []
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class CreateOrUpdateTransactionDialogModule {}

View File

@ -14,6 +14,18 @@
}
}
.mat-form-field-appearance-outline {
::ng-deep {
.mat-form-field-suffix {
top: -0.3rem;
}
}
ion-icon {
font-size: 130%;
}
}
.mat-select {
&.no-arrow {
::ng-deep {

View File

@ -109,6 +109,10 @@ export class TransactionsPageComponent implements OnInit {
});
}
public onCloneTransaction(aTransaction: OrderModel) {
this.openCreateTransactionDialog(aTransaction);
}
public onDeleteTransaction(aId: string) {
this.dataService.deleteOrder(aId).subscribe({
next: () => {
@ -130,7 +134,6 @@ export class TransactionsPageComponent implements OnInit {
date,
fee,
id,
platformId,
quantity,
symbol,
type,
@ -146,7 +149,6 @@ export class TransactionsPageComponent implements OnInit {
date,
fee,
id,
platformId,
quantity,
symbol,
type,
@ -177,21 +179,23 @@ export class TransactionsPageComponent implements OnInit {
this.unsubscribeSubject.complete();
}
private openCreateTransactionDialog(): void {
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
accounts: this.user?.accounts,
transaction: {
accountId: this.user?.accounts.find((account) => {
return account.isDefault;
})?.id,
currency: null,
accountId:
aTransaction?.accountId ??
this.user?.accounts.find((account) => {
return account.isDefault;
})?.id,
currency: aTransaction?.currency ?? null,
dataSource: aTransaction?.dataSource ?? null,
date: new Date(),
fee: 0,
platformId: null,
quantity: null,
symbol: null,
type: 'BUY',
symbol: aTransaction?.symbol ?? null,
type: aTransaction?.type ?? 'BUY',
unitPrice: null
}
},

View File

@ -9,6 +9,7 @@
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder"
[transactions]="transactions"
(transactionDeleted)="onDeleteTransaction($event)"
(transactionToClone)="onCloneTransaction($event)"
(transactionToUpdate)="onUpdateTransaction($event)"
></gf-transactions-table>
</div>

View File

@ -48,6 +48,10 @@ export class DataService {
return this.http.delete<any>(`/api/order/${aId}`);
}
public deleteUser(aId: string) {
return this.http.delete<any>(`/api/user/${aId}`);
}
public fetchAccesses() {
return this.http.get<Access[]>('/api/access');
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -12,6 +12,7 @@ export const permissions = {
createUserAccount: 'createUserAccount',
deleteAccount: 'deleteAcccount',
deleteOrder: 'deleteOrder',
deleteUser: 'deleteUser',
enableSocialLogin: 'enableSocialLogin',
enableSubscription: 'enableSubscription',
readForeignPortfolio: 'readForeignPortfolio',
@ -36,6 +37,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.createOrder,
permissions.deleteAccount,
permissions.deleteOrder,
permissions.deleteUser,
permissions.readForeignPortfolio,
permissions.updateAccount,
permissions.updateOrder,

View File

@ -6,7 +6,7 @@
".eslintrc.json": "*",
"nx.json": "*"
},
"affected": { "defaultBase": "master" },
"affected": { "defaultBase": "origin/main" },
"npmScope": "ghostfolio",
"tasksRunnerOptions": {
"default": {

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "0.98.0",
"version": "1.1.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {

View File

@ -68,8 +68,6 @@ model Order {
date DateTime
fee Float
id String @default(uuid())
Platform Platform? @relation(fields: [platformId], references: [id])
platformId String?
quantity Float
symbol String
type Type
@ -85,7 +83,6 @@ model Platform {
Account Account[]
id String @id @default(uuid())
name String?
Order Order[]
url String @unique
}

View File

@ -144,7 +144,6 @@ async function main() {
date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)),
fee: 30,
id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1',
platformId: platformDegiro.id,
quantity: 50,
symbol: 'TSLA',
type: Type.BUY,
@ -158,7 +157,6 @@ async function main() {
date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)),
fee: 29.9,
id: 'a1c5d73a-8631-44e5-ac44-356827a5212c',
platformId: platformCoinbase.id,
quantity: 0.5614682,
symbol: 'BTCUSD',
type: Type.BUY,
@ -172,7 +170,6 @@ async function main() {
date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)),
fee: 80.79,
id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b',
platformId: platformInteractiveBrokers.id,
quantity: 5,
symbol: 'AMZN',
type: Type.BUY,
@ -186,7 +183,6 @@ async function main() {
date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)),
fee: 19.9,
id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e',
platformId: platformInteractiveBrokers.id,
quantity: 10,
symbol: 'VTI',
type: Type.BUY,
@ -200,7 +196,6 @@ async function main() {
date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)),
fee: 19.9,
id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e',
platformId: platformInteractiveBrokers.id,
quantity: 10,
symbol: 'VTI',
type: Type.BUY,
@ -214,7 +209,6 @@ async function main() {
date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)),
fee: 19.9,
id: '347b0430-a84f-4031-a0f9-390399066ad6',
platformId: platformInteractiveBrokers.id,
quantity: 10,
symbol: 'VTI',
type: Type.BUY,
@ -228,7 +222,6 @@ async function main() {
date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)),
fee: 19.9,
id: '67ec3f47-3189-4b63-ba05-60d3a06b302f',
platformId: platformInteractiveBrokers.id,
quantity: 10,
symbol: 'VTI',
type: Type.BUY,
@ -242,7 +235,6 @@ async function main() {
date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)),
fee: 19.9,
id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2',
platformId: platformInteractiveBrokers.id,
quantity: 10,
symbol: 'VTI',
type: Type.BUY,

668
yarn.lock

File diff suppressed because it is too large Load Diff