Compare commits
82 Commits
Author | SHA1 | Date | |
---|---|---|---|
20f9225daa | |||
b6101c6375 | |||
e1022846b9 | |||
9ba79f6721 | |||
0ac97bd112 | |||
827270704a | |||
8634463597 | |||
3905782ad6 | |||
5db984ffef | |||
fb3cd4b689 | |||
3b5a34f6f3 | |||
22b43b5bfc | |||
6c66033eb4 | |||
162fc25e23 | |||
45f385a483 | |||
e9ef911548 | |||
d8d4d8f001 | |||
f47c7313af | |||
31f0056a2d | |||
550e646079 | |||
37ff7acf04 | |||
8236091477 | |||
2a71cb66de | |||
e60fe48fdd | |||
d40bc5070a | |||
fda4e0ea7d | |||
08d696ce33 | |||
46614a7c24 | |||
02b433eb1e | |||
25112a450b | |||
727340748b | |||
8ad6492477 | |||
4af76f6f6d | |||
10940214a5 | |||
d9a6c22e1e | |||
692309988c | |||
42a54263f9 | |||
4fb88859b2 | |||
aa24b5e8c6 | |||
90e18338f6 | |||
ad5ae938ef | |||
c9a8dd4958 | |||
f1ec5e704e | |||
f40f0653c2 | |||
5f7a230fd3 | |||
71feb531e8 | |||
ec3552d7f6 | |||
41875e70d6 | |||
5fa0540936 | |||
5b69dee246 | |||
19b0fe04a6 | |||
19ea4479ff | |||
0b2f6a312c | |||
f79d60014b | |||
5b7409d08e | |||
6230aa87e2 | |||
8b615d2f56 | |||
4100446cac | |||
ad3e6d637c | |||
aa87262954 | |||
01b6bb5b99 | |||
884b7f4de7 | |||
3f8a2b47f9 | |||
e2e4c9be3c | |||
0f7c6ff0fe | |||
703a96f4db | |||
42c0560422 | |||
eb63802d01 | |||
6d9191a46f | |||
6744245d8b | |||
8f64a77a9d | |||
0d5fc7655b | |||
c511ec7e33 | |||
b12349a148 | |||
f7e3a4c727 | |||
5f276469b7 | |||
69e1d92ed3 | |||
ef2849aa6c | |||
c668d7b456 | |||
e23bf62859 | |||
54c5746d21 | |||
7130ac7565 |
139
CHANGELOG.md
139
CHANGELOG.md
@ -5,6 +5,145 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 2.11.0 - 2023-10-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to transfer a part of the cash balance from one to another account
|
||||||
|
- Extended the markets overview by benchmarks (date of last all time high)
|
||||||
|
- Added support to import historical market data in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the style of the create button on the page for granting and revoking public access to share the portfolio
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `5.3.1` to `5.4.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `FEE` and `INTEREST` types in the activities import of `csv` files
|
||||||
|
- Fixed the displayed currency of the cash balance in the create or update account dialog
|
||||||
|
|
||||||
|
## 2.10.0 - 2023-10-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported enter key press to submit the form of the create or update access dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the display of the results in the search for a holding
|
||||||
|
- Changed the queue jobs view in the admin control panel to an `@angular/material` data table
|
||||||
|
- Improved the symbol conversion in the _EOD Historical Data_ service
|
||||||
|
|
||||||
|
## 2.9.0 - 2023-10-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to search for a holding by `isin`, `name` and `symbol` (experimental)
|
||||||
|
- Added support for notes in the activities import
|
||||||
|
- Added support to search in the platform selector of the create or update account dialog
|
||||||
|
- Added support for a search query in the portfolio position endpoint
|
||||||
|
- Added the application version to the endpoint `GET api/v1/admin`
|
||||||
|
- Introduced a carousel component for the testimonial section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Displayed the link to the markets overview on the home page without any permission
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the style of the active features page in the navigation on desktop
|
||||||
|
|
||||||
|
## 2.8.0 - 2023-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported enter key press to submit the form of the create or update account dialog
|
||||||
|
- Added the application version to the admin control panel
|
||||||
|
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the settings icon of the user account page
|
||||||
|
- Improved the usability to set an asset profile as a benchmark
|
||||||
|
- Reload platforms after making a change in the admin control panel
|
||||||
|
- Reload tags after making a change in the admin control panel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the sidebar navigation on the user account page
|
||||||
|
|
||||||
|
## 2.7.0 - 2023-09-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a new static portfolio analysis rule: Emergency fund setup
|
||||||
|
- Added tabs to the user account page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set up the _Inter_ font family
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.7.0` to `2.8.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a link on the features page
|
||||||
|
|
||||||
|
## 2.6.0 - 2023-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the management of tags in the admin control panel
|
||||||
|
- Added a blog post: _Hacktoberfest 2023_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `prettier` from version `3.0.2` to `3.0.3`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0`
|
||||||
|
|
||||||
|
## 2.5.0 - 2023-09-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for translated activity types in the activities table
|
||||||
|
- Added support for dates in `DD.MM.YYYY` format in the activities import
|
||||||
|
- Set up the language localization for Türkçe (`tr`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Skipped creating queue jobs for asset profiles with `MANUAL` data source on creating a new activity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the cash position in the holdings table
|
||||||
|
|
||||||
|
## 2.4.0 - 2023-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for interest on account level (experimental)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the preselected currency based on the account's currency in the create or edit activity dialog
|
||||||
|
- Unlocked the experimental features setting for all users
|
||||||
|
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering
|
||||||
|
|
||||||
|
## 2.3.0 - 2023-09-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for fees on account level (experimental)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the export functionality for liabilities
|
||||||
|
|
||||||
## 2.2.0 - 2023-09-17
|
## 2.2.0 - 2023-09-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -18,6 +18,12 @@
|
|||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
|
#### Access database via GUI
|
||||||
|
|
||||||
|
Run `yarn database:gui`
|
||||||
|
|
||||||
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `yarn database:push`
|
Run `yarn database:push`
|
||||||
|
@ -27,7 +27,7 @@ New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
|||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
|
@ -8,4 +8,8 @@ export class CreateAccessDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
type?: 'PUBLIC';
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { CreateAccountDto } from './create-account.dto';
|
import { CreateAccountDto } from './create-account.dto';
|
||||||
|
import { TransferBalanceDto } from './transfer-balance.dto';
|
||||||
import { UpdateAccountDto } from './update-account.dto';
|
import { UpdateAccountDto } from './update-account.dto';
|
||||||
|
|
||||||
@Controller('account')
|
@Controller('account')
|
||||||
@ -154,6 +155,58 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('transfer-balance')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async transferAccountBalance(
|
||||||
|
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountsOfUser = await this.accountService.getAccounts(
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentAccountIds = accountsOfUser.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
![accountIdFrom, accountIdTo].every((accountId) => {
|
||||||
|
return currentAccountIds.includes(accountId);
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currency } = accountsOfUser.find(({ id }) => {
|
||||||
|
return id === accountIdFrom;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
currency,
|
||||||
|
accountId: accountIdFrom,
|
||||||
|
amount: -balance,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
currency,
|
||||||
|
accountId: accountIdTo,
|
||||||
|
amount: balance,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
|
@ -109,7 +109,7 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string) {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
const accounts = await this.accounts({
|
const accounts = await this.accounts({
|
||||||
include: { Order: true, Platform: true },
|
include: { Order: true, Platform: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
@ -218,13 +218,13 @@ export class AccountService {
|
|||||||
accountId,
|
accountId,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
date,
|
date = new Date(),
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
date: Date;
|
date?: Date;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const { balance, currency: currencyOfAccount } = await this.account({
|
const { balance, currency: currencyOfAccount } = await this.account({
|
||||||
|
@ -12,7 +12,7 @@ import { isString } from 'lodash';
|
|||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType?: AccountType;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
12
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
12
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class TransferBalanceDto {
|
||||||
|
@IsString()
|
||||||
|
accountIdFrom: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
accountIdTo: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
}
|
@ -12,7 +12,7 @@ import { isString } from 'lodash';
|
|||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType?: AccountType;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
@ -43,6 +43,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||||
|
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@ -313,6 +314,43 @@ export class AdminController {
|
|||||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('market-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updateMarketData(
|
||||||
|
@Body() data: UpdateBulkMarketDataDto,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||||
|
({ date, marketPrice }) => ({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
marketPrice,
|
||||||
|
symbol,
|
||||||
|
state: 'CLOSE'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.marketDataService.updateMany({
|
||||||
|
data: dataBulkUpdate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(
|
public async update(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -97,7 +98,8 @@ export class AdminService {
|
|||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
users: await this.getUsersWithAnalytics()
|
users: await this.getUsersWithAnalytics(),
|
||||||
|
version: environment.version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
|
||||||
|
|
||||||
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
|
export class UpdateBulkMarketDataDto {
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => UpdateMarketDataDto)
|
||||||
|
marketData: UpdateMarketDataDto[];
|
||||||
|
}
|
@ -1,6 +1,10 @@
|
|||||||
import { IsNumber } from 'class-validator';
|
import { IsDate, IsNumber, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateMarketDataDto {
|
export class UpdateMarketDataDto {
|
||||||
|
@IsDate()
|
||||||
|
@IsOptional()
|
||||||
|
date?: Date;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
|||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
import { SymbolModule } from './symbol/symbol.module';
|
import { SymbolModule } from './symbol/symbol.module';
|
||||||
|
import { TagModule } from './tag/tag.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -101,6 +102,7 @@ import { UserModule } from './user/user.module';
|
|||||||
SitemapModule,
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
TagModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
@ -10,6 +10,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
@ -32,32 +33,6 @@ export class BenchmarkController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
|
||||||
return {
|
|
||||||
benchmarks: await this.benchmarkService.getBenchmarks()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('startDateString') startDateString: string,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
|
||||||
const startDate = new Date(startDateString);
|
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
|
||||||
dataSource,
|
|
||||||
startDate,
|
|
||||||
symbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||||
@ -94,4 +69,70 @@ export class BenchmarkController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteBenchmark(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!benchmark) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmark;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||||
|
return {
|
||||||
|
benchmarks: await this.benchmarkService.getBenchmarks()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('startDateString') startDateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const startDate = new Date(startDateString);
|
||||||
|
|
||||||
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
startDate,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,7 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
@ -85,15 +85,14 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
let performancePercentFromAllTimeHigh = 0;
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
if (allTimeHigh && marketPrice) {
|
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
allTimeHigh,
|
allTimeHigh.marketPrice,
|
||||||
marketPrice
|
marketPrice
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
storeInCache = false;
|
storeInCache = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
performancePercentFromAllTimeHigh
|
performancePercentFromAllTimeHigh
|
||||||
@ -101,6 +100,7 @@ export class BenchmarkService {
|
|||||||
name: benchmarkAssetProfiles[index].name,
|
name: benchmarkAssetProfiles[index].name,
|
||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
|
date: allTimeHigh.date,
|
||||||
performancePercent: performancePercentFromAllTimeHigh
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -245,6 +245,43 @@ export class BenchmarkService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async deleteBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||||
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let benchmarks =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as BenchmarkProperty[]) ?? [];
|
||||||
|
|
||||||
|
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId !== assetProfile.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_BENCHMARKS,
|
||||||
|
value: JSON.stringify(benchmarks)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
id: assetProfile.id,
|
||||||
|
name: assetProfile.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private getMarketCondition(aPerformanceInPercent: number) {
|
private getMarketCondition(aPerformanceInPercent: number) {
|
||||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||||
}
|
}
|
||||||
|
@ -77,7 +77,13 @@ export class ExportService {
|
|||||||
currency: SymbolProfile.currency,
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
date: date.toISOString(),
|
date: date.toISOString(),
|
||||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
symbol:
|
||||||
|
type === 'FEE' ||
|
||||||
|
type === 'INTEREST' ||
|
||||||
|
type === 'ITEM' ||
|
||||||
|
type === 'LIABILITY'
|
||||||
|
? SymbolProfile.name
|
||||||
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -410,7 +410,7 @@ export class ImportService {
|
|||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
//@ts-ignore
|
// @ts-ignore
|
||||||
SymbolProfile: assetProfile,
|
SymbolProfile: assetProfile,
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
|
@ -55,12 +55,8 @@ export class InfoService {
|
|||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = (
|
const platforms = await this.platformService.getPlatforms({
|
||||||
await this.platformService.getPlatforms({
|
orderBy: { name: 'asc' }
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
})
|
|
||||||
).map(({ id, name }) => {
|
|
||||||
return { id, name };
|
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
let systemMessage: string;
|
||||||
|
|
||||||
|
@ -89,7 +89,9 @@ export class OrderController {
|
|||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('tags') filterByTags?: string
|
@Query('skip') skip?: number,
|
||||||
|
@Query('tags') filterByTags?: string,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
@ -105,6 +107,8 @@ export class OrderController {
|
|||||||
filters,
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
take: isNaN(take) ? undefined : take,
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
@ -147,8 +151,9 @@ export class OrderController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!order.isDraft) {
|
if (data.dataSource && !order.isDraft) {
|
||||||
// Gather symbol data in the background, if not draft
|
// Gather symbol data in the background, if data source is set
|
||||||
|
// (not MANUAL) and not draft
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols([
|
||||||
{
|
{
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
|
@ -97,7 +97,12 @@ export class OrderService {
|
|||||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
const userId = data.userId;
|
const userId = data.userId;
|
||||||
|
|
||||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
@ -118,20 +123,22 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.addJobToQueue({
|
if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
|
||||||
data: {
|
this.dataGatheringService.addJobToQueue({
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
data: {
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
|
||||||
},
|
|
||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
|
||||||
opts: {
|
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
|
||||||
jobId: getAssetProfileIdentifier({
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
})
|
},
|
||||||
}
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
});
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: getAssetProfileIdentifier({
|
||||||
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
@ -151,6 +158,9 @@ export class OrderService {
|
|||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
const isDraft =
|
const isDraft =
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
data.type === 'LIABILITY'
|
data.type === 'LIABILITY'
|
||||||
? false
|
? false
|
||||||
: isAfter(data.date as Date, endOfToday());
|
: isAfter(data.date as Date, endOfToday());
|
||||||
@ -197,7 +207,12 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
if (
|
||||||
|
order.type === 'FEE' ||
|
||||||
|
order.type === 'INTEREST' ||
|
||||||
|
order.type === 'ITEM' ||
|
||||||
|
order.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,6 +230,8 @@ export class OrderService {
|
|||||||
public async getOrders({
|
public async getOrders({
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
|
skip,
|
||||||
|
take = Number.MAX_SAFE_INTEGER,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
@ -222,6 +239,8 @@ export class OrderService {
|
|||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -300,6 +319,8 @@ export class OrderService {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
await this.orders({
|
await this.orders({
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@ -368,7 +389,12 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
@ -47,6 +47,7 @@ export class PlatformController {
|
|||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.platformService.createPlatform(data);
|
return this.platformService.createPlatform(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client';
|
|||||||
export class PlatformService {
|
export class PlatformService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||||
|
return this.prismaService.platform.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePlatform(
|
||||||
|
where: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.delete({ where });
|
||||||
|
}
|
||||||
|
|
||||||
public async getPlatform(
|
public async getPlatform(
|
||||||
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
@ -56,12 +68,6 @@ export class PlatformService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
|
||||||
return this.prismaService.platform.create({
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updatePlatform({
|
public async updatePlatform({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
@ -74,10 +80,4 @@ export class PlatformService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePlatform(
|
|
||||||
where: Prisma.PlatformWhereUniqueInput
|
|
||||||
): Promise<Platform> {
|
|
||||||
return this.prismaService.platform.delete({ where });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -173,8 +173,14 @@ export class PortfolioController {
|
|||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
holdings[symbol] = {
|
holdings[symbol] = {
|
||||||
...portfolioPosition,
|
...portfolioPosition,
|
||||||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
assetClass:
|
||||||
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
hasDetails || portfolioPosition.assetClass === 'CASH'
|
||||||
|
? portfolioPosition.assetClass
|
||||||
|
: undefined,
|
||||||
|
assetSubClass:
|
||||||
|
hasDetails || portfolioPosition.assetSubClass === 'CASH'
|
||||||
|
? portfolioPosition.assetSubClass
|
||||||
|
: undefined,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
@ -385,12 +391,14 @@ export class PortfolioController {
|
|||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
|
filterBySearchQuery,
|
||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule
|
|||||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||||
|
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
|
||||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -50,18 +51,17 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
|
Type as ActivityType,
|
||||||
AssetClass,
|
AssetClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
Order,
|
Order,
|
||||||
Platform,
|
Platform,
|
||||||
Prisma,
|
Prisma,
|
||||||
Tag,
|
Tag
|
||||||
Type as TypeOfOrder
|
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
differenceInDays,
|
differenceInDays,
|
||||||
endOfToday,
|
|
||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
isBefore,
|
isBefore,
|
||||||
@ -1014,6 +1014,9 @@ export class PortfolioService {
|
|||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
|
const searchQuery = filters.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
@ -1042,9 +1045,9 @@ export class PortfolioService {
|
|||||||
const currentPositions =
|
const currentPositions =
|
||||||
await portfolioCalculator.getCurrentPositions(startDate);
|
await portfolioCalculator.getCurrentPositions(startDate);
|
||||||
|
|
||||||
const positions = currentPositions.positions.filter(
|
let positions = currentPositions.positions.filter(({ quantity }) => {
|
||||||
(item) => !item.quantity.eq(0)
|
return !quantity.eq(0);
|
||||||
);
|
});
|
||||||
|
|
||||||
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
@ -1067,6 +1070,18 @@ export class PortfolioService {
|
|||||||
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
|
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
positions = positions.filter(({ symbol }) => {
|
||||||
|
const enhancedSymbolProfile = symbolProfileMap[symbol];
|
||||||
|
|
||||||
|
return (
|
||||||
|
enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) ||
|
||||||
|
enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) ||
|
||||||
|
enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasErrors: currentPositions.hasErrors,
|
hasErrors: currentPositions.hasErrors,
|
||||||
positions: positions.map((position) => {
|
positions: positions.map((position) => {
|
||||||
@ -1215,12 +1230,6 @@ export class PortfolioService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isEmpty(orders)) {
|
|
||||||
return {
|
|
||||||
rules: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currency: userCurrency,
|
currency: userCurrency,
|
||||||
currentRateService: this.currentRateService,
|
currentRateService: this.currentRateService,
|
||||||
@ -1229,7 +1238,9 @@ export class PortfolioService {
|
|||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
const portfolioStart = parseDate(
|
||||||
|
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
|
||||||
|
);
|
||||||
const currentPositions =
|
const currentPositions =
|
||||||
await portfolioCalculator.getCurrentPositions(portfolioStart);
|
await portfolioCalculator.getCurrentPositions(portfolioStart);
|
||||||
|
|
||||||
@ -1250,33 +1261,48 @@ export class PortfolioService {
|
|||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userSettings = <UserSettings>this.request.user.Settings.settings;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: {
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: isEmpty(orders)
|
||||||
[
|
? undefined
|
||||||
new AccountClusterRiskCurrentInvestment(
|
: await this.rulesService.evaluate(
|
||||||
this.exchangeRateDataService,
|
[
|
||||||
accounts
|
new AccountClusterRiskCurrentInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
accounts
|
||||||
|
),
|
||||||
|
new AccountClusterRiskSingleAccount(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
accounts
|
||||||
|
)
|
||||||
|
],
|
||||||
|
userSettings
|
||||||
),
|
),
|
||||||
new AccountClusterRiskSingleAccount(
|
currencyClusterRisk: isEmpty(orders)
|
||||||
|
? undefined
|
||||||
|
: await this.rulesService.evaluate(
|
||||||
|
[
|
||||||
|
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
positions
|
||||||
|
),
|
||||||
|
new CurrencyClusterRiskCurrentInvestment(
|
||||||
|
this.exchangeRateDataService,
|
||||||
|
positions
|
||||||
|
)
|
||||||
|
],
|
||||||
|
userSettings
|
||||||
|
),
|
||||||
|
emergencyFund: await this.rulesService.evaluate(
|
||||||
|
[
|
||||||
|
new EmergencyFundSetup(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
accounts
|
userSettings.emergencyFund
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
<UserSettings>this.request.user.Settings.settings
|
userSettings
|
||||||
),
|
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
|
||||||
[
|
|
||||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
positions
|
|
||||||
),
|
|
||||||
new CurrencyClusterRiskCurrentInvestment(
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
positions
|
|
||||||
)
|
|
||||||
],
|
|
||||||
<UserSettings>this.request.user.Settings.settings
|
|
||||||
),
|
),
|
||||||
fees: await this.rulesService.evaluate(
|
fees: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -1286,7 +1312,7 @@ export class PortfolioService {
|
|||||||
this.getFees({ userCurrency, activities: orders }).toNumber()
|
this.getFees({ userCurrency, activities: orders }).toNumber()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
<UserSettings>this.request.user.Settings.settings
|
userSettings
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1342,36 +1368,6 @@ export class PortfolioService {
|
|||||||
return cashPositions;
|
return cashPositions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDividend({
|
|
||||||
activities,
|
|
||||||
date = new Date(0),
|
|
||||||
userCurrency
|
|
||||||
}: {
|
|
||||||
activities: OrderWithAccount[];
|
|
||||||
date?: Date;
|
|
||||||
userCurrency: string;
|
|
||||||
}) {
|
|
||||||
return activities
|
|
||||||
.filter((activity) => {
|
|
||||||
// Filter out all activities before given date (drafts) and type dividend
|
|
||||||
return (
|
|
||||||
isBefore(date, new Date(activity.date)) &&
|
|
||||||
activity.type === TypeOfOrder.DIVIDEND
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
new Big(quantity).mul(unitPrice).toNumber(),
|
|
||||||
SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce(
|
|
||||||
(previous, current) => new Big(previous).plus(current),
|
|
||||||
new Big(0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDividendsByGroup({
|
private getDividendsByGroup({
|
||||||
dividends,
|
dividends,
|
||||||
groupBy
|
groupBy
|
||||||
@ -1516,52 +1512,6 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
|
|
||||||
return activities
|
|
||||||
.filter((activity) => {
|
|
||||||
// Filter out all activities before given date (drafts) and type item
|
|
||||||
return (
|
|
||||||
isBefore(date, new Date(activity.date)) &&
|
|
||||||
activity.type === TypeOfOrder.ITEM
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
new Big(quantity).mul(unitPrice).toNumber(),
|
|
||||||
SymbolProfile.currency,
|
|
||||||
this.request.user.Settings.settings.baseCurrency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce(
|
|
||||||
(previous, current) => new Big(previous).plus(current),
|
|
||||||
new Big(0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLiabilities({
|
|
||||||
activities,
|
|
||||||
userCurrency
|
|
||||||
}: {
|
|
||||||
activities: OrderWithAccount[];
|
|
||||||
userCurrency: string;
|
|
||||||
}) {
|
|
||||||
return activities
|
|
||||||
.filter(({ type }) => {
|
|
||||||
return type === TypeOfOrder.LIABILITY;
|
|
||||||
})
|
|
||||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
new Big(quantity).mul(unitPrice).toNumber(),
|
|
||||||
SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce(
|
|
||||||
(previous, current) => new Big(previous).plus(current),
|
|
||||||
new Big(0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||||
switch (aDateRange) {
|
switch (aDateRange) {
|
||||||
case '1d':
|
case '1d':
|
||||||
@ -1650,9 +1600,10 @@ export class PortfolioService {
|
|||||||
return account?.isExcluded ?? false;
|
return account?.isExcluded ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const dividend = this.getDividend({
|
const dividend = this.getSumOfActivityType({
|
||||||
activities,
|
activities,
|
||||||
userCurrency
|
userCurrency,
|
||||||
|
activityType: 'DIVIDEND'
|
||||||
}).toNumber();
|
}).toNumber();
|
||||||
const emergencyFund = new Big(
|
const emergencyFund = new Big(
|
||||||
Math.max(
|
Math.max(
|
||||||
@ -1662,23 +1613,49 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||||
const firstOrderDate = activities[0]?.date;
|
const firstOrderDate = activities[0]?.date;
|
||||||
const items = this.getItems(activities).toNumber();
|
const interest = this.getSumOfActivityType({
|
||||||
const liabilities = this.getLiabilities({
|
|
||||||
activities,
|
activities,
|
||||||
userCurrency
|
userCurrency,
|
||||||
|
activityType: 'INTEREST'
|
||||||
|
}).toNumber();
|
||||||
|
const items = this.getSumOfActivityType({
|
||||||
|
activities,
|
||||||
|
userCurrency,
|
||||||
|
activityType: 'ITEM'
|
||||||
|
}).toNumber();
|
||||||
|
const liabilities = this.getSumOfActivityType({
|
||||||
|
activities,
|
||||||
|
userCurrency,
|
||||||
|
activityType: 'LIABILITY'
|
||||||
}).toNumber();
|
}).toNumber();
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
const totalBuy = this.getSumOfActivityType({
|
||||||
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
activities,
|
||||||
|
userCurrency,
|
||||||
|
activityType: 'BUY'
|
||||||
|
}).toNumber();
|
||||||
|
const totalSell = this.getSumOfActivityType({
|
||||||
|
activities,
|
||||||
|
userCurrency,
|
||||||
|
activityType: 'SELL'
|
||||||
|
}).toNumber();
|
||||||
|
|
||||||
const cash = new Big(balanceInBaseCurrency)
|
const cash = new Big(balanceInBaseCurrency)
|
||||||
.minus(emergencyFund)
|
.minus(emergencyFund)
|
||||||
.plus(emergencyFundPositionsValueInBaseCurrency)
|
.plus(emergencyFundPositionsValueInBaseCurrency)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||||
const totalOfExcludedActivities = new Big(
|
const totalOfExcludedActivities = this.getSumOfActivityType({
|
||||||
this.getTotalByType(excludedActivities, userCurrency, 'BUY')
|
userCurrency,
|
||||||
).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL'));
|
activities: excludedActivities,
|
||||||
|
activityType: 'BUY'
|
||||||
|
}).minus(
|
||||||
|
this.getSumOfActivityType({
|
||||||
|
userCurrency,
|
||||||
|
activities: excludedActivities,
|
||||||
|
activityType: 'SELL'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const cashDetailsWithExcludedAccounts =
|
const cashDetailsWithExcludedAccounts =
|
||||||
await this.accountService.getCashDetails({
|
await this.accountService.getCashDetails({
|
||||||
@ -1725,6 +1702,7 @@ export class PortfolioService {
|
|||||||
excludedAccountsAndActivities,
|
excludedAccountsAndActivities,
|
||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
|
interest,
|
||||||
items,
|
items,
|
||||||
liabilities,
|
liabilities,
|
||||||
netWorth,
|
netWorth,
|
||||||
@ -1747,6 +1725,39 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSumOfActivityType({
|
||||||
|
activities,
|
||||||
|
activityType,
|
||||||
|
date = new Date(0),
|
||||||
|
userCurrency
|
||||||
|
}: {
|
||||||
|
activities: OrderWithAccount[];
|
||||||
|
activityType: ActivityType;
|
||||||
|
date?: Date;
|
||||||
|
userCurrency: string;
|
||||||
|
}) {
|
||||||
|
return activities
|
||||||
|
.filter((activity) => {
|
||||||
|
// Filter out all activities before given date (drafts) and
|
||||||
|
// activity type
|
||||||
|
return (
|
||||||
|
isBefore(date, new Date(activity.date)) &&
|
||||||
|
activity.type === activityType
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
|
SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async getTransactionPoints({
|
private async getTransactionPoints({
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
@ -1818,6 +1829,21 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUserCurrency(aUser: UserWithSettings) {
|
||||||
|
return (
|
||||||
|
aUser.Settings?.settings.baseCurrency ??
|
||||||
|
this.request.user?.Settings?.settings.baseCurrency ??
|
||||||
|
DEFAULT_CURRENCY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
|
const impersonationUserId =
|
||||||
|
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||||
|
|
||||||
|
return impersonationUserId || aUserId;
|
||||||
|
}
|
||||||
|
|
||||||
private async getValueOfAccountsAndPlatforms({
|
private async getValueOfAccountsAndPlatforms({
|
||||||
filters = [],
|
filters = [],
|
||||||
orders,
|
orders,
|
||||||
@ -1961,38 +1987,4 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return { accounts, platforms };
|
return { accounts, platforms };
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTotalByType(
|
|
||||||
orders: OrderWithAccount[],
|
|
||||||
currency: string,
|
|
||||||
type: TypeOfOrder
|
|
||||||
) {
|
|
||||||
return orders
|
|
||||||
.filter(
|
|
||||||
(order) => !isAfter(order.date, endOfToday()) && order.type === type
|
|
||||||
)
|
|
||||||
.map((order) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
order.quantity * order.unitPrice,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
currency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((previous, current) => previous + current, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getUserCurrency(aUser: UserWithSettings) {
|
|
||||||
return (
|
|
||||||
aUser.Settings?.settings.baseCurrency ??
|
|
||||||
this.request.user?.Settings?.settings.baseCurrency ??
|
|
||||||
DEFAULT_CURRENCY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
|
||||||
const impersonationUserId =
|
|
||||||
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
|
||||||
|
|
||||||
return impersonationUserId || aUserId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
6
apps/api/src/app/tag/create-tag.dto.ts
Normal file
6
apps/api/src/app/tag/create-tag.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTagDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
104
apps/api/src/app/tag/tag.controller.ts
Normal file
104
apps/api/src/app/tag/tag.controller.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { CreateTagDto } from './create-tag.dto';
|
||||||
|
import { TagService } from './tag.service';
|
||||||
|
import { UpdateTagDto } from './update-tag.dto';
|
||||||
|
|
||||||
|
@Controller('tag')
|
||||||
|
export class TagController {
|
||||||
|
public constructor(
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly tagService: TagService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getTags() {
|
||||||
|
return this.tagService.getTagsWithActivityCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
|
||||||
|
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagService.createTag(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
|
||||||
|
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTag = await this.tagService.getTag({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalTag) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagService.updateTag({
|
||||||
|
data: {
|
||||||
|
...data
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteTag(@Param('id') id: string) {
|
||||||
|
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTag = await this.tagService.getTag({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalTag) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagService.deleteTag({ id });
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/tag/tag.module.ts
Normal file
13
apps/api/src/app/tag/tag.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TagController } from './tag.controller';
|
||||||
|
import { TagService } from './tag.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TagController],
|
||||||
|
exports: [TagService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [TagService]
|
||||||
|
})
|
||||||
|
export class TagModule {}
|
79
apps/api/src/app/tag/tag.service.ts
Normal file
79
apps/api/src/app/tag/tag.service.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma, Tag } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TagService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createTag(data: Prisma.TagCreateInput) {
|
||||||
|
return this.prismaService.tag.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.delete({ where });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTag(
|
||||||
|
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
|
||||||
|
): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.findUnique({
|
||||||
|
where: tagWhereUniqueInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTags({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
cursor?: Prisma.TagWhereUniqueInput;
|
||||||
|
orderBy?: Prisma.TagOrderByWithRelationInput;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
where?: Prisma.TagWhereInput;
|
||||||
|
} = {}) {
|
||||||
|
return this.prismaService.tag.findMany({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTagsWithActivityCount() {
|
||||||
|
const tagsWithOrderCount = await this.prismaService.tag.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { orders: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tagsWithOrderCount.map(({ _count, id, name }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
activityCount: _count.orders
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateTag({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
data: Prisma.TagUpdateInput;
|
||||||
|
where: Prisma.TagWhereUniqueInput;
|
||||||
|
}): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.update({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
9
apps/api/src/app/tag/update-tag.dto.ts
Normal file
9
apps/api/src/app/tag/update-tag.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateTagDto {
|
||||||
|
@IsString()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
@ -163,6 +163,13 @@ export class UserService {
|
|||||||
|
|
||||||
let currentPermissions = getPermissions(user.role);
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
|
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||||
|
currentPermissions = without(
|
||||||
|
currentPermissions,
|
||||||
|
permissions.accessAssistant
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
@ -58,6 +58,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -74,6 +78,10 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -142,6 +150,14 @@
|
|||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
|
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -254,6 +270,10 @@
|
|||||||
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
|
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/faq</loc>
|
<loc>https://ghostfol.io/en/faq</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -292,6 +312,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -308,6 +332,10 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -376,6 +404,14 @@
|
|||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -558,6 +594,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -574,6 +614,10 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -642,6 +686,14 @@
|
|||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
|
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -670,6 +722,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -686,6 +742,10 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -754,6 +814,14 @@
|
|||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
|
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
@ -868,4 +936,8 @@
|
|||||||
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
|
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
|
||||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/tr</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash';
|
|||||||
|
|
||||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||||
for (const key in aObject) {
|
for (const key in aObject) {
|
||||||
if (aObject[key] === null || aObject[key] === null) {
|
if (aObject[key] === null || aObject[key] === undefined) {
|
||||||
return true;
|
return true;
|
||||||
} else if (isObject(aObject[key])) {
|
} else if (isObject(aObject[key])) {
|
||||||
return hasNotDefinedValuesInObject(aObject[key]);
|
return hasNotDefinedValuesInObject(aObject[key]);
|
||||||
|
@ -18,7 +18,8 @@ const descriptions = {
|
|||||||
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
||||||
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
||||||
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
||||||
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.'
|
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
||||||
|
tr: 'Ghostfolio, hisse senetleri, ETF’ler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.'
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = 'Ghostfolio – Open Source Wealth Management Software';
|
const title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||||
@ -79,6 +80,10 @@ const locales = {
|
|||||||
'/en/blog/2023/09/ghostfolio-2': {
|
'/en/blog/2023/09/ghostfolio-2': {
|
||||||
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
|
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
|
||||||
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
|
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
|
||||||
|
},
|
||||||
|
'/en/blog/2023/09/hacktoberfest-2023': {
|
||||||
|
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
|
||||||
|
title: `Hacktoberfest 2023 - ${titleShort}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -6,16 +7,18 @@ import {
|
|||||||
UserSettings
|
UserSettings
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
|
private accounts: PortfolioDetails['accounts'];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private accounts: PortfolioDetails['accounts']
|
accounts: PortfolioDetails['accounts']
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Investment'
|
name: 'Investment'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.accounts = accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||||
|
private accounts: PortfolioDetails['accounts'];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private accounts: PortfolioDetails['accounts']
|
accounts: PortfolioDetails['accounts']
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Single Account'
|
name: 'Single Account'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.accounts = accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate() {
|
public evaluate() {
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||||
|
private positions: TimelinePosition[];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private positions: TimelinePosition[]
|
positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Investment: Base Currency'
|
name: 'Investment: Base Currency'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.positions = positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||||
|
private positions: TimelinePosition[];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private positions: TimelinePosition[]
|
positions: TimelinePosition[]
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Investment'
|
name: 'Investment'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.positions = positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export class EmergencyFundSetup extends Rule<Settings> {
|
||||||
|
private emergencyFund: number;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
emergencyFund: number
|
||||||
|
) {
|
||||||
|
super(exchangeRateDataService, {
|
||||||
|
name: 'Emergency Fund: Set up'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emergencyFund = emergencyFund;
|
||||||
|
}
|
||||||
|
|
||||||
|
public evaluate(ruleSettings: Settings) {
|
||||||
|
if (this.emergencyFund > ruleSettings.threshold) {
|
||||||
|
return {
|
||||||
|
evaluation: 'An emergency fund has been set up',
|
||||||
|
value: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
evaluation: 'No emergency fund has been set up',
|
||||||
|
value: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSettings(aUserSettings: UserSettings): Settings {
|
||||||
|
return {
|
||||||
|
baseCurrency: aUserSettings.baseCurrency,
|
||||||
|
isActive: true,
|
||||||
|
threshold: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings extends RuleSettings {
|
||||||
|
baseCurrency: string;
|
||||||
|
threshold: number;
|
||||||
|
}
|
@ -1,22 +1,29 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
|
||||||
|
|
||||||
export class FeeRatioInitialInvestment extends Rule<Settings> {
|
export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||||
|
private fees: number;
|
||||||
|
private totalInvestment: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
protected exchangeRateDataService: ExchangeRateDataService,
|
protected exchangeRateDataService: ExchangeRateDataService,
|
||||||
private totalInvestment: number,
|
totalInvestment: number,
|
||||||
private fees: number
|
fees: number
|
||||||
) {
|
) {
|
||||||
super(exchangeRateDataService, {
|
super(exchangeRateDataService, {
|
||||||
name: 'Investment'
|
name: 'Fee Ratio'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.fees = fees;
|
||||||
|
this.totalInvestment = totalInvestment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(ruleSettings: Settings) {
|
public evaluate(ruleSettings: Settings) {
|
||||||
const feeRatio = this.fees / this.totalInvestment;
|
const feeRatio = this.totalInvestment
|
||||||
|
? this.fees / this.totalInvestment
|
||||||
|
: 0;
|
||||||
|
|
||||||
if (feeRatio > ruleSettings.threshold) {
|
if (feeRatio > ruleSettings.threshold) {
|
||||||
return {
|
return {
|
||||||
|
@ -8,14 +8,17 @@ export class ApiService {
|
|||||||
public buildFiltersFromQueryParams({
|
public buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
|
filterBySearchQuery,
|
||||||
filterByTags
|
filterByTags
|
||||||
}: {
|
}: {
|
||||||
filterByAccounts?: string;
|
filterByAccounts?: string;
|
||||||
filterByAssetClasses?: string;
|
filterByAssetClasses?: string;
|
||||||
|
filterBySearchQuery?: string;
|
||||||
filterByTags?: string;
|
filterByTags?: string;
|
||||||
}): Filter[] {
|
}): Filter[] {
|
||||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||||
|
const searchQuery = filterBySearchQuery?.toLowerCase();
|
||||||
const tagIds = filterByTags?.split(',') ?? [];
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -31,6 +34,10 @@ export class ApiService {
|
|||||||
type: 'ASSET_CLASS'
|
type: 'ASSET_CLASS'
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
id: searchQuery,
|
||||||
|
type: 'SEARCH_QUERY'
|
||||||
|
},
|
||||||
...tagIds.map((tagId) => {
|
...tagIds.map((tagId) => {
|
||||||
return <Filter>{
|
return <Filter>{
|
||||||
id: tagId,
|
id: tagId,
|
||||||
|
@ -13,6 +13,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { Job } from 'bull';
|
import { Job } from 'bull';
|
||||||
import {
|
import {
|
||||||
|
addDays,
|
||||||
format,
|
format,
|
||||||
getDate,
|
getDate,
|
||||||
getMonth,
|
getMonth,
|
||||||
@ -101,15 +102,7 @@ export class DataGatheringProcessor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count month one up for iteration
|
currentDate = addDays(currentDate, 1);
|
||||||
currentDate = new Date(
|
|
||||||
Date.UTC(
|
|
||||||
getYear(currentDate),
|
|
||||||
getMonth(currentDate),
|
|
||||||
getDate(currentDate) + 1,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.marketDataService.updateMany({ data });
|
await this.marketDataService.updateMany({ data });
|
||||||
|
@ -127,6 +127,10 @@ export class DataGatheringService {
|
|||||||
uniqueAssets = await this.getUniqueAssets();
|
uniqueAssets = await this.getUniqueAssets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uniqueAssets.length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const assetProfiles =
|
const assetProfiles =
|
||||||
await this.dataProviderService.getAssetProfiles(uniqueAssets);
|
await this.dataProviderService.getAssetProfiles(uniqueAssets);
|
||||||
const symbolProfiles =
|
const symbolProfiles =
|
||||||
|
@ -283,7 +283,6 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
if (symbol.endsWith('.FOREX')) {
|
if (symbol.endsWith('.FOREX')) {
|
||||||
symbol = symbol.replace('GBX', 'GBp');
|
symbol = symbol.replace('GBX', 'GBp');
|
||||||
symbol = symbol.replace('.FOREX', '');
|
symbol = symbol.replace('.FOREX', '');
|
||||||
symbol = `${DEFAULT_CURRENCY}${symbol}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return symbol;
|
return symbol;
|
||||||
@ -292,7 +291,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
/**
|
/**
|
||||||
* Converts a symbol to a EOD symbol
|
* Converts a symbol to a EOD symbol
|
||||||
*
|
*
|
||||||
* Currency: USDCHF -> CHF.FOREX
|
* Currency: USDCHF -> USDCHF.FOREX
|
||||||
*/
|
*/
|
||||||
private convertToEodSymbol(aSymbol: string) {
|
private convertToEodSymbol(aSymbol: string) {
|
||||||
if (
|
if (
|
||||||
@ -304,9 +303,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
|
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return `${aSymbol
|
let symbol = aSymbol;
|
||||||
.replace('GBp', 'GBX')
|
symbol = symbol.replace('GBp', 'GBX');
|
||||||
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
|
|
||||||
|
return `${symbol}.FOREX`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,18 +39,22 @@ export class MarketDataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
|
public async getMax({ dataSource, symbol }: UniqueAsset) {
|
||||||
const aggregations = await this.prismaService.marketData.aggregate({
|
return this.prismaService.marketData.findFirst({
|
||||||
_max: {
|
select: {
|
||||||
|
date: true,
|
||||||
marketPrice: true
|
marketPrice: true
|
||||||
},
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
marketPrice: 'desc'
|
||||||
|
}
|
||||||
|
],
|
||||||
where: {
|
where: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return aggregations._max.marketPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRange({
|
public async getRange({
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
"tsConfig": "apps/client/tsconfig.app.json",
|
"tsConfig": "apps/client/tsconfig.app.json",
|
||||||
"assets": [],
|
"assets": [],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"apps/client/src/assets/fonts/inter.css",
|
||||||
"apps/client/src/styles/theme.scss",
|
"apps/client/src/styles/theme.scss",
|
||||||
"apps/client/src/styles.scss"
|
"apps/client/src/styles.scss"
|
||||||
],
|
],
|
||||||
@ -63,6 +64,10 @@
|
|||||||
"baseHref": "/pt/",
|
"baseHref": "/pt/",
|
||||||
"localize": ["pt"]
|
"localize": ["pt"]
|
||||||
},
|
},
|
||||||
|
"development-tr": {
|
||||||
|
"baseHref": "/tr/",
|
||||||
|
"localize": ["tr"]
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@ -99,40 +104,40 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "mkdir -p dist/apps/client"
|
"command": "shx mkdir -p dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp -r apps/client/src/assets dist/apps/client"
|
"command": "shx cp -r apps/client/src/assets dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp -r apps/client/src/assets/.well-known dist/apps/client"
|
"command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp apps/client/src/assets/favicon.ico dist/apps/client"
|
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp apps/client/src/assets/index.html dist/apps/client"
|
"command": "shx cp apps/client/src/assets/index.html dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp apps/client/src/assets/robots.txt dist/apps/client"
|
"command": "shx cp apps/client/src/assets/robots.txt dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp node_modules/ionicons/dist/index.js dist/apps/client"
|
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
|
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
|
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp CHANGELOG.md dist/apps/client/assets"
|
"command": "shx cp CHANGELOG.md dist/apps/client/assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cp LICENSE dist/apps/client/assets"
|
"command": "shx cp LICENSE dist/apps/client/assets"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -165,6 +170,9 @@
|
|||||||
"development-pt": {
|
"development-pt": {
|
||||||
"browserTarget": "client:build:development-pt"
|
"browserTarget": "client:build:development-pt"
|
||||||
},
|
},
|
||||||
|
"development-tr": {
|
||||||
|
"browserTarget": "client:build:development-tr"
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "client:build:production"
|
"browserTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
@ -182,7 +190,8 @@
|
|||||||
"messages.fr.xlf",
|
"messages.fr.xlf",
|
||||||
"messages.it.xlf",
|
"messages.it.xlf",
|
||||||
"messages.nl.xlf",
|
"messages.nl.xlf",
|
||||||
"messages.pt.xlf"
|
"messages.pt.xlf",
|
||||||
|
"messages.tr.xlf"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -226,6 +235,10 @@
|
|||||||
"pt": {
|
"pt": {
|
||||||
"baseHref": "/pt/",
|
"baseHref": "/pt/",
|
||||||
"translation": "apps/client/src/locales/messages.pt.xlf"
|
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"baseHref": "/tr/",
|
||||||
|
"translation": "apps/client/src/locales/messages.tr.xlf"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sourceLocale": "en"
|
"sourceLocale": "en"
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
<gf-header
|
<gf-header
|
||||||
class="position-fixed w-100"
|
class="position-fixed w-100"
|
||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
|
[deviceType]="deviceType"
|
||||||
[hasTabs]="hasTabs"
|
[hasTabs]="hasTabs"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
[pageTitle]="pageTitle"
|
[pageTitle]="pageTitle"
|
||||||
@ -152,6 +153,11 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||||
</li>
|
</li>
|
||||||
|
<!--
|
||||||
|
<li>
|
||||||
|
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
|
||||||
|
</li>
|
||||||
|
-->
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -112,6 +112,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.hasTabs =
|
this.hasTabs =
|
||||||
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
|
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
|
||||||
|
this.currentRoute === 'account' ||
|
||||||
this.currentRoute === 'admin' ||
|
this.currentRoute === 'admin' ||
|
||||||
this.currentRoute === 'home' ||
|
this.currentRoute === 'home' ||
|
||||||
this.currentRoute === 'portfolio' ||
|
this.currentRoute === 'portfolio' ||
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Platform } from '@angular/cdk/platform';
|
import { Platform } from '@angular/cdk/platform';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import {
|
import {
|
||||||
@ -35,6 +35,7 @@ export function NgxStripeFactory(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
bootstrap: [AppComponent],
|
||||||
declarations: [AppComponent],
|
declarations: [AppComponent],
|
||||||
imports: [
|
imports: [
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
@ -72,6 +73,6 @@ export function NgxStripeFactory(): string {
|
|||||||
useFactory: NgxStripeFactory
|
useFactory: NgxStripeFactory
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
bootstrap: [AppComponent]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
@ -1,15 +1,3 @@
|
|||||||
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
|
|
||||||
<a
|
|
||||||
color="primary"
|
|
||||||
i18n
|
|
||||||
mat-flat-button
|
|
||||||
[queryParams]="{ createDialog: true }"
|
|
||||||
[routerLink]="[]"
|
|
||||||
>
|
|
||||||
Add Access
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="alias">
|
<ng-container matColumnDef="alias">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
|
||||||
|
@ -19,7 +19,6 @@ import { Access } from '@ghostfolio/common/interfaces';
|
|||||||
})
|
})
|
||||||
export class AccessTableComponent implements OnChanges, OnInit {
|
export class AccessTableComponent implements OnChanges, OnInit {
|
||||||
@Input() accesses: Access[];
|
@Input() accesses: Access[];
|
||||||
@Input() hasPermissionToCreateAccess = false;
|
|
||||||
@Input() showActions: boolean;
|
@Input() showActions: boolean;
|
||||||
|
|
||||||
@Output() accessDeleted = new EventEmitter<string>();
|
@Output() accessDeleted = new EventEmitter<string>();
|
||||||
|
@ -1,3 +1,14 @@
|
|||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button
|
||||||
|
class="align-items-center d-flex"
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="onTransferBalance()"
|
||||||
|
>
|
||||||
|
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
|
||||||
|
<ng-container i18n>Transfer Cash Balance</ng-container>...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
|
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th
|
<th
|
||||||
|
@ -34,6 +34,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
@Output() accountDeleted = new EventEmitter<string>();
|
@Output() accountDeleted = new EventEmitter<string>();
|
||||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||||
|
@Output() transferBalance = new EventEmitter<void>();
|
||||||
|
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
@ -97,6 +98,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
alert(aComment);
|
alert(aComment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onTransferBalance() {
|
||||||
|
this.transferBalance.emit();
|
||||||
|
}
|
||||||
|
|
||||||
public onUpdateAccount(aAccount: AccountModel) {
|
public onUpdateAccount(aAccount: AccountModel) {
|
||||||
this.accountToUpdate.emit(aAccount);
|
this.accountToUpdate.emit(aAccount);
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
||||||
@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
export class AdminJobsComponent implements OnDestroy, OnInit {
|
export class AdminJobsComponent implements OnDestroy, OnInit {
|
||||||
public defaultDateTimeFormat: string;
|
public defaultDateTimeFormat: string;
|
||||||
public filterForm: FormGroup;
|
public filterForm: FormGroup;
|
||||||
public jobs: AdminJobs['jobs'] = [];
|
public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
|
||||||
|
new MatTableDataSource();
|
||||||
|
public displayedColumns = [
|
||||||
|
'index',
|
||||||
|
'type',
|
||||||
|
'symbol',
|
||||||
|
'dataSource',
|
||||||
|
'attempts',
|
||||||
|
'created',
|
||||||
|
'finished',
|
||||||
|
'status',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
|
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
|
|||||||
.fetchJobs({ status: aStatus })
|
.fetchJobs({ status: aStatus })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ jobs }) => {
|
.subscribe(({ jobs }) => {
|
||||||
this.jobs = jobs;
|
this.dataSource = new MatTableDataSource(jobs);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -13,122 +13,158 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
<table class="gf-table w-100">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<thead>
|
<ng-container matColumnDef="index">
|
||||||
<tr class="mat-header-row">
|
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
#
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
{{ element.id }}
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
|
</td>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
</ng-container>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
<ng-container matColumnDef="type">
|
||||||
<th class="mat-header-cell px-1 py-2">
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
<button
|
<ng-container i18n>Type</ng-container>
|
||||||
class="mx-1 no-min-width px-2"
|
</th>
|
||||||
mat-button
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
[matMenuTriggerFor]="jobsActionsMenu"
|
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
|
||||||
(click)="$event.stopPropagation()"
|
Asset Profile
|
||||||
>
|
</ng-container>
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ng-container
|
||||||
|
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'"
|
||||||
|
i18n
|
||||||
|
>
|
||||||
|
Historical Market Data
|
||||||
|
</ng-container>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="symbol">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Symbol</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
{{ element.data?.symbol }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="dataSource">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Data Source</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
{{ element.data?.dataSource }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="attempts">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
|
||||||
|
<ng-container i18n>Attempts</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
|
||||||
|
{{ element.attemptsMade }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="created">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Created</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
{{ element.timestamp | date: defaultDateTimeFormat }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="finished">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Finished</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
{{ element.finishedOn | date: defaultDateTimeFormat }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<ng-container i18n>Status</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'active'"
|
||||||
|
name="play-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'completed'"
|
||||||
|
class="text-success"
|
||||||
|
name="checkmark-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'delayed'"
|
||||||
|
name="time-outline"
|
||||||
|
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'failed'"
|
||||||
|
class="text-danger"
|
||||||
|
name="alert-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'paused'"
|
||||||
|
name="pause-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="element.state === 'waiting'"
|
||||||
|
name="cafe-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="jobsActionsMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item (click)="onDeleteJobs()">
|
||||||
|
<ng-container i18n>Delete Jobs</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
</mat-menu>
|
||||||
<button mat-menu-item (click)="onDeleteJobs()">
|
</th>
|
||||||
<ng-container i18n>Delete Jobs</ng-container>
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
</button>
|
<button
|
||||||
</mat-menu>
|
class="mx-1 no-min-width px-2"
|
||||||
</th>
|
mat-button
|
||||||
</tr>
|
[matMenuTriggerFor]="jobActionsMenu"
|
||||||
</thead>
|
(click)="$event.stopPropagation()"
|
||||||
<tbody>
|
>
|
||||||
<ng-container *ngFor="let job of jobs">
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
<tr class="mat-row">
|
</button>
|
||||||
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
|
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||||
<td class="mat-cell px-1 py-2">
|
<button mat-menu-item (click)="onViewData(element.data)">
|
||||||
<span class="align-items-center d-flex">
|
<ng-container i18n>View Data</ng-container>
|
||||||
<ion-icon
|
</button>
|
||||||
class="mr-1"
|
<button
|
||||||
name="arrow-down-circle-outline"
|
mat-menu-item
|
||||||
></ion-icon>
|
[disabled]="element.stacktrace?.length <= 0"
|
||||||
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
|
(click)="onViewStacktrace(element.stacktrace)"
|
||||||
<span i18n>Asset Profile</span>
|
>
|
||||||
</ng-container>
|
<ng-container i18n>View Stacktrace</ng-container>
|
||||||
<ng-container
|
</button>
|
||||||
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
|
<button mat-menu-item (click)="onDeleteJob(element.id)">
|
||||||
>
|
<ng-container i18n>Delete Job</ng-container>
|
||||||
<span i18n>Historical Market Data</span>
|
</button>
|
||||||
</ng-container>
|
</mat-menu>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
</ng-container>
|
||||||
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
|
|
||||||
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
{{ job.attemptsMade }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
{{ job.timestamp | date: defaultDateTimeFormat }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
{{ job.finishedOn | date: defaultDateTimeFormat }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'active'"
|
|
||||||
name="play-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'completed'"
|
|
||||||
class="text-success"
|
|
||||||
name="checkmark-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'delayed'"
|
|
||||||
name="time-outline"
|
|
||||||
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'failed'"
|
|
||||||
class="text-danger"
|
|
||||||
name="alert-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'paused'"
|
|
||||||
name="pause-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="job.state === 'waiting'"
|
|
||||||
name="cafe-outline"
|
|
||||||
></ion-icon>
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
<button
|
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="jobActionsMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
|
||||||
</button>
|
|
||||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
|
||||||
<button mat-menu-item (click)="onViewData(job.data)">
|
|
||||||
<ng-container i18n>View Data</ng-container>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
mat-menu-item
|
|
||||||
[disabled]="job.stacktrace?.length <= 0"
|
|
||||||
(click)="onViewStacktrace(job.stacktrace)"
|
|
||||||
>
|
|
||||||
<ng-container i18n>View Stacktrace</ng-container>
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item (click)="onDeleteJob(job.id)">
|
|
||||||
<ng-container i18n>Delete Job</ng-container>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-container>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
|
||||||
import { AdminJobsComponent } from './admin-jobs.component';
|
import { AdminJobsComponent } from './admin-jobs.component';
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
|
MatTableModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -177,7 +177,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
dialogRef
|
dialogRef
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ withRefresh }) => {
|
.subscribe(({ withRefresh } = { withRefresh: false }) => {
|
||||||
this.marketDataChanged.next(withRefresh);
|
this.marketDataChanged.next(withRefresh);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -342,7 +342,7 @@ export class AdminMarketDataComponent
|
|||||||
dialogRef
|
dialogRef
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ dataSource, symbol }) => {
|
.subscribe(({ dataSource, symbol } = {}) => {
|
||||||
if (dataSource && symbol) {
|
if (dataSource && symbol) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.addAssetProfile({ dataSource, symbol })
|
.addAssetProfile({ dataSource, symbol })
|
||||||
|
@ -2,11 +2,4 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
.fab-container {
|
|
||||||
bottom: 2rem;
|
|
||||||
position: fixed;
|
|
||||||
right: 2rem;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -11,13 +11,15 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|||||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
ScraperConfiguration,
|
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { MarketData, SymbolProfile } from '@prisma/client';
|
import { MarketData, SymbolProfile } from '@prisma/client';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { parse as csvToJson } from 'papaparse';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -43,12 +45,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
public countries: {
|
public countries: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public historicalDataAsCsvString: string;
|
||||||
public isBenchmark = false;
|
public isBenchmark = false;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public marketDataDetails: MarketData[] = [];
|
||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
|
||||||
|
new Date(),
|
||||||
|
DATE_FORMAT
|
||||||
|
)};123.45`;
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -67,6 +74,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public initialize() {
|
public initialize() {
|
||||||
|
this.historicalDataAsCsvString =
|
||||||
|
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminMarketDataBySymbol({
|
.fetchAdminMarketDataBySymbol({
|
||||||
dataSource: this.data.dataSource,
|
dataSource: this.data.dataSource,
|
||||||
@ -135,6 +145,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onImportHistoricalData() {
|
||||||
|
const marketData = csvToJson(this.historicalDataAsCsvString, {
|
||||||
|
dynamicTyping: true,
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true
|
||||||
|
}).data;
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.postMarketData({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
marketData: {
|
||||||
|
marketData: marketData.map(({ date, marketPrice }) => {
|
||||||
|
return { marketPrice, date: parseISO(date) };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.initialize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
if (withRefresh) {
|
if (withRefresh) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
@ -146,9 +179,11 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
.postBenchmark({ dataSource, symbol })
|
.postBenchmark({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
setTimeout(() => {
|
this.dataService.updateInfo();
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
this.isBenchmark = true;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,6 +220,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
this.dataService
|
||||||
|
.deleteBenchmark({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.dataService.updateInfo();
|
||||||
|
|
||||||
|
this.isBenchmark = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
|
@ -37,13 +37,6 @@
|
|||||||
>
|
>
|
||||||
<ng-container i18n>Gather Profile Data</ng-container>
|
<ng-container i18n>Gather Profile Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
mat-menu-item
|
|
||||||
[disabled]="isBenchmark"
|
|
||||||
(click)="onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
|
|
||||||
>
|
|
||||||
<ng-container i18n>Set as Benchmark</ng-container>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -58,6 +51,36 @@
|
|||||||
[symbol]="data.symbol"
|
[symbol]="data.symbol"
|
||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
></gf-admin-market-data-detail>
|
></gf-admin-market-data-detail>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-label>
|
||||||
|
<ng-container i18n>Historical Data</ng-container> (CSV)
|
||||||
|
</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkAutosizeMaxRows="5"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
matInput
|
||||||
|
placeholder="e.g. 20230601;1.61"
|
||||||
|
type="text"
|
||||||
|
[ngModelOptions]="{standalone: true}"
|
||||||
|
[(ngModel)]="historicalDataAsCsvString"
|
||||||
|
(keyup.enter)="$event.stopPropagation()"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-2">
|
||||||
|
<button
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
(click)="onImportHistoricalData()"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Import</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||||
@ -151,6 +174,17 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50">
|
||||||
|
<mat-checkbox
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
[checked]="isBenchmark"
|
||||||
|
(change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
|
||||||
|
>Benchmark</mat-checkbox
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Symbol Mapping</mat-label>
|
<mat-label i18n>Symbol Mapping</mat-label>
|
||||||
|
@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
|||||||
GfPortfolioProportionChartModule,
|
GfPortfolioProportionChartModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||||
|
import { environment } from '@ghostfolio/client/../environments/environment';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public userCount: number;
|
public userCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
public version: string;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -202,15 +204,18 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
.subscribe(
|
||||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
({ exchangeRates, settings, transactionCount, userCount, version }) => {
|
||||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||||
this.exchangeRates = exchangeRates;
|
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||||
this.transactionCount = transactionCount;
|
this.exchangeRates = exchangeRates;
|
||||||
this.userCount = userCount;
|
this.transactionCount = transactionCount;
|
||||||
|
this.userCount = userCount;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCouponCode(aLength: number) {
|
private generateCouponCode(aLength: number) {
|
||||||
|
@ -3,12 +3,18 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<mat-card appearance="outlined" class="mb-3">
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Version</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<gf-value [value]="version" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>User Count</div>
|
<div class="w-50" i18n>User Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<gf-value
|
<gf-value
|
||||||
precision="0"
|
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="0"
|
||||||
[value]="userCount"
|
[value]="userCount"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -17,8 +23,8 @@
|
|||||||
<div class="w-50" i18n>Activity Count</div>
|
<div class="w-50" i18n>Activity Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<gf-value
|
<gf-value
|
||||||
precision="0"
|
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="0"
|
||||||
[value]="transactionCount"
|
[value]="transactionCount"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
<div *ngIf="transactionCount && userCount">
|
<div *ngIf="transactionCount && userCount">
|
||||||
@ -72,19 +78,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
*ngIf="info?.tags?.length > 0"
|
|
||||||
class="align-items-start d-flex my-3"
|
|
||||||
>
|
|
||||||
<div class="w-50" i18n>Tags</div>
|
|
||||||
<div class="w-50">
|
|
||||||
<table>
|
|
||||||
<tr *ngFor="let tag of info.tags">
|
|
||||||
<td class="pl-1">{{ tag.name }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>User Signup</div>
|
<div class="w-50" i18n>User Signup</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
|
@ -13,13 +13,14 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
|
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
|
||||||
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
|
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { Platform } from '@prisma/client';
|
import { Platform } from '@prisma/client';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-account-platform.component';
|
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -40,6 +41,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@ -114,10 +116,13 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((platforms) => {
|
.subscribe((platforms) => {
|
||||||
this.platforms = platforms;
|
this.platforms = platforms;
|
||||||
|
|
||||||
this.dataSource = new MatTableDataSource(platforms);
|
this.dataSource = new MatTableDataSource(platforms);
|
||||||
this.dataSource.sort = this.sort;
|
this.dataSource.sort = this.sort;
|
||||||
this.dataSource.sortingDataAccessor = get;
|
this.dataSource.sortingDataAccessor = get;
|
||||||
|
|
||||||
|
this.dataService.updateInfo();
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -130,7 +135,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
|||||||
url: null
|
url: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
});
|
});
|
||||||
@ -170,7 +174,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
|||||||
url
|
url
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
});
|
});
|
||||||
|
@ -15,8 +15,8 @@ export class CreateOrUpdatePlatformDialog {
|
|||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>,
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams
|
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public onCancel() {
|
public onCancel() {
|
@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
|
||||||
import { CreateOrUpdatePlatformDialog } from './create-or-update-account-platform.component';
|
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [CreateOrUpdatePlatformDialog],
|
declarations: [CreateOrUpdatePlatformDialog],
|
||||||
|
@ -2,14 +2,13 @@
|
|||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="text-center" i18n>Platforms</h2>
|
<h2 class="text-center" i18n>Platforms</h2>
|
||||||
<gf-admin-platform></gf-admin-platform>
|
<gf-admin-platform />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!--
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="text-center" i18n>Tags</h2>
|
<h2 class="text-center" i18n>Tags</h2>
|
||||||
|
<gf-admin-tag />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
-->
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
|
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
|
||||||
|
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
|
||||||
|
|
||||||
import { AdminSettingsComponent } from './admin-settings.component';
|
import { AdminSettingsComponent } from './admin-settings.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminSettingsComponent],
|
declarations: [AdminSettingsComponent],
|
||||||
imports: [CommonModule, GfAdminPlatformModule, RouterModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfAdminPlatformModule,
|
||||||
|
GfAdminTagModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfAdminSettingsModule {}
|
export class GfAdminSettingsModule {}
|
||||||
|
@ -0,0 +1,85 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[queryParams]="{ createTagDialog: true }"
|
||||||
|
[routerLink]="[]"
|
||||||
|
>
|
||||||
|
Add Tag
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<table
|
||||||
|
class="gf-table w-100"
|
||||||
|
mat-table
|
||||||
|
matSort
|
||||||
|
matSortActive="name"
|
||||||
|
matSortDirection="asc"
|
||||||
|
[dataSource]="dataSource"
|
||||||
|
>
|
||||||
|
<ng-container matColumnDef="name">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="name"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Name</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.name }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="activities">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="px-1"
|
||||||
|
mat-header-cell
|
||||||
|
mat-sort-header="activityCount"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Activities</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.activityCount }}
|
||||||
|
</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]="tagMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #tagMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item (click)="onUpdateTag(element)">
|
||||||
|
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||||
|
<span i18n>Edit</span>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
||||||
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||||
|
<span i18n>Delete</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,5 @@
|
|||||||
|
@import 'apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
204
apps/client/src/app/components/admin-tag/admin-tag.component.ts
Normal file
204
apps/client/src/app/components/admin-tag/admin-tag.component.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
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 { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
|
||||||
|
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { get } from 'lodash';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
|
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-admin-tag',
|
||||||
|
styleUrls: ['./admin-tag.component.scss'],
|
||||||
|
templateUrl: './admin-tag.component.html'
|
||||||
|
})
|
||||||
|
export class AdminTagComponent implements OnInit, OnDestroy {
|
||||||
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
|
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
|
||||||
|
public deviceType: string;
|
||||||
|
public displayedColumns = ['name', 'activities', 'actions'];
|
||||||
|
public tags: Tag[];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (params['createTagDialog']) {
|
||||||
|
this.openCreateTagDialog();
|
||||||
|
} else if (params['editTagDialog']) {
|
||||||
|
if (this.tags) {
|
||||||
|
const tag = this.tags.find(({ id }) => {
|
||||||
|
return id === params['tagId'];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.openUpdateTagDialog(tag);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.fetchTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteTag(aId: string) {
|
||||||
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this tag?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.deleteTag(aId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onUpdateTag({ id }: Tag) {
|
||||||
|
this.router.navigate([], {
|
||||||
|
queryParams: { editTagDialog: true, tagId: id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteTag(aId: string) {
|
||||||
|
this.adminService
|
||||||
|
.deleteTag(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
this.fetchTags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchTags() {
|
||||||
|
this.adminService
|
||||||
|
.fetchTags()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((tags) => {
|
||||||
|
this.tags = tags;
|
||||||
|
|
||||||
|
this.dataSource = new MatTableDataSource(this.tags);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
this.dataSource.sortingDataAccessor = get;
|
||||||
|
|
||||||
|
this.dataService.updateInfo();
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreateTagDialog() {
|
||||||
|
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
|
||||||
|
data: {
|
||||||
|
tag: {
|
||||||
|
name: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data) => {
|
||||||
|
const tag: CreateTagDto = data?.tag;
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
this.adminService
|
||||||
|
.postTag(tag)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
this.fetchTags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openUpdateTagDialog({ id, name }) {
|
||||||
|
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
|
||||||
|
data: {
|
||||||
|
tag: {
|
||||||
|
id,
|
||||||
|
name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data) => {
|
||||||
|
const tag: UpdateTagDto = data?.tag;
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
this.adminService
|
||||||
|
.putTag(tag)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
this.fetchTags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
26
apps/client/src/app/components/admin-tag/admin-tag.module.ts
Normal file
26
apps/client/src/app/components/admin-tag/admin-tag.module.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { AdminTagComponent } from './admin-tag.component';
|
||||||
|
import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminTagComponent],
|
||||||
|
exports: [AdminTagComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfCreateOrUpdateTagDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatTableModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAdminTagModule {}
|
@ -0,0 +1,30 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'h-100' },
|
||||||
|
selector: 'gf-create-or-update-tag-dialog',
|
||||||
|
styleUrls: ['./create-or-update-tag-dialog.scss'],
|
||||||
|
templateUrl: 'create-or-update-tag-dialog.html'
|
||||||
|
})
|
||||||
|
export class CreateOrUpdateTagDialog {
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
|
||||||
|
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public onCancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
<form #addTagForm="ngForm" class="d-flex flex-column h-100">
|
||||||
|
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
|
||||||
|
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
|
||||||
|
<div class="flex-grow-1 py-3" 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.tag.name" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
|
<button
|
||||||
|
color="primary"
|
||||||
|
mat-flat-button
|
||||||
|
[disabled]="!addTagForm.form.valid"
|
||||||
|
[mat-dialog-close]="data"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Save</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,23 @@
|
|||||||
|
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 { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [CreateOrUpdateTagDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GfCreateOrUpdateTagDialogModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface CreateOrUpdateTagDialogParams {
|
||||||
|
tag: Tag;
|
||||||
|
}
|
@ -110,6 +110,31 @@
|
|||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li *ngIf="hasPermissionToAccessAssistant" class="list-inline-item">
|
||||||
|
<button
|
||||||
|
#assistantTrigger="matMenuTrigger"
|
||||||
|
class="h-100 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[mat-menu-trigger-for]="assistantMenu"
|
||||||
|
[matMenuTriggerRestoreFocus]="false"
|
||||||
|
(menuOpened)="onOpenAssistant()"
|
||||||
|
>
|
||||||
|
<ion-icon name="search-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu
|
||||||
|
#assistantMenu="matMenu"
|
||||||
|
class="assistant"
|
||||||
|
xPosition="before"
|
||||||
|
[overlapTrigger]="true"
|
||||||
|
(closed)="assistantElement?.setIsOpen(false)"
|
||||||
|
>
|
||||||
|
<gf-assistant
|
||||||
|
#assistant
|
||||||
|
[deviceType]="deviceType"
|
||||||
|
(closed)="closeAssistant()"
|
||||||
|
/>
|
||||||
|
</mat-menu>
|
||||||
|
</li>
|
||||||
<li class="list-inline-item">
|
<li class="list-inline-item">
|
||||||
<button
|
<button
|
||||||
class="no-min-width px-1"
|
class="no-min-width px-1"
|
||||||
@ -272,7 +297,7 @@
|
|||||||
mat-flat-button
|
mat-flat-button
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'font-weight-bold': currentRoute === routeFeatures,
|
'font-weight-bold': currentRoute === routeFeatures,
|
||||||
'text-decoration-underline': currentRoute === routeFeatuers
|
'text-decoration-underline': currentRoute === routeFeatures
|
||||||
}"
|
}"
|
||||||
[routerLink]="routerLinkFeatures"
|
[routerLink]="routerLinkFeatures"
|
||||||
>Features</a
|
>Features</a
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mdc-button {
|
.mdc-button {
|
||||||
height: unset;
|
height: 100%;
|
||||||
|
|
||||||
&:not(.mat-primary) {
|
&:not(.mat-primary) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
@ -2,11 +2,14 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
Output
|
Output,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatMenuTrigger } from '@angular/material/menu';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -18,6 +21,7 @@ import {
|
|||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
|||||||
styleUrls: ['./header.component.scss']
|
styleUrls: ['./header.component.scss']
|
||||||
})
|
})
|
||||||
export class HeaderComponent implements OnChanges {
|
export class HeaderComponent implements OnChanges {
|
||||||
|
@HostListener('window:keydown', ['$event'])
|
||||||
|
openAssistantWithHotKey(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key === '/' &&
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target?.nodeName?.toLowerCase() !== 'input' &&
|
||||||
|
event.target?.nodeName?.toLowerCase() !== 'textarea' &&
|
||||||
|
this.hasPermissionToAccessAssistant
|
||||||
|
) {
|
||||||
|
this.assistantElement.setIsOpen(true);
|
||||||
|
this.assistentMenuTriggerElement.openMenu();
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Input() currentRoute: string;
|
@Input() currentRoute: string;
|
||||||
|
@Input() deviceType: string;
|
||||||
@Input() hasTabs: boolean;
|
@Input() hasTabs: boolean;
|
||||||
@Input() info: InfoItem;
|
@Input() info: InfoItem;
|
||||||
@Input() pageTitle: string;
|
@Input() pageTitle: string;
|
||||||
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
|
|||||||
|
|
||||||
@Output() signOut = new EventEmitter<void>();
|
@Output() signOut = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('assistant') assistantElement: AssistantComponent;
|
||||||
|
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
|
||||||
|
|
||||||
public hasPermissionForSocialLogin: boolean;
|
public hasPermissionForSocialLogin: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessAdminControl: boolean;
|
public hasPermissionToAccessAdminControl: boolean;
|
||||||
|
public hasPermissionToAccessAssistant: boolean;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public hasPermissionToCreateUser: boolean;
|
public hasPermissionToCreateUser: boolean;
|
||||||
public impersonationId: string;
|
public impersonationId: string;
|
||||||
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
|
|||||||
permissions.accessAdminControl
|
permissions.accessAdminControl
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToAccessAssistant = hasPermission(
|
||||||
|
this.user?.permissions,
|
||||||
|
permissions.accessAssistant
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
this.info?.globalPermissions,
|
this.info?.globalPermissions,
|
||||||
permissions.enableFearAndGreedIndex
|
permissions.enableFearAndGreedIndex
|
||||||
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public closeAssistant() {
|
||||||
|
this.assistentMenuTriggerElement?.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
public impersonateAccount(aId: string) {
|
public impersonateAccount(aId: string) {
|
||||||
if (aId) {
|
if (aId) {
|
||||||
this.impersonationStorageService.setId(aId);
|
this.impersonationStorageService.setId(aId);
|
||||||
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
this.isMenuOpen = true;
|
this.isMenuOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onOpenAssistant() {
|
||||||
|
this.assistantElement.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
this.signOut.next();
|
this.signOut.next();
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||||
|
import { GfAssistantModule } from '@ghostfolio/ui/assistant';
|
||||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||||
|
|
||||||
import { HeaderComponent } from './header.component';
|
import { HeaderComponent } from './header.component';
|
||||||
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
|
|||||||
exports: [HeaderComponent],
|
exports: [HeaderComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfAssistantModule,
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
LoginWithAccessTokenDialogModule,
|
LoginWithAccessTokenDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
|
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
|
||||||
<div class="mb-5 row">
|
<div *ngIf="hasPermissionToAccessFearAndGreedIndex" class="mb-5 row">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<div class="mb-2 text-center text-muted">
|
<div class="mb-2 text-center text-muted">
|
||||||
<small i18n>Last {{ numberOfDays }} Days</small>
|
<small i18n>Last {{ numberOfDays }} Days</small>
|
||||||
@ -8,15 +8,15 @@
|
|||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
symbol="Fear & Greed Index"
|
symbol="Fear & Greed Index"
|
||||||
yMax="100"
|
|
||||||
yMin="0"
|
|
||||||
[colorScheme]="user?.settings?.colorScheme"
|
[colorScheme]="user?.settings?.colorScheme"
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
[isAnimated]="true"
|
[isAnimated]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
|
[yMax]="100"
|
||||||
[yMaxLabel]="greedLabel"
|
[yMaxLabel]="greedLabel"
|
||||||
|
[yMin]="0"
|
||||||
[yMinLabel]="fearLabel"
|
[yMinLabel]="fearLabel"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<gf-fear-and-greed-index
|
<gf-fear-and-greed-index
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [PortfolioPerformanceComponent],
|
declarations: [PortfolioPerformanceComponent],
|
||||||
exports: [PortfolioPerformanceComponent],
|
exports: [PortfolioPerformanceComponent],
|
||||||
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule]
|
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfPortfolioPerformanceModule {}
|
export class GfPortfolioPerformanceModule {}
|
||||||
|
@ -5,6 +5,15 @@
|
|||||||
<gf-value class="justify-content-end" [value]="timeInMarket"></gf-value>
|
<gf-value class="justify-content-end" [value]="timeInMarket"></gf-value>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex-nowrap px-3 py-1 row"
|
||||||
|
[hidden]="summary?.ordersCount === null"
|
||||||
|
>
|
||||||
|
<div class="flex-grow-1 ml-3 text-truncate" i18n>
|
||||||
|
{{ summary?.ordersCount }} {summary?.ordersCount, plural, =1 {transaction}
|
||||||
|
other {transactions}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col"><hr /></div>
|
<div class="col"><hr /></div>
|
||||||
</div>
|
</div>
|
||||||
@ -75,10 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-nowrap px-3 py-1 row">
|
<div class="flex-nowrap px-3 py-1 row">
|
||||||
<div class="flex-grow-1 text-truncate" i18n>
|
<div class="flex-grow-1 text-truncate" i18n>Fees</div>
|
||||||
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
|
|
||||||
{transaction} other {transactions}}
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
|
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
|
||||||
<gf-value
|
<gf-value
|
||||||
@ -270,6 +276,18 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col"><hr /></div>
|
<div class="col"><hr /></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-nowrap px-3 py-1 row">
|
||||||
|
<div class="flex-grow-1 text-truncate" i18n>Interest</div>
|
||||||
|
<div class="justify-content-end">
|
||||||
|
<gf-value
|
||||||
|
class="justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[unit]="baseCurrency"
|
||||||
|
[value]="isLoading ? undefined : summary?.interest"
|
||||||
|
></gf-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex-nowrap px-3 py-1 row">
|
<div class="flex-nowrap px-3 py-1 row">
|
||||||
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
|
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
|
||||||
<div class="justify-content-end">
|
<div class="justify-content-end">
|
||||||
|
@ -213,11 +213,11 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="h5" i18n>Sectors</div>
|
<div class="h5" i18n>Sectors</div>
|
||||||
<gf-portfolio-proportion-chart
|
<gf-portfolio-proportion-chart
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="data.baseCurrency"
|
||||||
[colorScheme]="data.colorScheme"
|
[colorScheme]="data.colorScheme"
|
||||||
[isInPercent]="true"
|
[isInPercent]="true"
|
||||||
[keys]="['name']"
|
[keys]="['name']"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="data.locale"
|
||||||
[maxItems]="10"
|
[maxItems]="10"
|
||||||
[positions]="sectors"
|
[positions]="sectors"
|
||||||
></gf-portfolio-proportion-chart>
|
></gf-portfolio-proportion-chart>
|
||||||
@ -225,11 +225,11 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="h5" i18n>Countries</div>
|
<div class="h5" i18n>Countries</div>
|
||||||
<gf-portfolio-proportion-chart
|
<gf-portfolio-proportion-chart
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="data.baseCurrency"
|
||||||
[colorScheme]="data.colorScheme"
|
[colorScheme]="data.colorScheme"
|
||||||
[isInPercent]="true"
|
[isInPercent]="true"
|
||||||
[keys]="['name']"
|
[keys]="['name']"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="data.locale"
|
||||||
[maxItems]="10"
|
[maxItems]="10"
|
||||||
[positions]="countries"
|
[positions]="countries"
|
||||||
></gf-portfolio-proportion-chart>
|
></gf-portfolio-proportion-chart>
|
||||||
|
@ -4,7 +4,9 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
||||||
@ -17,19 +19,36 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
|||||||
templateUrl: 'create-or-update-access-dialog.html'
|
templateUrl: 'create-or-update-access-dialog.html'
|
||||||
})
|
})
|
||||||
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||||
|
public accessForm: FormGroup;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
|
||||||
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
|
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
|
private formBuilder: FormBuilder
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {}
|
ngOnInit() {
|
||||||
|
this.accessForm = this.formBuilder.group({
|
||||||
|
alias: [this.data.access.alias],
|
||||||
|
type: [this.data.access.type, Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onCancel() {
|
public onCancel() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
const access: CreateAccessDto = {
|
||||||
|
alias: this.accessForm.controls['alias'].value,
|
||||||
|
type: this.accessForm.controls['type'].value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dialogRef.close({ access });
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
@ -1,33 +1,38 @@
|
|||||||
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
|
<form
|
||||||
|
class="d-flex flex-column h-100"
|
||||||
|
[formGroup]="accessForm"
|
||||||
|
(keyup.enter)="accessForm.valid && onSubmit()"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
<h1 i18n mat-dialog-title>Grant access</h1>
|
<h1 i18n mat-dialog-title>Grant access</h1>
|
||||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Alias</mat-label>
|
<mat-label i18n>Alias</mat-label>
|
||||||
<input
|
<input
|
||||||
|
formControlName="alias"
|
||||||
matInput
|
matInput
|
||||||
name="alias"
|
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="data.access.alias"
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Type</mat-label>
|
<mat-label i18n>Type</mat-label>
|
||||||
<mat-select name="type" required [(value)]="data.access.type">
|
<mat-select formControlName="type">
|
||||||
<mat-option i18n value="PUBLIC">Public</mat-option>
|
<mat-option i18n value="PUBLIC">Public</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-content-end" mat-dialog-actions>
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!addAccessForm.form.valid"
|
type="submit"
|
||||||
[mat-dialog-close]="data"
|
[disabled]="!accessForm.valid"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Save</ng-container>
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-mdc-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-user-account-access',
|
||||||
|
styleUrls: ['./user-account-access.scss'],
|
||||||
|
templateUrl: './user-account-access.html'
|
||||||
|
})
|
||||||
|
export class UserAccountAccessComponent implements OnDestroy, OnInit {
|
||||||
|
public accesses: Access[];
|
||||||
|
public deviceType: string;
|
||||||
|
public hasPermissionToCreateAccess: boolean;
|
||||||
|
public hasPermissionToDeleteAccess: boolean;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
const { globalPermissions } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteAccess = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.deleteAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToCreateAccess = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.createAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteAccess = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.deleteAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (params['createDialog']) {
|
||||||
|
this.openCreateAccessDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteAccess(aId: string) {
|
||||||
|
this.dataService
|
||||||
|
.deleteAccess(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openCreateAccessDialog(): void {
|
||||||
|
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
|
||||||
|
data: {
|
||||||
|
access: {
|
||||||
|
alias: '',
|
||||||
|
type: 'PUBLIC'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data: any) => {
|
||||||
|
const access: CreateAccessDto = data?.access;
|
||||||
|
|
||||||
|
if (access) {
|
||||||
|
this.dataService
|
||||||
|
.postAccess({ alias: access.alias })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
this.dataService
|
||||||
|
.fetchAccesses()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((accesses) => {
|
||||||
|
this.accesses = accesses;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1
|
||||||
|
class="align-items-center d-none d-sm-flex h3 justify-content-center mb-3 text-center"
|
||||||
|
>
|
||||||
|
<span i18n>Granted Access</span>
|
||||||
|
<gf-premium-indicator
|
||||||
|
*ngIf="user?.subscription?.type === 'Basic'"
|
||||||
|
class="ml-1"
|
||||||
|
></gf-premium-indicator>
|
||||||
|
</h1>
|
||||||
|
<gf-access-table
|
||||||
|
[accesses]="accesses"
|
||||||
|
[showActions]="hasPermissionToDeleteAccess"
|
||||||
|
(accessDeleted)="onDeleteAccess($event)"
|
||||||
|
></gf-access-table>
|
||||||
|
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
|
||||||
|
<a
|
||||||
|
class="align-items-center d-flex justify-content-center"
|
||||||
|
color="primary"
|
||||||
|
mat-fab
|
||||||
|
[queryParams]="{ createDialog: true }"
|
||||||
|
[routerLink]="[]"
|
||||||
|
>
|
||||||
|
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,25 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
||||||
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
|
|
||||||
|
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
|
||||||
|
import { UserAccountAccessComponent } from './user-account-access.component';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [UserAccountAccessComponent],
|
||||||
|
exports: [UserAccountAccessComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfCreateOrUpdateAccessDialogModule,
|
||||||
|
GfPortfolioAccessTableModule,
|
||||||
|
GfPremiumIndicatorModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GfUserAccountAccessModule {}
|
@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
gf-access-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
MatSnackBar,
|
||||||
|
MatSnackBarRef,
|
||||||
|
TextOnlySnackBar
|
||||||
|
} from '@angular/material/snack-bar';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { StripeService } from 'ngx-stripe';
|
||||||
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
|
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-user-account-membership',
|
||||||
|
styleUrls: ['./user-account-membership.scss'],
|
||||||
|
templateUrl: './user-account-membership.html'
|
||||||
|
})
|
||||||
|
export class UserAccountMembershipComponent implements OnDestroy, OnInit {
|
||||||
|
public baseCurrency: string;
|
||||||
|
public coupon: number;
|
||||||
|
public couponId: string;
|
||||||
|
public defaultDateFormat: string;
|
||||||
|
public hasPermissionForSubscription: boolean;
|
||||||
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
|
public price: number;
|
||||||
|
public priceId: string;
|
||||||
|
public routerLinkPricing = ['/' + $localize`pricing`];
|
||||||
|
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||||
|
public trySubscriptionMail =
|
||||||
|
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
|
private stripeService: StripeService,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
const { baseCurrency, globalPermissions, subscriptions } =
|
||||||
|
this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.baseCurrency = baseCurrency;
|
||||||
|
|
||||||
|
this.hasPermissionForSubscription = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.enableSubscription
|
||||||
|
);
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.defaultDateFormat = getDateFormatString(
|
||||||
|
this.user.settings.locale
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.updateUserSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon;
|
||||||
|
this.couponId =
|
||||||
|
subscriptions?.[this.user.subscription.offer]?.couponId;
|
||||||
|
this.price = subscriptions?.[this.user.subscription.offer]?.price;
|
||||||
|
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {}
|
||||||
|
|
||||||
|
public onCheckout() {
|
||||||
|
this.dataService
|
||||||
|
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
|
||||||
|
.pipe(
|
||||||
|
switchMap(({ sessionId }: { sessionId: string }) => {
|
||||||
|
return this.stripeService.redirectToCheckout({ sessionId });
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
alert(error.message);
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((result) => {
|
||||||
|
if (result.error) {
|
||||||
|
alert(result.error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onRedeemCoupon() {
|
||||||
|
let couponCode = prompt($localize`Please enter your coupon code:`);
|
||||||
|
couponCode = couponCode?.trim();
|
||||||
|
|
||||||
|
if (couponCode) {
|
||||||
|
this.dataService
|
||||||
|
.redeemCoupon(couponCode)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError(() => {
|
||||||
|
this.snackBar.open(
|
||||||
|
'😞 ' + $localize`Could not redeem coupon code`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
duration: 3000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.snackBarRef = this.snackBar.open(
|
||||||
|
'✅ ' + $localize`Coupon code has been redeemed`,
|
||||||
|
$localize`Reload`,
|
||||||
|
{
|
||||||
|
duration: 3000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.snackBarRef
|
||||||
|
.afterDismissed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.snackBarRef
|
||||||
|
.onAction()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="mx-auto">
|
||||||
|
<div class="align-items-center d-flex mb-1">
|
||||||
|
<a [routerLink]="routerLinkPricing"
|
||||||
|
>{{ user?.subscription?.type }}</a
|
||||||
|
>
|
||||||
|
<gf-premium-indicator
|
||||||
|
*ngIf="user?.subscription?.type === 'Premium'"
|
||||||
|
class="ml-1"
|
||||||
|
></gf-premium-indicator>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="user?.subscription?.type === 'Premium'">
|
||||||
|
<ng-container i18n>Valid until</ng-container> {{
|
||||||
|
user?.subscription?.expiresAt | date: defaultDateFormat }}
|
||||||
|
</div>
|
||||||
|
<div *ngIf="user?.subscription?.type === 'Basic'">
|
||||||
|
<ng-container
|
||||||
|
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
|
||||||
|
>
|
||||||
|
<button color="primary" mat-flat-button (click)="onCheckout()">
|
||||||
|
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
|
||||||
|
>Upgrade</ng-container
|
||||||
|
>
|
||||||
|
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
|
||||||
|
>Renew</ng-container
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<div *ngIf="price" class="mt-1">
|
||||||
|
<ng-container *ngIf="coupon"
|
||||||
|
><del class="text-muted"
|
||||||
|
>{{ baseCurrency }} {{ price }}</del
|
||||||
|
> {{ baseCurrency }} {{ price - coupon
|
||||||
|
}}</ng-container
|
||||||
|
>
|
||||||
|
<ng-container *ngIf="!coupon"
|
||||||
|
>{{ baseCurrency }} {{ price }}</ng-container
|
||||||
|
> <span i18n>per year</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<a
|
||||||
|
*ngIf="!user?.subscription?.expiresAt"
|
||||||
|
class="mr-2 my-2"
|
||||||
|
mat-stroked-button
|
||||||
|
[href]="trySubscriptionMail"
|
||||||
|
><span i18n>Try Premium</span>
|
||||||
|
<gf-premium-indicator
|
||||||
|
class="d-inline-block ml-1"
|
||||||
|
[enableLink]="false"
|
||||||
|
></gf-premium-indicator
|
||||||
|
></a>
|
||||||
|
<a
|
||||||
|
*ngIf="hasPermissionToUpdateUserSettings"
|
||||||
|
class="mr-2 my-2"
|
||||||
|
i18n
|
||||||
|
mat-stroked-button
|
||||||
|
[routerLink]=""
|
||||||
|
(click)="onRedeemCoupon()"
|
||||||
|
>Redeem Coupon</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
|
import { UserAccountMembershipComponent } from './user-account-membership.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [UserAccountMembershipComponent],
|
||||||
|
exports: [UserAccountMembershipComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPremiumIndicatorModule,
|
||||||
|
GfValueModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GfUserAccountMembershipModule {}
|
@ -0,0 +1,8 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -0,0 +1,258 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
ViewChild
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import {
|
||||||
|
STAY_SIGNED_IN,
|
||||||
|
SettingsStorageService
|
||||||
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { uniq } from 'lodash';
|
||||||
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-user-account-settings',
|
||||||
|
styleUrls: ['./user-account-settings.scss'],
|
||||||
|
templateUrl: './user-account-settings.html'
|
||||||
|
})
|
||||||
|
export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||||
|
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
||||||
|
signInWithFingerprintElement: MatCheckbox;
|
||||||
|
|
||||||
|
public appearancePlaceholder = $localize`Auto`;
|
||||||
|
public baseCurrency: string;
|
||||||
|
public currencies: string[] = [];
|
||||||
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
|
public language = document.documentElement.lang;
|
||||||
|
public locales = [
|
||||||
|
'de',
|
||||||
|
'de-CH',
|
||||||
|
'en-GB',
|
||||||
|
'en-US',
|
||||||
|
'es',
|
||||||
|
'fr',
|
||||||
|
'it',
|
||||||
|
'nl',
|
||||||
|
'pt',
|
||||||
|
'tr'
|
||||||
|
];
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private settingsStorageService: SettingsStorageService,
|
||||||
|
private userService: UserService,
|
||||||
|
public webAuthnService: WebAuthnService
|
||||||
|
) {
|
||||||
|
const { baseCurrency, currencies } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
|
this.baseCurrency = baseCurrency;
|
||||||
|
this.currencies = currencies;
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.updateUserSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToUpdateViewMode = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.updateViewMode
|
||||||
|
);
|
||||||
|
|
||||||
|
this.locales.push(this.user.settings.locale);
|
||||||
|
this.locales = uniq(this.locales.sort());
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onChangeUserSetting(aKey: string, aValue: string) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ [aKey]: aValue })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
if (aKey === 'language') {
|
||||||
|
if (aValue) {
|
||||||
|
window.location.href = `../${aValue}/account`;
|
||||||
|
} else {
|
||||||
|
window.location.href = `../`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onExport() {
|
||||||
|
this.dataService
|
||||||
|
.fetchExport()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data) => {
|
||||||
|
for (const activity of data.activities) {
|
||||||
|
delete activity.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAsFile({
|
||||||
|
content: data,
|
||||||
|
fileName: `ghostfolio-export-${format(
|
||||||
|
parseISO(data.meta.date),
|
||||||
|
'yyyyMMddHHmm'
|
||||||
|
)}.json`,
|
||||||
|
format: 'json'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onRestrictedViewChange(aEvent: MatCheckboxChange) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ isRestrictedView: aEvent.checked })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) {
|
||||||
|
if (aEvent.checked) {
|
||||||
|
this.registerDevice();
|
||||||
|
} else {
|
||||||
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to remove this sign in method?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.deregisterDevice();
|
||||||
|
} else {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onViewModeChange(aEvent: MatCheckboxChange) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private deregisterDevice() {
|
||||||
|
this.webAuthnService
|
||||||
|
.deregister()
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError(() => {
|
||||||
|
this.update();
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerDevice() {
|
||||||
|
this.webAuthnService
|
||||||
|
.register()
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError(() => {
|
||||||
|
this.update();
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
if (this.signInWithFingerprintElement) {
|
||||||
|
this.signInWithFingerprintElement.checked =
|
||||||
|
this.webAuthnService.isEnabled() ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user