Compare commits

...

36 Commits

Author SHA1 Message Date
58fd59beb1 Release 1.2.0 (#87) 2021-05-14 21:22:38 +02:00
eb09d77251 Feature/move pricing section to page (#86)
* Add a dedicated pricing page

* Update changelog
2021-05-14 21:15:20 +02:00
42b9178d96 Feature/improve filter search in transactions table (#85)
* Improve filter search style

* Update changelog
2021-05-14 21:09:30 +02:00
45516311f5 Improve proportion charts (#83)
* Improve proportion charts

* Clean up code
2021-05-13 21:58:39 +02:00
04cfa7366f Sort imports (#84) 2021-05-12 22:03:07 +02:00
4234ab84a9 Add build badge (#81) 2021-05-12 20:35:28 +02:00
b8c05d1014 Feature/harmonize table styles (#80)
* Harmonize table styles

* Update changelog
2021-05-12 20:32:34 +02:00
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
e248c9cedd Release 0.98.0 (#61) 2021-05-02 21:22:30 +02:00
90a2fea7d6 Feature/create and update accounts (#60)
* Allow to create and update accounts

* Activate account selector in transaction dialog

* Refactor analytics and report from platforms to accounts
2021-05-02 21:18:52 +02:00
d17b02092e Release 0.97.0 (#59) 2021-05-01 12:33:35 +02:00
c70eb7793e Feature/migration to accounts (#58)
* Migrate transaction table
* Add accounts page
* Add account page logic
2021-05-01 12:30:52 +02:00
e3a1d2b9cf Fix tests (#57) 2021-05-01 12:28:48 +02:00
e68ebdf76d Release 0.96.0 2021-04-30 21:18:42 +02:00
949cf58eef Release 0.96.0 (#56) 2021-04-30 21:14:28 +02:00
0816defb95 Feature/update slogan (#55)
* Harmonize slogan
2021-04-30 21:11:48 +02:00
a076a1c933 Feature/extend position detail dialog (#54)
* Extend position detail dialog

* Absolute change
* Number of transactions
2021-04-30 21:08:43 +02:00
40c95a541d Add data source to transaction model (#53) 2021-04-28 21:30:49 +02:00
2d04d7b8d5 Release 0.94.0 (#52) 2021-04-27 21:04:51 +02:00
94e0feac68 Add scraper symbols to symbol lookup results (#51) 2021-04-27 21:03:22 +02:00
cd9e974c40 Release 0.93.0 (#50) 2021-04-26 22:09:20 +02:00
11700f75d9 Improve colors in dark mode, improve user table (#49) 2021-04-26 21:54:19 +02:00
109 changed files with 2863 additions and 1138 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,98 @@ 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.2.0 - 14.05.2021
### Changed
- Harmonized the style of various tables
- Keep the color per type when switching between _Initial_ and _Current_ in pie charts
- Upgraded `chart.js` from version `3.0.2` to `3.2.1`
- Moved the pricing section to a dedicated page
- Improved the style of the transaction filtering component
### Fixed
- Fixed the tooltips when switching between _Initial_ and _Current_ in pie charts
## 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
- Added the logic to create and update accounts
## 0.97.0 - 01.05.2021
### Added
- Added an account page as a preparation for the multi accounts support
## 0.96.0 - 30.04.2021
### Added
- Added the absolute change to the position detail dialog
- Added the number of transactions to the position detail dialog
### Changed
- Harmonized the slogan to "Open Source Portfolio Tracker"
## 0.95.0 - 28.04.2021
### Added
- Added a data source attribute to the transactions model
## 0.94.0 - 27.04.2021
### Added
- Added the generic scraper symbols to the symbol lookup results
## 0.93.0 - 26.04.2021
### Changed
- Improved the user table styling of the admin control panel
- Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021
### Added

View File

@ -7,8 +7,11 @@
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p>
<p>
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/>
</a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/>
</a>
</p>
</div>
@ -68,19 +71,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

@ -0,0 +1,237 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Account as AccountModel, Order } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account')
export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const account = await this.accountService.accountWithOrders(
{
id_userId: {
id,
userId: this.request.user.id
}
},
{ Order: true }
);
if (account?.isDefault || account?.Order.length > 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountService.deleteAccount(
{
id_userId: {
id,
userId: this.request.user.id
}
},
this.request.user.id
);
}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accounts = await this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: impersonationUserId || this.request.user.id }
});
if (
impersonationUserId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
accounts = nullifyValuesInObjects(accounts, [
'fee',
'quantity',
'unitPrice'
]);
}
return accounts;
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
return this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.accountService.createAccount(
{
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
} else {
delete data.platformId;
return this.accountService.createAccount(
{
...data,
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccount = await this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
if (!originalAccount) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
} else {
// platformId is null, remove it
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
Platform: originalAccount.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
}
}
}

View File

@ -0,0 +1,30 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
@Module({
imports: [RedisCacheModule],
controllers: [AccountController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class AccountModule {}

View File

@ -0,0 +1,89 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Injectable()
export class AccountService {
public constructor(
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> {
return this.prisma.account.findUnique({
where: accountWhereUniqueInput
});
}
public async accountWithOrders(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude
): Promise<
Account & {
Order?: Order[];
}
> {
return this.prisma.account.findUnique({
include: accountInclude,
where: accountWhereUniqueInput
});
}
public async accounts(params: {
include?: Prisma.AccountInclude;
skip?: number;
take?: number;
cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput;
}): Promise<Account[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.account.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prisma.account.create({
data
});
}
public async deleteAccount(
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
return this.prisma.account.delete({
where
});
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput;
},
aUserId: string
): Promise<Account> {
const { data, where } = params;
return this.prisma.account.update({
data,
where
});
}
}

View File

@ -0,0 +1,14 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsString()
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
}

View File

@ -0,0 +1,17 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsString()
id: string;
@IsString()
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
}

View File

@ -16,6 +16,7 @@ import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yah
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { PrismaService } from '../services/prisma.service';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
@ -32,6 +33,7 @@ import { UserModule } from './user/user.module';
imports: [
AdminModule,
AccessModule,
AccountModule,
AuthModule,
CacheModule,
ConfigModule.forRoot(),

View File

@ -7,7 +7,7 @@ import { Injectable } from '@nestjs/common';
import { Currency, Type } from '@prisma/client';
import { parseISO } from 'date-fns';
import { OrderWithPlatform } from '../order/interfaces/order-with-platform.type';
import { OrderWithAccount } from '../order/interfaces/order-with-account.type';
import { CreateOrderDto } from './create-order.dto';
import { Data } from './interfaces/data.interface';
@ -33,12 +33,13 @@ export class ExperimentalService {
aDate: Date,
aBaseCurrency: Currency
): Promise<Data> {
const ordersWithPlatform: OrderWithPlatform[] = aOrders.map((order) => {
const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => {
return {
...order,
accountId: undefined,
accountUserId: undefined,
createdAt: new Date(),
dataSource: undefined,
date: parseISO(order.date),
fee: 0,
id: undefined,

View File

@ -1,4 +1,4 @@
import { Currency, Type } from '@prisma/client';
import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateOrderDto {
@ -8,16 +8,15 @@ export class CreateOrderDto {
@IsString()
currency: Currency;
@IsString()
dataSource: DataSource;
@IsISO8601()
date: string;
@IsNumber()
fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsNumber()
quantity: number;

View File

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

View File

@ -1,3 +0,0 @@
import { Order, Platform } from '@prisma/client';
export type OrderWithPlatform = Order & { Platform?: Platform };

View File

@ -71,7 +71,11 @@ export class OrderController {
let orders = await this.orderService.orders({
include: {
Platform: true
Account: {
include: {
Platform: true
}
}
},
orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id }
@ -121,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')
@ -180,65 +162,38 @@ export class OrderController {
}
});
if (!originalOrder) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(data.date);
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

@ -5,7 +5,7 @@ import { Order, Prisma } from '@prisma/client';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { OrderWithPlatform } from './interfaces/order-with-platform.type';
import { OrderWithAccount } from './interfaces/order-with-account.type';
@Injectable()
export class OrderService {
@ -31,7 +31,7 @@ export class OrderService {
cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput;
}): Promise<OrderWithPlatform[]> {
}): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.order.findMany({

View File

@ -1,4 +1,4 @@
import { Currency, Type } from '@prisma/client';
import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateOrderDto {
@ -8,16 +8,15 @@ export class UpdateOrderDto {
@IsString()
currency: Currency;
@IsString()
dataSource: DataSource;
@IsISO8601()
date: string;
@IsNumber()
fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsString()
id: string;

View File

@ -16,4 +16,5 @@ export interface Position {
investmentInOriginalCurrency?: number;
marketPrice?: number;
quantity: number;
transactionCount: number;
}

View File

@ -1,6 +1,8 @@
import { Currency } from '@prisma/client';
export interface PortfolioPositionDetail {
averagePrice: number;
currency: string;
currency: Currency;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;
@ -11,6 +13,7 @@ export interface PortfolioPositionDetail {
minPrice: number;
quantity: number;
symbol: string;
transactionCount: number;
}
export interface HistoricalDataItem {

View File

@ -2,6 +2,9 @@ import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Currency } from '@prisma/client';
export interface PortfolioPosition {
accounts: {
[name: string]: { current: number; original: number };
};
currency: Currency;
exchange?: string;
grossPerformance: number;
@ -13,13 +16,11 @@ export interface PortfolioPosition {
marketPrice: number;
marketState: MarketState;
name: string;
platforms: {
[name: string]: { current: number; original: number };
};
quantity: number;
sector?: string;
shareCurrent: number;
shareInvestment: number;
transactionCount: number;
symbol: string;
type?: string;
url?: string;

View File

@ -185,11 +185,11 @@ export class PortfolioController {
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
for (const [platform, { current, original }] of Object.entries(
portfolioPosition.platforms
for (const [account, { current, original }] of Object.entries(
portfolioPosition.accounts
)) {
portfolioPosition.platforms[platform].current = current / totalValue;
portfolioPosition.platforms[platform].original =
portfolioPosition.accounts[account].current = current / totalValue;
portfolioPosition.accounts[account].original =
original / totalInvestment;
}

View File

@ -73,7 +73,7 @@ export class PortfolioService {
// Get portfolio from database
const orders = await this.orderService.orders({
include: {
Platform: true
Account: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
@ -209,7 +209,8 @@ export class PortfolioService {
firstBuyDate,
investment,
marketPrice,
quantity
quantity,
transactionCount
} = portfolio.getPositions(new Date())[aSymbol];
const historicalData = await this.dataProviderService.getHistorical(
@ -262,6 +263,7 @@ export class PortfolioService {
maxPrice,
minPrice,
quantity,
transactionCount,
grossPerformance: this.exchangeRateDataService.toCurrency(
marketPrice - averagePrice,
currency,
@ -315,7 +317,8 @@ export class PortfolioService {
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol
symbol: aSymbol,
transactionCount: undefined
};
}
@ -331,7 +334,8 @@ export class PortfolioService {
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol
symbol: aSymbol,
transactionCount: undefined
};
}

View File

@ -1,6 +1,7 @@
import { Currency } from '@prisma/client';
import { Currency, DataSource } from '@prisma/client';
export interface SymbolItem {
currency: Currency;
dataSource: DataSource;
marketPrice: number;
}

View File

@ -1,4 +1,6 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
@ -10,31 +12,51 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable()
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService
private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
) {}
public async get(aSymbol: string): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]);
const { currency, marketPrice } = response[aSymbol];
const { currency, dataSource, marketPrice } = response[aSymbol];
return {
dataSource,
marketPrice,
currency: <Currency>(<unknown>currency)
};
}
public async lookup(aQuery: string): Promise<LookupItem[]> {
public async lookup(aQuery = ''): Promise<LookupItem[]> {
const query = aQuery.toLowerCase();
const results: LookupItem[] = [];
if (!query) {
return results;
}
const get = bent(
`https://query1.finance.yahoo.com/v1/finance/search?q=${aQuery}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
`https://query1.finance.yahoo.com/v1/finance/search?q=${query}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
// Add custom symbols
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
scraperConfigurations.forEach((scraperConfiguration) => {
if (scraperConfiguration.name.toLowerCase().startsWith(query)) {
results.push({
name: scraperConfiguration.name,
symbol: scraperConfiguration.symbol
});
}
});
try {
const { quotes } = await get();
return quotes
const searchResult = quotes
.filter(({ isYahooFinance }) => {
return isYahooFinance;
})
@ -59,6 +81,8 @@ export class SymbolService {
symbol: convertFromYahooSymbol(symbol)
};
});
return results.concat(searchResult);
} catch (error) {
console.error(error);

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

@ -1,27 +1,27 @@
import { Currency, Platform } from '@prisma/client';
import { Account, Currency, Platform } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
import { OrderType } from './order-type';
export class Order {
private account: Account;
private currency: Currency;
private fee: number;
private date: string;
private id: string;
private quantity: number;
private platform: Platform;
private symbol: string;
private total: number;
private type: OrderType;
private unitPrice: number;
public constructor(data: IOrder) {
this.account = data.account;
this.currency = data.currency;
this.fee = data.fee;
this.date = data.date;
this.id = data.id || uuidv4();
this.platform = data.platform;
this.quantity = data.quantity;
this.symbol = data.symbol;
this.type = data.type;
@ -30,6 +30,10 @@ export class Order {
this.total = this.quantity * data.unitPrice;
}
public getAccount() {
return this.account;
}
public getCurrency() {
return this.currency;
}
@ -46,10 +50,6 @@ export class Order {
return this.id;
}
public getPlatform() {
return this.platform;
}
public getQuantity() {
return this.quantity;
}

View File

@ -1,6 +1,6 @@
import { baseCurrency, getUtc, getYesterday } from '@ghostfolio/helper';
import { Test } from '@nestjs/testing';
import { Currency, Role, Type } from '@prisma/client';
import { AccountType, Currency, DataSource, Role, Type } from '@prisma/client';
import { ConfigurationService } from '../services/configuration.service';
import { DataProviderService } from '../services/data-provider.service';
@ -70,6 +70,18 @@ describe('Portfolio', () => {
);
portfolio.setUser({
accessToken: null,
Account: [
{
accountType: AccountType.SECURITIES,
createdAt: new Date(),
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
platformId: null,
updatedAt: new Date(),
userId: USER_ID
}
],
alias: 'Test',
createdAt: new Date(),
id: USER_ID,
@ -133,10 +145,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(),
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
platformId: null,
quantity: 1,
symbol: 'BTCUSD',
type: Type.BUY,
@ -157,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,
@ -183,10 +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: 1,
type: 'Cryptocurrency'
}
});
@ -233,10 +246,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
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,
@ -257,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,
@ -282,9 +284,21 @@ 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,
transactionCount: 1,
symbol: 'ETHUSD',
type: 'Cryptocurrency'
}
@ -327,10 +341,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
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,
@ -343,10 +357,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
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,
@ -403,10 +417,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.EUR,
dataSource: DataSource.YAHOO,
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,
@ -419,10 +433,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
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,
@ -492,10 +506,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
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,
@ -508,10 +522,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
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,
@ -524,10 +538,10 @@ describe('Portfolio', () => {
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
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

@ -23,7 +23,7 @@ import { cloneDeep, isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { UserWithSettings } from '../app/interfaces/user-with-settings';
import { OrderWithPlatform } from '../app/order/interfaces/order-with-platform.type';
import { OrderWithAccount } from '../app/order/interfaces/order-with-account.type';
import { DateRange } from '../app/portfolio/interfaces/date-range.type';
import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
@ -34,14 +34,14 @@ import { IOrder } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment';
import { PlatformClusterRiskCurrentInvestment } from './rules/platform-cluster-risk/current-investment';
import { PlatformClusterRiskInitialInvestment } from './rules/platform-cluster-risk/initial-investment';
import { PlatformClusterRiskSinglePlatform } from './rules/platform-cluster-risk/single-platform';
export class Portfolio implements PortfolioInterface {
private orders: Order[] = [];
@ -57,7 +57,7 @@ export class Portfolio implements PortfolioInterface {
public async addCurrentPortfolioItems() {
const currentData = await this.dataProviderService.get(this.getSymbols());
let currentDate = new Date();
const currentDate = new Date();
const year = getYear(currentDate);
const month = getMonth(currentDate);
@ -82,7 +82,9 @@ export class Portfolio implements PortfolioInterface {
marketPrice:
currentData[symbol]?.marketPrice ??
portfolioItemsYesterday.positions[symbol]?.marketPrice,
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity,
transactionCount:
portfolioItemsYesterday?.positions[symbol]?.transactionCount
};
});
@ -117,11 +119,11 @@ export class Portfolio implements PortfolioInterface {
}): Portfolio {
orders.forEach(
({
account,
currency,
fee,
date,
id,
platform,
quantity,
symbol,
type,
@ -129,11 +131,11 @@ export class Portfolio implements PortfolioInterface {
}) => {
this.orders.push(
new Order({
account,
currency,
fee,
date,
id,
platform,
quantity,
symbol,
type,
@ -200,7 +202,7 @@ export class Portfolio implements PortfolioInterface {
const data = await this.dataProviderService.get(symbols);
symbols.forEach((symbol) => {
const platforms: PortfolioPosition['platforms'] = {};
const accounts: PortfolioPosition['accounts'] = {};
const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => {
@ -225,15 +227,15 @@ export class Portfolio implements PortfolioInterface {
originalValueOfSymbol *= -1;
}
if (platforms[orderOfSymbol.getPlatform()?.name || 'Other']?.current) {
platforms[
orderOfSymbol.getPlatform()?.name || 'Other'
if (accounts[orderOfSymbol.getAccount()?.name || 'Other']?.current) {
accounts[
orderOfSymbol.getAccount()?.name || 'Other'
].current += currentValueOfSymbol;
platforms[
orderOfSymbol.getPlatform()?.name || 'Other'
accounts[
orderOfSymbol.getAccount()?.name || 'Other'
].original += originalValueOfSymbol;
} else {
platforms[orderOfSymbol.getPlatform()?.name || 'Other'] = {
accounts[orderOfSymbol.getAccount()?.name || 'Other'] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
@ -274,7 +276,7 @@ export class Portfolio implements PortfolioInterface {
details[symbol] = {
...data[symbol],
platforms,
accounts,
symbol,
grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before),
@ -289,7 +291,9 @@ export class Portfolio implements PortfolioInterface {
data[symbol]?.currency,
this.user.Settings.currency
) / value,
shareInvestment: portfolioItem.positions[symbol].investment / investment
shareInvestment:
portfolioItem.positions[symbol].investment / investment,
transactionCount: portfolioItem.positions[symbol].transactionCount
};
});
@ -392,6 +396,19 @@ export class Portfolio implements PortfolioInterface {
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
this,
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
],
{ baseCurrency: this.user.Settings.currency }
),
currencyClusterRisk: await this.rulesService.evaluate(
this,
[
@ -410,19 +427,6 @@ export class Portfolio implements PortfolioInterface {
],
{ baseCurrency: this.user.Settings.currency }
),
platformClusterRisk: await this.rulesService.evaluate(
this,
[
new PlatformClusterRiskSinglePlatform(this.exchangeRateDataService),
new PlatformClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new PlatformClusterRiskCurrentInvestment(
this.exchangeRateDataService
)
],
{ baseCurrency: this.user.Settings.currency }
),
fees: await this.rulesService.evaluate(
this,
[new FeeRatioInitialInvestment(this.exchangeRateDataService)],
@ -518,17 +522,17 @@ export class Portfolio implements PortfolioInterface {
return isFinite(value) ? value : null;
}
public async setOrders(aOrders: OrderWithPlatform[]) {
public async setOrders(aOrders: OrderWithAccount[]) {
this.orders = [];
// Map data
aOrders.forEach((order) => {
this.orders.push(
new Order({
account: order.Account,
currency: <any>order.currency,
date: order.date.toISOString(),
fee: order.fee,
platform: order.Platform,
quantity: order.quantity,
symbol: order.symbol,
type: <any>order.type,
@ -582,7 +586,8 @@ export class Portfolio implements PortfolioInterface {
marketPrice:
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
?.marketPrice || 0,
quantity: 0
quantity: 0,
transactionCount: 0
};
});
@ -623,7 +628,8 @@ export class Portfolio implements PortfolioInterface {
marketPrice:
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
?.marketPrice || 0,
quantity: 0
quantity: 0,
transactionCount: 0
};
});
@ -730,6 +736,10 @@ export class Portfolio implements PortfolioInterface {
order.getSymbol()
].currency = order.getCurrency();
this.portfolioItems[i].positions[
order.getSymbol()
].transactionCount += 1;
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate

View File

@ -3,7 +3,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { Rule } from '../../rule';
export class PlatformClusterRiskCurrentInvestment extends Rule {
export class AccountClusterRiskCurrentInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, {
name: 'Current Investment'
@ -18,24 +18,22 @@ export class PlatformClusterRiskCurrentInvestment extends Rule {
}
) {
const ruleSettings =
aRuleSettingsMap[PlatformClusterRiskCurrentInvestment.name];
aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name];
const platforms: {
const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number;
};
} = {};
Object.values(aPositions).forEach((position) => {
for (const [platform, { current }] of Object.entries(
position.platforms
)) {
if (platforms[platform]?.investment) {
platforms[platform].investment += current;
for (const [account, { current }] of Object.entries(position.accounts)) {
if (accounts[account]?.investment) {
accounts[account].investment += current;
} else {
platforms[platform] = {
accounts[account] = {
investment: current,
name: platform
name: account
};
}
}
@ -44,17 +42,17 @@ export class PlatformClusterRiskCurrentInvestment extends Rule {
let maxItem;
let totalInvestment = 0;
Object.values(platforms).forEach((platform) => {
Object.values(accounts).forEach((account) => {
if (!maxItem) {
maxItem = platform;
maxItem = account;
}
// Calculate total investment
totalInvestment += platform.investment;
totalInvestment += account.investment;
// Find maximum
if (platform.investment > maxItem?.investment) {
maxItem = platform;
if (account.investment > maxItem?.investment) {
maxItem = account;
}
});

View File

@ -3,7 +3,7 @@ import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-dat
import { Rule } from '../../rule';
export class PlatformClusterRiskInitialInvestment extends Rule {
export class AccountClusterRiskInitialInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, {
name: 'Initial Investment'
@ -18,7 +18,7 @@ export class PlatformClusterRiskInitialInvestment extends Rule {
}
) {
const ruleSettings =
aRuleSettingsMap[PlatformClusterRiskInitialInvestment.name];
aRuleSettingsMap[AccountClusterRiskInitialInvestment.name];
const platforms: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
@ -27,15 +27,13 @@ export class PlatformClusterRiskInitialInvestment extends Rule {
} = {};
Object.values(aPositions).forEach((position) => {
for (const [platform, { original }] of Object.entries(
position.platforms
)) {
if (platforms[platform]?.investment) {
platforms[platform].investment += original;
for (const [account, { original }] of Object.entries(position.accounts)) {
if (platforms[account]?.investment) {
platforms[account].investment += original;
} else {
platforms[platform] = {
platforms[account] = {
investment: original,
name: platform
name: account
};
}
}

View File

@ -3,33 +3,33 @@ import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-dat
import { Rule } from '../../rule';
export class PlatformClusterRiskSinglePlatform extends Rule {
export class AccountClusterRiskSingleAccount extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, {
name: 'Single Platform'
name: 'Single Account'
});
}
public evaluate(positions: { [symbol: string]: PortfolioPosition }) {
const platforms: string[] = [];
const accounts: string[] = [];
Object.values(positions).forEach((position) => {
for (const [platform] of Object.entries(position.platforms)) {
if (!platforms.includes(platform)) {
platforms.push(platform);
for (const [account] of Object.entries(position.accounts)) {
if (!accounts.includes(account)) {
accounts.push(account);
}
}
});
if (platforms.length === 1) {
if (accounts.length === 1) {
return {
evaluation: `All your investment is managed by a single platform`,
evaluation: `All your investment is managed by a single account`,
value: false
};
}
return {
evaluation: `Your investment is managed by ${platforms.length} platforms`,
evaluation: `Your investment is managed by ${accounts.length} accounts`,
value: true
};
}

View File

@ -18,6 +18,7 @@ import {
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { PrismaService } from './prisma.service';
@Injectable()
@ -25,6 +26,7 @@ export class DataGatheringService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private prisma: PrismaService
) {}
@ -183,6 +185,17 @@ export class DataGatheringService {
}
}
public async getCustomSymbolsToGather(startDate?: Date) {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => {
return {
date: startDate,
symbol: scraperConfiguration.symbol
};
});
}
private getBenchmarksToGather(startDate: Date) {
const benchmarksToGather = benchmarks.map((symbol) => {
return {
@ -201,32 +214,6 @@ export class DataGatheringService {
return benchmarksToGather;
}
private async getCustomSymbolsToGather(startDate: Date) {
const customSymbolsToGather = [];
if (this.configurationService.get('ENABLE_FEATURE_CUSTOM_SYMBOLS')) {
try {
const {
value: scraperConfigString
} = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
JSON.parse(scraperConfigString).forEach((item) => {
customSymbolsToGather.push({
date: startDate,
symbol: item.symbol
});
});
} catch {}
}
return customSymbolsToGather;
}
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
const startDate = subDays(resetHours(new Date()), 7);

View File

@ -1,5 +1,6 @@
import { getYesterday } from '@ghostfolio/helper';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { format } from 'date-fns';
@ -12,6 +13,7 @@ import {
MarketState
} from '../../interfaces/interfaces';
import { PrismaService } from '../../prisma.service';
import { ScraperConfig } from './interfaces/scraper-config.interface';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
@ -29,7 +31,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try {
const symbol = aSymbols[0];
const scraperConfig = await this.getScraperConfig(symbol);
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
const { marketPrice } = await this.prisma.marketData.findFirst({
orderBy: {
@ -44,6 +46,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: {
marketPrice,
currency: scraperConfig?.currency,
dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed,
name: scraperConfig?.name
}
@ -70,15 +73,17 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try {
const symbol = aSymbols[0];
const scraperConfig = await this.getScraperConfig(symbol);
const scraperConfiguration = await this.getScraperConfigurationBySymbol(
symbol
);
const get = bent(scraperConfig?.url, 'GET', 'string', 200, {});
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = this.extractNumberFromString(
$(scraperConfig?.selector).text()
$(scraperConfiguration?.selector).text()
);
return {
@ -95,6 +100,23 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return {};
}
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try {
const {
value: scraperConfigString
} = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString);
} catch {}
return [];
}
private extractNumberFromString(aString: string): number {
try {
const [numberString] = aString.match(
@ -106,22 +128,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
}
private async getScraperConfig(aSymbol: string) {
try {
const {
value: scraperConfigString
} = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString).find((item) => {
return item.symbol === aSymbol;
});
} catch {}
return {};
private async getScraperConfigurationBySymbol(aSymbol: string) {
const scraperConfigurations = await this.getScraperConfigurations();
return scraperConfigurations.find((scraperConfiguration) => {
return scraperConfiguration.symbol === aSymbol;
});
}
}

View File

@ -0,0 +1,9 @@
import { Currency } from '@prisma/client';
export interface ScraperConfig {
currency: Currency;
name: string;
selector: string;
symbol: string;
url: string;
}

View File

@ -1,5 +1,6 @@
import { getToday, getYesterday } from '@ghostfolio/helper';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns';
@ -39,6 +40,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {
'GF.FEAR_AND_GREED_INDEX': {
currency: undefined,
dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value,
marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME

View File

@ -1,5 +1,6 @@
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/helper';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance';
@ -49,6 +50,7 @@ export class YahooFinanceService implements DataProviderInterface {
response[symbol] = {
currency: parseCurrency(value.price?.currency),
dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName),
marketState:
value.price?.marketState === 'REGULAR' || isCrypto(symbol)

View File

@ -1,4 +1,4 @@
import { Currency, Platform } from '@prisma/client';
import { Account, Currency, DataSource, Platform } from '@prisma/client';
import { OrderType } from '../../models/order-type';
@ -33,11 +33,11 @@ export const Type = {
};
export interface IOrder {
account: Account;
currency: Currency;
date: string;
fee: number;
id?: string;
platform: Platform;
quantity: number;
symbol: string;
type: OrderType;
@ -51,6 +51,7 @@ export interface IDataProviderHistoricalResponse {
export interface IDataProviderResponse {
currency: Currency;
dataSource: DataSource;
exchange?: string;
industry?: Industry;
marketChange?: number;

View File

@ -2,14 +2,14 @@ import { Injectable } from '@nestjs/common';
import { Portfolio } from '../models/portfolio';
import { Rule } from '../models/rule';
import { AccountClusterRiskCurrentInvestment } from '../models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '../models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '../models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '../models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '../models/rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from '../models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '../models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '../models/rules/fees/fee-ratio-initial-investment';
import { PlatformClusterRiskCurrentInvestment } from '../models/rules/platform-cluster-risk/current-investment';
import { PlatformClusterRiskInitialInvestment } from '../models/rules/platform-cluster-risk/initial-investment';
import { PlatformClusterRiskSinglePlatform } from '../models/rules/platform-cluster-risk/single-platform';
@Injectable()
export class RulesService {
@ -39,6 +39,17 @@ export class RulesService {
private getDefaultRuleSettings(aUserSettings: { baseCurrency: string }) {
return {
[AccountClusterRiskCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[AccountClusterRiskInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[AccountClusterRiskSingleAccount.name]: { isActive: true },
[CurrencyClusterRiskBaseCurrencyInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
@ -61,18 +72,7 @@ export class RulesService {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.01
},
[PlatformClusterRiskCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[PlatformClusterRiskInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[PlatformClusterRiskSinglePlatform.name]: { isActive: true }
}
};
}
}

View File

@ -9,11 +9,6 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
},
{
path: 'admin',
loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
},
{
path: 'account',
loadChildren: () =>
@ -22,9 +17,16 @@ const routes: Routes = [
)
},
{
path: 'auth',
path: 'accounts',
loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
import('./pages/accounts/accounts-page.module').then(
(m) => m.AccountsPageModule
)
},
{
path: 'admin',
loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
},
{
path: 'analysis',
@ -33,11 +35,23 @@ const routes: Routes = [
(m) => m.AnalysisPageModule
)
},
{
path: 'auth',
loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
},
{
path: 'home',
loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
},
{
path: 'pricing',
loadChildren: () =>
import('./pages/pricing/pricing-page.module').then(
(m) => m.PricingPageModule
)
},
{
path: 'report',
loadChildren: () =>

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
@ -74,7 +73,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createAccount
permissions.createUserAccount
);
this.cd.markForCheck();

View File

@ -1,14 +1,14 @@
<table mat-table [dataSource]="dataSource" class="w-100">
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="granteeAlias">
<th mat-header-cell *matHeaderCellDef i18n>User</th>
<td mat-cell *matCellDef="let element">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>User</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.granteeAlias }}
</td></ng-container
>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef i18n>Type</th>
<td mat-cell *matCellDef="let element">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
Restricted Access
</td></ng-container

View File

@ -1,3 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -0,0 +1,81 @@
<table
class="gf-table w-100"
matSort
matSortActive="account"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Name
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="platform">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Platform
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
*ngIf="element.Platform?.url"
class="mr-1"
[tooltip]=""
[url]="element.Platform?.url"
></gf-symbol-icon>
<span>{{ element.Platform?.name }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<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 (click)="onUpdateAccount(element)">
Edit
</button>
<button
i18n
mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0"
(click)="onDeleteAccount(element.id)"
>
Delete
</button>
</mat-menu>
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Transactions</th>
<td *matCellDef="let element" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>

View File

@ -0,0 +1,27 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
::ng-deep {
.mat-form-field-infix {
border-top: 0 solid transparent !important;
}
}
.mat-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
}
}
}
}
}
:host-context(.is-dark-theme) {
.mat-form-field {
color: rgba(var(--light-primary-text));
}
}

View File

@ -0,0 +1,85 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
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 { Account as AccountModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@Component({
selector: 'gf-accounts-table',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './accounts-table.component.html',
styleUrls: ['./accounts-table.component.scss']
})
export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() accounts: AccountModel[];
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() locale: string;
@Input() showActions: boolean;
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
public routeQueryParams: Subscription;
private unsubscribeSubject = new Subject<void>();
public constructor(
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {}
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions'];
if (this.showActions) {
this.displayedColumns.push('actions');
}
this.isLoading = true;
if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.isLoading = false;
}
}
public onDeleteAccount(aId: string) {
const confirmation = confirm('Do you really want to delete this account?');
if (confirmation) {
this.accountDeleted.emit(aId);
}
}
public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { AccountsTableComponent } from './accounts-table.component';
@NgModule({
declarations: [AccountsTableComponent],
exports: [AccountsTableComponent],
imports: [
CommonModule,
GfSymbolIconModule,
GfValueModule,
MatButtonModule,
MatInputModule,
MatMenuModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAccountsTableModule {}

View File

@ -6,59 +6,76 @@
<span class="spacer"></span>
<a
class="d-none d-sm-block"
[routerLink]="['/']"
i18n
mat-flat-button
[color]="currentRoute === 'home' ? 'primary' : null"
[color]="currentRoute?.startsWith('home') ? 'primary' : null"
[routerLink]="['/']"
>Overview</a
>
<a
class="d-none d-sm-block mx-1"
[routerLink]="['/analysis']"
i18n
mat-flat-button
[color]="currentRoute === 'analysis' ? 'primary' : null"
[color]="currentRoute?.startsWith('analysis') ? 'primary' : null"
[routerLink]="['/analysis']"
>Analysis</a
>
<a
class="d-none d-sm-block mx-1"
[routerLink]="['/report']"
i18n
mat-flat-button
[color]="currentRoute === 'report' ? 'primary' : null"
[color]="currentRoute?.startsWith('report') ? 'primary' : null"
[routerLink]="['/report']"
>X-ray</a
>
<a
class="d-none d-sm-block mx-1"
[routerLink]="['/transactions']"
i18n
mat-flat-button
[color]="currentRoute === 'transactions' ? 'primary' : null"
[color]="currentRoute?.startsWith('transactions') ? 'primary' : null"
[routerLink]="['/transactions']"
>Transactions</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('accounts') ? 'primary' : null"
[routerLink]="['/accounts']"
>Accounts</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-none d-sm-block mx-1"
[routerLink]="['/admin']"
i18n
mat-flat-button
[color]="currentRoute === 'admin' ? 'primary' : null"
[color]="currentRoute?.startsWith('admin') ? 'primary' : null"
[routerLink]="['/admin']"
>Admin Control</a
>
<a
class="d-none d-sm-block mx-1"
[routerLink]="['/resources']"
i18n
mat-flat-button
[color]="currentRoute === 'resources' ? 'primary' : null"
[color]="currentRoute?.startsWith('resources') ? 'primary' : null"
[routerLink]="['/resources']"
>Resources</a
>
<a
*ngIf="hasPermissionForSubscription"
class="d-none d-sm-block mx-1"
[routerLink]="['/about']"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
[color]="currentRoute?.startsWith('pricing') ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
[routerLink]="['/about']"
>About</a
>
<button
@ -119,60 +136,81 @@
</ng-container>
<a
class="d-block d-sm-none"
[routerLink]="['/analysis']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('analysis') }"
[routerLink]="['/analysis']"
>Analysis</a
>
<a
class="d-block d-sm-none"
[routerLink]="['/report']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('report') }"
[routerLink]="['/report']"
>X-ray</a
>
<a
class="d-block d-sm-none"
[routerLink]="['/transactions']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'transactions' }"
[ngClass]="{
'font-weight-bold': currentRoute?.startsWith('transactions')
}"
[routerLink]="['/transactions']"
>Transactions</a
>
<a
class="align-items-center d-flex"
[routerLink]="['/account']"
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
>Account</a
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('accounts') }"
[routerLink]="['/accounts']"
>Accounts</a
>
<a
class="align-items-center d-flex"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('account') }"
[routerLink]="['/account']"
>Ghostfolio Account</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-block d-sm-none"
[routerLink]="['/admin']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('admin') }"
[routerLink]="['/admin']"
>Admin Control</a
>
<hr class="m-0" />
<a
class="d-block d-sm-none"
[routerLink]="['/resources']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'resources' }"
[ngClass]="{
'font-weight-bold': currentRoute?.startsWith('resources')
}"
[routerLink]="['/resources']"
>Resources</a
>
<a
*ngIf="hasPermissionForSubscription"
class="d-block d-sm-none"
[routerLink]="['/about']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('pricing') }"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('about') }"
[routerLink]="['/about']"
>About Ghostfolio</a
>
<hr class="d-block d-sm-none m-0" />
@ -182,19 +220,19 @@
<ng-container *ngIf="user === null">
<a
*ngIf="currentRoute && currentRoute !== 'start'"
[routerLink]="['/']"
class="mx-2 no-min-width px-2"
mat-button
[routerLink]="['/']"
>
<gf-logo></gf-logo>
</a>
<span class="spacer"></span>
<a
class="d-none d-sm-block mx-1"
[routerLink]="['/about']"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
[routerLink]="['/about']"
>About</a
>
<a

View File

@ -27,8 +27,9 @@ export class HeaderComponent implements OnChanges {
@Input() info: InfoItem;
@Input() user: User;
public hasPermissionToAccessAdminControl: boolean;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public impersonationId: string;
private unsubscribeSubject = new Subject<void>();
@ -49,16 +50,21 @@ export class HeaderComponent implements OnChanges {
public ngOnChanges() {
if (this.user) {
this.hasPermissionForSocialLogin = hasPermission(
this.info?.globalPermissions,
permissions.enableSocialLogin
);
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToAccessAdminControl = hasPermission(
this.user.permissions,
permissions.accessAdminControl
);
}
this.hasPermissionForSocialLogin = hasPermission(
this.info?.globalPermissions,
permissions.enableSocialLogin
);
}
public impersonateAccount(aId: string) {

View File

@ -36,6 +36,10 @@ export class PortfolioProportionChartComponent
public chart: Chart;
public isLoading = true;
private colorMap: {
[symbol: string]: string;
} = {};
public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
}
@ -48,37 +52,67 @@ export class PortfolioProportionChartComponent
}
}
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() {
this.isLoading = true;
const chartData: { [symbol: string]: number } = {};
const chartData: {
[symbol: string]: { color?: string; value: number };
} = {};
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.key]) {
if (chartData[this.positions[symbol][this.key]]) {
chartData[this.positions[symbol][this.key]] += this.positions[
chartData[this.positions[symbol][this.key]].value += this.positions[
symbol
].value;
} else {
chartData[this.positions[symbol][this.key]] = this.positions[
symbol
].value;
chartData[this.positions[symbol][this.key]] = {
value: this.positions[symbol].value
};
}
} else {
if (chartData['Other']) {
chartData['Other'].value += this.positions[symbol].value;
} else {
chartData['Other'] = {
value: this.positions[symbol].value
};
}
}
});
const chartDataSorted = Object.entries(chartData)
.sort((a, b) => {
return a[1] - b[1];
return a[1].value - b[1].value;
})
.reverse();
chartDataSorted.forEach(([symbol, item], index) => {
if (this.colorMap[symbol]) {
// Reuse color
item.color = this.colorMap[symbol];
} else {
const color = this.getColorPalette()[index];
// Store color for reuse
this.colorMap[symbol] = color;
item.color = color;
}
});
const data = {
datasets: [
{
backgroundColor: this.getColorPalette(),
backgroundColor: chartDataSorted.map(([, item]) => {
return item.color;
}),
borderWidth: 0,
data: chartDataSorted.map(([, value]) => {
return value;
data: chartDataSorted.map(([, item]) => {
return item.value;
})
}
],
@ -100,21 +134,13 @@ export class PortfolioProportionChartComponent
tooltip: {
callbacks: {
label: (context) => {
const label = data.labels[context.dataIndex];
const label = context.label;
if (this.isInPercent) {
const value =
100 *
data.datasets[context.datasetIndex].data[
context.dataIndex
];
const value = 100 * <number>context.raw;
return `${label} (${value.toFixed(2)}%)`;
} else {
const value =
data.datasets[context.datasetIndex].data[
context.dataIndex
];
const value = <number>context.raw;
return `${label} (${value.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
@ -153,8 +179,4 @@ export class PortfolioProportionChartComponent
'#cc5de8' // grape 5
];
}
public ngOnDestroy() {
this.chart?.destroy();
}
}

View File

@ -23,6 +23,7 @@ export class PositionDetailDialog {
public benchmarkDataItems: LineChartItem[];
public currency: string;
public firstBuyDate: string;
public grossPerformance: number;
public grossPerformancePercent: number;
public historicalDataItems: LineChartItem[];
public investment: number;
@ -30,6 +31,7 @@ export class PositionDetailDialog {
public maxPrice: number;
public minPrice: number;
public quantity: number;
public transactionCount: number;
public constructor(
private cd: ChangeDetectorRef,
@ -44,18 +46,21 @@ export class PositionDetailDialog {
averagePrice,
currency,
firstBuyDate,
grossPerformance,
grossPerformancePercent,
historicalData,
investment,
marketPrice,
maxPrice,
minPrice,
quantity
quantity,
transactionCount
}) => {
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.currency = currency;
this.firstBuyDate = firstBuyDate;
this.grossPerformance = quantity * grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;
this.historicalDataItems = historicalData.map(
(historicalDataItem) => {
@ -75,6 +80,7 @@ export class PositionDetailDialog {
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.quantity = quantity;
this.transactionCount = transactionCount;
if (isToday(parseISO(this.firstBuyDate))) {
// Add average price

View File

@ -19,6 +19,16 @@
></gf-line-chart>
<div class="row">
<div class="col-6 mb-3">
<gf-value
label="Change"
size="medium"
[colorizeSign]="true"
[currency]="data.baseCurrency"
[locale]="data.locale"
[value]="grossPerformance"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Performance"
@ -29,13 +39,6 @@
[value]="grossPerformancePercent"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="First Buy Date"
size="medium"
[value]="firstBuyDate"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Ø Buy Price"
@ -91,6 +94,22 @@
[value]="investment"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="First Buy Date"
size="medium"
[value]="firstBuyDate"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Transactions"
size="medium"
[isCurrency]="true"
[locale]="data.locale"
[value]="transactionCount"
></gf-value>
</div>
</div>
</div>
</div>

View File

@ -1,10 +1,5 @@
<!--<mat-form-field appearance="outline" class="w-100">
<input #input autocomplete="off" matInput (keyup)="applyFilter($event)" />
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
</mat-form-field>-->
<table
class="w-100"
class="gf-table w-100"
matSort
matSortActive="shareCurrent"
matSortDirection="desc"
@ -12,20 +7,24 @@
[dataSource]="dataSource"
>
<ng-container matColumnDef="symbol">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Symbol</th>
<td mat-cell *matCellDef="let element">{{ element.symbol | gfSymbol }}</td>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Symbol
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol | gfSymbol }}
</td>
</ng-container>
<ng-container matColumnDef="performance">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell text-right"
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Performance
</th>
<td class="d-none d-lg-table-cell" mat-cell *matCellDef="let element">
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end">
<gf-value
[colorizeSign]="true"
@ -40,7 +39,7 @@
<ng-container matColumnDef="shareInvestment">
<th
*matHeaderCellDef
class="justify-content-end"
class="justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
@ -48,7 +47,7 @@
Initial Share
</th>
<td mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end">
<div class="d-flex justify-content-end px-1">
<gf-value
[isPercent]="true"
[locale]="locale"
@ -61,14 +60,14 @@
<ng-container matColumnDef="shareCurrent">
<th
*matHeaderCellDef
class="justify-content-end"
class="justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Current Share
</th>
<td mat-cell *matCellDef="let element">
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isPercent]="true"

View File

@ -1,3 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
@ -8,10 +10,6 @@
}
.mat-table {
td {
border: 0;
}
th {
::ng-deep {
.mat-sort-header-container {
@ -22,13 +20,6 @@
.mat-row {
cursor: pointer;
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
}
@ -37,15 +28,4 @@
.mat-form-field {
color: rgba(var(--light-primary-text));
}
.mat-table {
.mat-row {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
}

View File

@ -1,52 +1,59 @@
<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"
class="mx-1 my-0 px-2 py-0"
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"
[formControl]="searchControl"
[matAutocomplete]="autocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[placeholder]="placeholder"
(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
class="w-100"
class="gf-table w-100"
matSort
matSortActive="date"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="platform">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell text-center px-0"
i18n
mat-header-cell
>
Platform
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-0" mat-cell>
<div class="d-flex justify-content-center">
<gf-symbol-icon
*ngIf="element.Platform?.url"
[tooltip]="element.Platform?.name"
[url]="element.Platform?.url"
></gf-symbol-icon>
</div>
</td>
</ng-container>
<ng-container matColumnDef="date">
<th
*matHeaderCellDef
class="justify-content-center"
class="justify-content-center px-1"
i18n
mat-header-cell
mat-sort-header
>
Date
</th>
<td mat-cell *matCellDef="let element">
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-center">
{{ element.date | date: defaultDateFormat }}
</div>
@ -56,14 +63,14 @@
<ng-container matColumnDef="type">
<th
*matHeaderCellDef
class="justify-content-center"
class="justify-content-center px-1"
i18n
mat-header-cell
mat-sort-header
>
Type
</th>
<td mat-cell *matCellDef="let element" class="text-center">
<td mat-cell *matCellDef="let element" class="px-1 text-center">
<div
class="d-inline-flex justify-content-center pl-1 pr-2 py-1 type-badge"
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
@ -82,21 +89,25 @@
</ng-container>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Symbol</th>
<td mat-cell *matCellDef="let element">{{ element.symbol | gfSymbol }}</td>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Symbol
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol | gfSymbol }}
</td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-center"
class="d-none d-lg-table-cell justify-content-center px-1"
mat-header-cell
i18n
mat-sort-header
>
Currency
</th>
<td class="d-none d-lg-table-cell" mat-cell *matCellDef="let element">
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-center">
{{ element.currency }}
</div>
@ -106,14 +117,14 @@
<ng-container matColumnDef="quantity">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end"
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Quantity
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell" mat-cell>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
@ -127,14 +138,14 @@
<ng-container matColumnDef="unitPrice">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end"
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Unit Price
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell" mat-cell>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
@ -148,14 +159,14 @@
<ng-container matColumnDef="fee">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end"
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Fee
</th>
<td class="d-none d-lg-table-cell" mat-cell *matCellDef="let element">
<td *matCellDef="let element" class="d-none d-lg-table-cell px1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
@ -166,9 +177,26 @@
</td>
</ng-container>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Account
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
*ngIf="element.Account?.Platform?.url"
class="mr-1"
[tooltip]="element.Account?.Platform?.name"
[url]="element.Account?.Platform?.url"
></gf-symbol-icon>
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-0 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-0 text-center" mat-cell>
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
@ -181,6 +209,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

@ -1,3 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
@ -7,11 +9,12 @@
}
}
.mat-table {
td {
border: 0;
}
.mat-chip {
cursor: pointer;
min-height: 1.5rem !important;
}
.mat-table {
th {
::ng-deep {
.mat-sort-header-container {
@ -23,13 +26,6 @@
.mat-row {
cursor: pointer;
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
}
.type-badge {
background-color: rgba(var(--dark-primary-text), 0.05);
border-radius: 1rem;
@ -57,17 +53,8 @@
}
.mat-table {
.mat-row {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
}
.type-badge {
background-color: rgba(var(--light-primary-text), 0.1);
}
.type-badge {
background-color: rgba(var(--light-primary-text), 0.1);
}
}
}

View File

@ -1,6 +1,8 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
@ -9,17 +11,26 @@ import {
Output,
ViewChild
} from '@angular/core';
import { FormControl } from '@angular/forms';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
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';
const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...';
const SEARCH_STRING_SEPARATOR = ',';
@Component({
selector: 'gf-transactions-table',
changeDetection: ChangeDetectionStrategy.OnPush,
@ -32,19 +43,31 @@ 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 placeholder = '';
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,41 +85,90 @@ 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() {}
public ngOnChanges() {
this.displayedColumns = [
'platform',
'date',
'type',
'symbol',
'currency',
'quantity',
'unitPrice',
'fee'
'fee',
'account'
];
this.isLoading = true;
if (this.showActions) {
this.displayedColumns.push('actions');
}
this.isLoading = true;
if (this.transactions) {
this.dataSource = new MatTableDataSource(this.transactions);
this.dataSource.filterPredicate = (data, filter) => {
const dataString = this.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 +191,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 +228,51 @@ 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.placeholder =
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
this.allFilteredTransactions = this.getSearchableFieldValues(
this.transactions
).filter((item) => {
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
});
this.filteredTransactions$.next(this.allFilteredTransactions);
}
private getSearchableFieldValues(transactions: OrderWithAccount[]): string[] {
const fieldValues = new Set<string>();
for (const transaction of transactions) {
this.getFilterableValues(transaction, fieldValues);
}
return [...fieldValues]
.filter((item) => {
return item !== undefined;
})
.sort();
}
private getFilterableValues(
transaction: OrderWithAccount,
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) => {
return item !== undefined;
});
}
}

View File

@ -1,6 +1,9 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
@ -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,9 +1,8 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { baseCurrency, hasPermission, permissions } from '@ghostfolio/helper';
import { baseCurrency } from '@ghostfolio/helper';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -16,7 +15,6 @@ import { environment } from '../../../environments/environment';
})
export class AboutPageComponent implements OnInit {
public baseCurrency = baseCurrency;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
public lastPublish = environment.lastPublish;
public user: User;
@ -37,15 +35,6 @@ export class AboutPageComponent implements OnInit {
* Initializes the controller
*/
public ngOnInit() {
this.dataService.fetchInfo().subscribe((info) => {
this.hasPermissionForSubscription = hasPermission(
info.globalPermissions,
permissions.enableSubscription
);
this.cd.markForCheck();
});
this.isLoggedIn = !!this.tokenStorageService.getToken();
if (this.isLoggedIn)

View File

@ -61,105 +61,6 @@
</div>
</div>
<div *ngIf="hasPermissionForSubscription" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Pricing Plans</h3>
<div class="row">
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<h4 i18n>Open Source</h4>
<p>Host your <strong>Ghostfolio</strong> instance by yourself.</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
<p class="h5 text-right">
<span>Free</span>
</p>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<mat-card
class="mb-3"
[ngClass]="{ 'active': user?.subscription?.type === 'Trial' }"
>
<h4 class="align-items-center d-flex" i18n>
Diamond
<ion-icon
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</h4>
<p>Get a managed <strong>Ghostfolio</strong> cloud offering.</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
<p class="h5 text-right">
<span class="font-weight-normal"
>{{ user?.settings.baseCurrency || baseCurrency }}
<strong>2.99</strong>
<del class="ml-1 text-muted">3.99</del> / Month</span
>
</p>
</mat-card>
</div>
</div>
</div>
</div>
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3>

View File

@ -7,10 +7,6 @@
}
.mat-card {
&.active {
border-color: rgba(var(--palette-primary-500), 1);
}
&.changelog {
::ng-deep {
markdown {

View File

@ -8,11 +8,10 @@ import {
hasPermission,
permissions
} from '@ghostfolio/helper';
import { Currency } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { Currency } from '.prisma/client';
@Component({
selector: 'gf-account-page',
templateUrl: './account-page.html',

View File

@ -55,11 +55,7 @@
<div *ngIf="accesses?.length > 0" class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
<mat-card class="mb-3">
<mat-card-content>
<gf-access-table [accesses]="accesses"></gf-access-table>
</mat-card-content>
</mat-card>
<gf-access-table [accesses]="accesses"></gf-access-table>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountsPageComponent } from './accounts-page.component';
const routes: Routes = [
{ path: '', component: AccountsPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AccountsPageRoutingModule {}

View File

@ -0,0 +1,192 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
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 { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/helper';
import { Account as AccountModel, AccountType } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
@Component({
selector: 'gf-accounts-page',
templateUrl: './accounts-page.html',
styleUrls: ['./accounts-page.scss']
})
export class AccountsPageComponent implements OnInit {
public accounts: AccountModel[];
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateAccount: boolean;
public hasPermissionToDeleteAccount: boolean;
public routeQueryParams: Subscription;
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateAccountDialog();
} else if (params['editDialog']) {
if (this.accounts) {
const account = this.accounts.find((account) => {
return account.id === params['transactionId'];
});
this.openUpdateAccountDialog(account);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.hasPermissionToCreateAccount = hasPermission(
user.permissions,
permissions.createAccount
);
this.hasPermissionToDeleteAccount = hasPermission(
user.permissions,
permissions.deleteAccount
);
this.cd.markForCheck();
});
});
this.fetchAccounts();
}
public fetchAccounts() {
this.dataService.fetchAccounts().subscribe((response) => {
this.accounts = response;
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.cd.markForCheck();
});
}
public onDeleteAccount(aId: string) {
this.dataService.deleteAccount(aId).subscribe({
next: () => {
this.fetchAccounts();
}
});
}
public onUpdateAccount(aAccount: AccountModel) {
this.router.navigate([], {
queryParams: { editDialog: true, transactionId: aAccount.id }
});
}
public openUpdateAccountDialog({
accountType,
id,
name,
platformId
}: AccountModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: {
account: {
accountType,
id,
name,
platformId
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const account: UpdateAccountDto = data?.account;
if (account) {
this.dataService.putAccount(account).subscribe({
next: () => {
this.fetchAccounts();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openCreateAccountDialog(): void {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: {
account: {
accountType: AccountType.SECURITIES,
name: null,
platformId: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const account: CreateAccountDto = data?.account;
if (account) {
this.dataService.postAccount(account).subscribe({
next: () => {
this.fetchAccounts();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -0,0 +1,31 @@
<div class="container">
<div class="row mb-3">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Accounts</h3>
<gf-accounts-table
[accounts]="accounts"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount"
(accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)"
></gf-accounts-table>
</div>
</div>
<div
*ngIf="!hasImpersonationId && hasPermissionToCreateAccount"
class="fab-container"
>
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[routerLink]="[]"
[queryParams]="{ createDialog: true }"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -0,0 +1,25 @@
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 { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
import { AccountsPageComponent } from './accounts-page.component';
import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module';
@NgModule({
declarations: [AccountsPageComponent],
exports: [],
imports: [
AccountsPageRoutingModule,
CommonModule,
CreateOrUpdateAccountDialogModule,
GfAccountsTableModule,
MatButtonModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AccountsPageModule {}

View File

@ -0,0 +1,8 @@
:host {
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
}

View File

@ -0,0 +1,43 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Currency } from '@prisma/client';
import { Subject } from 'rxjs';
import { DataService } from '../../../services/data.service';
import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'h-100' },
selector: 'gf-create-or-update-account-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./create-or-update-account-dialog.scss'],
templateUrl: 'create-or-update-account-dialog.html'
})
export class CreateOrUpdateAccountDialog {
public currencies: Currency[] = [];
public platforms: { id: string; name: string }[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateAccountDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams
) {}
ngOnInit() {
this.dataService.fetchInfo().subscribe(({ currencies, platforms }) => {
this.currencies = currencies;
this.platforms = platforms;
});
}
public onCancel(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,43 @@
<form #addAccountForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.account.id" mat-dialog-title i18n>Update account</h1>
<h1 *ngIf="!data.account.id" mat-dialog-title i18n>Add account</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.account.name" />
</mat-form-field>
</div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.account.accountType">
<mat-option value="SECURITIES" i18n> SECURITIES </mat-option>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>
<mat-select name="platformId" [(value)]="data.account.platformId">
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let platform of platforms" [value]="platform.id"
>{{ platform.name }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!addAccountForm.form.valid"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</form>

View File

@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
@NgModule({
declarations: [CreateOrUpdateAccountDialog],
exports: [],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule
],
providers: []
})
export class CreateOrUpdateAccountDialogModule {}

View File

@ -0,0 +1,43 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
.autocomplete {
font-size: 90%;
height: 2.5rem;
.symbol {
display: inline-block;
min-width: 4rem;
}
}
.mat-select {
&.no-arrow {
::ng-deep {
.mat-select-arrow {
opacity: 0;
}
}
}
}
.mat-datepicker-input {
&.mat-input-element:disabled {
color: var(--dark-primary-text);
}
}
}
}
:host-context(.is-dark-theme) {
.mat-dialog-content {
.mat-datepicker-input {
&.mat-input-element:disabled {
color: var(--light-primary-text);
}
}
}
}

View File

@ -0,0 +1,5 @@
import { Account } from '@prisma/client';
export interface CreateOrUpdateAccountDialogParams {
account: Account;
}

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

@ -69,44 +69,59 @@
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Users</h3>
<mat-card class="px-0">
<mat-card-content class="users">
<table>
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell pl-2 py-2" i18n>User</th>
<th class="mat-header-cell pr-2 py-2" i18n>
Registration Date
</th>
<th class="mat-header-cell pr-2 py-2" i18n>Accounts</th>
<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-2 py-2" i18n>Last Activitiy</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let userItem of users" class="mat-row">
<td class="mat-cell pl-2 py-2">
{{ userItem.alias || userItem.id }}
</td>
<td class="mat-cell pr-2 py-2">
{{ userItem.createdAt | date: defaultDateFormat }}
</td>
<td class="mat-cell pr-2 py-2">
{{ userItem._count?.Account }}
</td>
<td class="mat-cell pr-2 py-2">{{ userItem._count?.Order }}</td>
<td class="mat-cell pr-2 py-2">
{{ userItem.Analytics?.activityCount }}
</td>
<td class="mat-cell pr-2 py-2">
{{ formatDistanceToNow(userItem.Analytics?.updatedAt) }}
</td>
</tr>
</tbody>
</table>
</mat-card-content>
</mat-card>
<div class="users">
<table class="gf-table">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2" i18n>Registration Date</th>
<th class="mat-header-cell px-1 py-2" i18n>Accounts</th>
<th class="mat-header-cell px-1 py-2" i18n>Transactions</th>
<th class="mat-header-cell px-1 py-2" i18n>Engagement</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" class="mat-row">
<td class="mat-cell px-1 py-2">
{{ userItem.alias || userItem.id }}
</td>
<td class="mat-cell px-1 py-2">
{{ userItem.createdAt | date: defaultDateFormat }}
</td>
<td class="mat-cell px-1 py-2">{{ userItem._count?.Account }}</td>
<td class="mat-cell px-1 py-2">{{ userItem._count?.Order }}</td>
<td class="mat-cell px-1 py-2">
{{ userItem.Analytics?.activityCount }}
</td>
<td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.Analytics?.updatedAt) }}
</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>

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

@ -1,25 +1,18 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
color: rgb(var(--dark-primary-text));
display: block;
.mat-card-content {
&.users {
overflow-x: auto;
.users {
overflow-x: auto;
table {
tr {
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
}
}
table {
min-width: 100%;
.mat-row,
.mat-header-row {
width: 100%;
}
.mat-row,
.mat-header-row {
width: 100%;
}
}
}
@ -39,15 +32,4 @@
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
.mat-table {
tr {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
}

View File

@ -16,6 +16,9 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./analysis-page.scss']
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
public accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
};
public deviceType: string;
public period = 'current';
public periodOptions: ToggleOption[] = [
@ -23,9 +26,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
{ label: 'Current', value: 'current' }
];
public hasImpersonationId: boolean;
public platforms: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
};
public portfolioItems: PortfolioItem[];
public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any };
@ -95,7 +95,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
},
aPeriod: string
) {
this.platforms = {};
this.accounts = {};
this.positions = {};
this.positionsArray = [];
@ -113,16 +113,16 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
};
this.positionsArray.push(position);
for (const [platform, { current, original }] of Object.entries(
position.platforms
for (const [account, { current, original }] of Object.entries(
position.accounts
)) {
if (this.platforms[platform]?.value) {
this.platforms[platform].value +=
if (this.accounts[account]?.value) {
this.accounts[account].value +=
aPeriod === 'original' ? original : current;
} else {
this.platforms[platform] = {
this.accounts[account] = {
value: aPeriod === 'original' ? original : current,
name: platform
name: account
};
}
}

View File

@ -36,7 +36,7 @@
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Platform</mat-card-title>
<mat-card-title i18n>By Account</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -50,7 +50,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="hasImpersonationId"
[locale]="user?.settings?.locale"
[positions]="platforms"
[positions]="accounts"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>

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

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PricingPageComponent } from './pricing-page.component';
const routes: Routes = [{ path: '', component: PricingPageComponent }];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class PricingPageRoutingModule {}

View File

@ -0,0 +1,53 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { baseCurrency } from '@ghostfolio/helper';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-pricing-page',
templateUrl: './pricing-page.html',
styleUrls: ['./pricing-page.scss']
})
export class PricingPageComponent implements OnInit {
public baseCurrency = baseCurrency;
public isLoggedIn: boolean;
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken();
if (this.isLoggedIn)
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,102 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Pricing Plans</h3>
<div class="row">
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<h4 i18n>Open Source</h4>
<p>Host your <strong>Ghostfolio</strong> instance by yourself.</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
<p class="h5 text-right">
<span>Free</span>
</p>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<mat-card
class="mb-3"
[ngClass]="{ 'active': user?.subscription?.type === 'Trial' }"
>
<h4 class="align-items-center d-flex" i18n>
Diamond
<ion-icon
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</h4>
<p>
Get a fully managed <strong>Ghostfolio</strong> cloud offering.
</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
<p class="h5 text-right">
<span class="font-weight-normal"
>{{ user?.settings.baseCurrency || baseCurrency }}
<strong>2.99</strong>
<del class="ml-1 text-muted">3.99</del> / Month</span
>
</p>
</mat-card>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { PricingPageRoutingModule } from './pricing-page-routing.module';
import { PricingPageComponent } from './pricing-page.component';
@NgModule({
declarations: [PricingPageComponent],
exports: [],
imports: [CommonModule, MatCardModule, PricingPageRoutingModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class PricingPageModule {}

View File

@ -0,0 +1,14 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
.mat-card {
&.active {
border-color: rgba(var(--palette-primary-500), 1);
}
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -10,9 +10,9 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./report-page.scss']
})
export class ReportPageComponent implements OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public platformClusterRiskRules: PortfolioReportRule[];
private unsubscribeSubject = new Subject<void>();
@ -32,11 +32,11 @@ export class ReportPageComponent implements OnInit {
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk'] || null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk'] || null;
this.feeRules = portfolioReport.rules['fees'] || null;
this.platformClusterRiskRules =
portfolioReport.rules['platformClusterRisk'] || null;
this.cd.markForCheck();
});

View File

@ -18,8 +18,8 @@
<gf-rules [rules]="currencyClusterRiskRules"></gf-rules>
</div>
<div class="mb-4">
<h4 class="m-0" i18n>Platform Cluster Risks</h4>
<gf-rules [rules]="platformClusterRiskRules"></gf-rules>
<h4 class="m-0" i18n>Account Cluster Risks</h4>
<gf-rules [rules]="accountClusterRiskRules"></gf-rules>
</div>
<div>
<h4 class="m-0" i18n>Fees</h4>

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 {
@ -78,9 +93,10 @@ export class CreateOrUpdateTransactionDialog {
this.dataService
.fetchSymbolItem(this.data.transaction.symbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currency, marketPrice }) => {
.subscribe(({ currency, dataSource, marketPrice }) => {
this.data.transaction.currency = currency;
this.data.transaction.unitPrice = marketPrice;
this.data.transaction.dataSource = dataSource;
this.currentMarketPrice = marketPrice;
this.isLoading = false;
@ -90,6 +106,7 @@ export class CreateOrUpdateTransactionDialog {
public onUpdateSymbolByTyping(value: string) {
this.data.transaction.currency = null;
this.data.transaction.dataSource = null;
this.data.transaction.unitPrice = null;
this.data.transaction.symbol = value;

View File

@ -25,7 +25,7 @@
class="autocomplete"
[value]="lookupItem.symbol"
>
<span class="mr-2 symbol">{{ lookupItem.symbol }}</span
<span class="mr-2 symbol">{{ lookupItem.symbol | gfSymbol }}</span
><span><b>{{ lookupItem.name }}</b></span>
</mat-option>
</ng-container>
@ -58,6 +58,18 @@
</mat-select>
</mat-form-field>
</div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Data Source</mat-label>
<input
disabled
matInput
name="dataSource"
required
[(ngModel)]="data.transaction.dataSource"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
@ -69,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>
@ -98,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
@ -107,9 +125,18 @@
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 class="d-none">
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account</mat-label>
<mat-select
@ -123,17 +150,6 @@
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>
<mat-select name="platformId" [(value)]="data.transaction.platformId">
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let platform of platforms" [value]="platform.id"
>{{ platform.name }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>

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';
@ -9,6 +9,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component';
@ -17,6 +18,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
exports: [],
imports: [
CommonModule,
GfSymbolModule,
FormsModule,
MatAutocompleteModule,
MatButtonModule,
@ -28,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

@ -1,5 +1,4 @@
import { Order } from '../../interfaces/order.interface';
import { Account } from '@prisma/client';
import { Account, Order } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams {
accountId: string;

View File

@ -1,12 +0,0 @@
export interface Order {
accountId: string;
currency: string;
date: Date;
fee: number;
id: string;
quantity: number;
platformId: string;
symbol: string;
type: string;
unitPrice: number;
}

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -108,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: () => {
@ -125,10 +130,10 @@ export class TransactionsPageComponent implements OnInit {
public openUpdateTransactionDialog({
accountId,
currency,
dataSource,
date,
fee,
id,
platformId,
quantity,
symbol,
type,
@ -140,10 +145,10 @@ export class TransactionsPageComponent implements OnInit {
transaction: {
accountId,
currency,
dataSource,
date,
fee,
id,
platformId,
quantity,
symbol,
type,
@ -174,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,
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
}
},
@ -197,7 +204,7 @@ export class TransactionsPageComponent implements OnInit {
});
dialogRef.afterClosed().subscribe((data: any) => {
const transaction: UpdateOrderDto = data?.transaction;
const transaction: CreateOrderDto = data?.transaction;
if (transaction) {
this.dataService.postOrder(transaction).subscribe({

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

@ -1,8 +1,11 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { AdminData } from '@ghostfolio/api/app/admin/interfaces/admin-data.interface';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface';
@ -19,6 +22,7 @@ import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interfac
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { UpdateUserSettingsDto } from '@ghostfolio/api/app/user/update-user-settings.dto';
import { Order as OrderModel } from '@prisma/client';
import { Account as AccountModel } from '@prisma/client';
@Injectable({
providedIn: 'root'
@ -28,14 +32,26 @@ export class DataService {
public constructor(private http: HttpClient) {}
public fetchAccounts() {
return this.http.get<AccountModel[]>('/api/account');
}
public fetchAdminData() {
return this.http.get<AdminData>('/api/admin');
}
public deleteAccount(aId: string) {
return this.http.delete<any>(`/api/account/${aId}`);
}
public deleteOrder(aId: string) {
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');
}
@ -108,7 +124,11 @@ export class DataService {
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
}
public postOrder(aOrder: UpdateOrderDto) {
public postAccount(aAccount: CreateAccountDto) {
return this.http.post<OrderModel>(`/api/account`, aAccount);
}
public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/order`, aOrder);
}
@ -116,6 +136,10 @@ export class DataService {
return this.http.post<UserItem>(`/api/user`, {});
}
public putAccount(aAccount: UpdateAccountDto) {
return this.http.put<UserItem>(`/api/account/${aAccount.id}`, aAccount);
}
public putOrder(aOrder: UpdateOrderDto) {
return this.http.put<UserItem>(`/api/order/${aOrder.id}`, aOrder);
}

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

@ -1,7 +1,7 @@
{
"background_color": "transparent",
"categories": ["finance", "utilities"],
"description": "Privacy-first Portfolio Tracker",
"description": "Open Source Portfolio Tracker",
"display": "standalone",
"icons": [
{

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