Compare commits
73 Commits
Author | SHA1 | Date | |
---|---|---|---|
ea65dc5034 | |||
84db54babd | |||
653c9c62a8 | |||
74278073b3 | |||
0375b938a2 | |||
32df7620d9 | |||
8492a8fed0 | |||
30e561c06f | |||
7243090c0e | |||
7ae49eb839 | |||
bf816c3b89 | |||
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 |
129
CHANGELOG.md
129
CHANGELOG.md
@ -5,6 +5,123 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 2.12.0 - 2023-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances
|
||||
- Added support to search for an asset profile by `isin`, `name` and `symbol` as an administrator (experimental)
|
||||
- Added support for creating asset profiles with `MANUAL` data source
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the checkboxes to slide toggles in the user settings of the user account page
|
||||
- Extended the `copy-assets` `Nx` target to copy the locales to the server’s assets
|
||||
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `5.2.1` to `8.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Displayed the transfer cash balance button based on a permission
|
||||
- Fixed the biometric authentication
|
||||
- Fixed the query to get asset profiles that match both the `dataSource` and `symbol` values
|
||||
|
||||
## 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
|
||||
@ -29,13 +146,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the preselected currency based on the account's currency in the create or edit activity dialog
|
||||
- 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
|
||||
- Fixed a memory leak related to the server’s timezone (behind UTC) in the data gathering
|
||||
|
||||
## 2.3.0 - 2023-09-17
|
||||
|
||||
@ -186,7 +303,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the activities import by allowing a different currency than the asset's official one
|
||||
- Optimized the activities import by allowing a different currency than the asset’s official one
|
||||
- Added a timeout to the _EOD Historical Data_ requests
|
||||
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
|
||||
|
||||
@ -693,7 +810,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Persisted today's market data continuously
|
||||
- Persisted today’s market data continuously
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -927,7 +1044,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Filtered activities with type `ITEM` from search results
|
||||
- Considered the user's language in the _Stripe_ checkout
|
||||
- Considered the user’s language in the _Stripe_ checkout
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
||||
|
||||
@ -2601,7 +2718,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Moved the countries and sectors charts in the position detail dialog
|
||||
- Distinguished today's data point of historical data in the admin control panel
|
||||
- Distinguished today’s data point of historical data in the admin control panel
|
||||
- Restructured the server modules
|
||||
|
||||
### Fixed
|
||||
|
@ -18,6 +18,12 @@
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Access database via GUI
|
||||
|
||||
Run `yarn database:gui`
|
||||
|
||||
https://www.prisma.io/studio
|
||||
|
||||
#### Synchronize schema with database for prototyping
|
||||
|
||||
Run `yarn database:push`
|
||||
|
@ -8,4 +8,8 @@ export class CreateAccessDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
granteeUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
type?: 'PUBLIC';
|
||||
}
|
||||
|
@ -1,8 +1,12 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountBalancesResponse,
|
||||
Accounts
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
AccountWithValue,
|
||||
@ -29,11 +33,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountService } from './account.service';
|
||||
import { CreateAccountDto } from './create-account.dto';
|
||||
import { TransferBalanceDto } from './transfer-balance.dto';
|
||||
import { UpdateAccountDto } from './update-account.dto';
|
||||
|
||||
@Controller('account')
|
||||
export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@ -115,6 +121,18 @@ export class AccountController {
|
||||
return accountsWithAggregations.accounts[0];
|
||||
}
|
||||
|
||||
@Get(':id/balances')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountBalancesById(
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalancesResponse> {
|
||||
return this.accountBalanceService.getAccountBalances({
|
||||
accountId: id,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createAccount(
|
||||
@ -154,6 +172,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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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({
|
||||
include: { Order: true, Platform: true },
|
||||
orderBy: { name: 'asc' },
|
||||
@ -218,13 +218,13 @@ export class AccountService {
|
||||
accountId,
|
||||
amount,
|
||||
currency,
|
||||
date,
|
||||
date = new Date(),
|
||||
userId
|
||||
}: {
|
||||
accountId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
date: Date;
|
||||
date?: Date;
|
||||
userId: string;
|
||||
}) {
|
||||
const { balance, currency: currencyOfAccount } = await this.account({
|
||||
|
@ -12,7 +12,7 @@ import { isString } from 'lodash';
|
||||
export class CreateAccountDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
accountType?: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
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 {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
accountType?: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -12,8 +12,7 @@ import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
EnhancedSymbolProfile
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -43,12 +42,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
@ -254,6 +255,7 @@ export class AdminController {
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||
@Query('presetId') presetId?: MarketDataPreset,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@ -271,16 +273,10 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...assetSubClasses.map((assetSubClass) => {
|
||||
return <Filter>{
|
||||
id: assetSubClass,
|
||||
type: 'ASSET_SUB_CLASS'
|
||||
};
|
||||
})
|
||||
];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAssetSubClasses,
|
||||
filterBySearchQuery
|
||||
});
|
||||
|
||||
return this.adminService.getMarketData({
|
||||
filters,
|
||||
@ -313,6 +309,43 @@ export class AdminController {
|
||||
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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(
|
||||
@ -365,8 +398,11 @@ export class AdminController {
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||
return this.adminService.addAssetProfile({
|
||||
dataSource,
|
||||
symbol,
|
||||
currency: this.request.user.Settings.settings.baseCurrency
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
@ -40,10 +41,19 @@ export class AdminService {
|
||||
) {}
|
||||
|
||||
public async addAssetProfile({
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
||||
try {
|
||||
if (dataSource === 'MANUAL') {
|
||||
return this.symbolProfileService.add({
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
@ -97,7 +107,8 @@ export class AdminService {
|
||||
settings: await this.propertyService.get(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
users: await this.getUsersWithAnalytics()
|
||||
users: await this.getUsersWithAnalytics(),
|
||||
version: environment.version
|
||||
};
|
||||
}
|
||||
|
||||
@ -129,10 +140,14 @@ export class AdminService {
|
||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||
}
|
||||
|
||||
const searchQuery = filters.find(({ type }) => {
|
||||
return type === 'SEARCH_QUERY';
|
||||
})?.id;
|
||||
|
||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
return filter.type;
|
||||
({ type }) => {
|
||||
return type;
|
||||
}
|
||||
);
|
||||
|
||||
@ -145,6 +160,14 @@ export class AdminService {
|
||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
where.OR = [
|
||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||
];
|
||||
}
|
||||
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
|
||||
@ -171,7 +194,9 @@ export class AdminService {
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
@ -192,7 +217,9 @@ export class AdminService {
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
symbol
|
||||
@ -211,8 +238,10 @@ export class AdminService {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
@ -339,6 +368,8 @@ export class AdminService {
|
||||
symbol,
|
||||
assetClass: 'CASH',
|
||||
countriesCount: 0,
|
||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||
name: symbol,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
|
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 {
|
||||
@IsDate()
|
||||
@IsOptional()
|
||||
date?: Date;
|
||||
|
||||
@IsNumber()
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SitemapModule } from './sitemap/sitemap.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { TagModule } from './tag/tag.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@Module({
|
||||
@ -101,6 +102,7 @@ import { UserModule } from './user/user.module';
|
||||
SitemapModule,
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
TagModule,
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
],
|
||||
|
@ -64,7 +64,7 @@ export class WebAuthService {
|
||||
}
|
||||
};
|
||||
|
||||
const options = generateRegistrationOptions(opts);
|
||||
const options = await generateRegistrationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
@ -88,10 +88,16 @@ export class WebAuthService {
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
const opts: VerifyRegistrationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
expectedRPID: this.rpID,
|
||||
response: {
|
||||
clientExtensionResults: credential.clientExtensionResults,
|
||||
id: credential.id,
|
||||
rawId: credential.rawId,
|
||||
response: credential.response,
|
||||
type: 'public-key'
|
||||
}
|
||||
};
|
||||
verification = await verifyRegistrationResponse(opts);
|
||||
} catch (error) {
|
||||
@ -117,8 +123,8 @@ export class WebAuthService {
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
counter,
|
||||
credentialPublicKey,
|
||||
credentialId: credentialID,
|
||||
credentialId: Buffer.from(credentialID),
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
User: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
@ -152,7 +158,7 @@ export class WebAuthService {
|
||||
userVerification: 'preferred'
|
||||
};
|
||||
|
||||
const options = generateAuthenticationOptions(opts);
|
||||
const options = await generateAuthenticationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
@ -181,7 +187,6 @@ export class WebAuthService {
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
credential,
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
@ -189,9 +194,16 @@ export class WebAuthService {
|
||||
},
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
expectedRPID: this.rpID,
|
||||
response: {
|
||||
clientExtensionResults: credential.clientExtensionResults,
|
||||
id: credential.id,
|
||||
rawId: credential.rawId,
|
||||
response: credential.response,
|
||||
type: 'public-key'
|
||||
}
|
||||
};
|
||||
verification = verifyAuthenticationResponse(opts);
|
||||
verification = await verifyAuthenticationResponse(opts);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'WebAuthService');
|
||||
throw new InternalServerErrorException({ error: error.message });
|
||||
|
@ -10,6 +10,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
@ -32,32 +33,6 @@ export class BenchmarkController {
|
||||
@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()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
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 promises: Promise<number>[] = [];
|
||||
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
@ -85,15 +85,14 @@ export class BenchmarkService {
|
||||
|
||||
let performancePercentFromAllTimeHigh = 0;
|
||||
|
||||
if (allTimeHigh && marketPrice) {
|
||||
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||
allTimeHigh,
|
||||
allTimeHigh.marketPrice,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
@ -101,6 +100,7 @@ export class BenchmarkService {
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
date: allTimeHigh.date,
|
||||
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) {
|
||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
}
|
||||
|
@ -55,12 +55,8 @@ export class InfoService {
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
let isReadOnlyMode: boolean;
|
||||
const platforms = (
|
||||
await this.platformService.getPlatforms({
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
).map(({ id, name }) => {
|
||||
return { id, name };
|
||||
const platforms = await this.platformService.getPlatforms({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
let systemMessage: string;
|
||||
|
||||
|
@ -89,7 +89,9 @@ export class OrderController {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
@Query('skip') skip?: number,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('take') take?: number
|
||||
): Promise<Activities> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
@ -105,6 +107,8 @@ export class OrderController {
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
take: isNaN(take) ? undefined : take,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
@ -230,6 +230,8 @@ export class OrderService {
|
||||
public async getOrders({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
skip,
|
||||
take = Number.MAX_SAFE_INTEGER,
|
||||
types,
|
||||
userCurrency,
|
||||
userId,
|
||||
@ -237,6 +239,8 @@ export class OrderService {
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
@ -315,6 +319,8 @@ export class OrderService {
|
||||
|
||||
return (
|
||||
await this.orders({
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
@ -47,6 +47,7 @@ export class PlatformController {
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.platformService.createPlatform(data);
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client';
|
||||
export class PlatformService {
|
||||
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(
|
||||
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||
): Promise<Platform> {
|
||||
@ -56,12 +68,6 @@ export class PlatformService {
|
||||
});
|
||||
}
|
||||
|
||||
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||
return this.prismaService.platform.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async updatePlatform({
|
||||
data,
|
||||
where
|
||||
@ -74,10 +80,4 @@ export class PlatformService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async deletePlatform(
|
||||
where: Prisma.PlatformWhereUniqueInput
|
||||
): Promise<Platform> {
|
||||
return this.prismaService.platform.delete({ where });
|
||||
}
|
||||
}
|
||||
|
@ -391,12 +391,14 @@ export class PortfolioController {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPositions> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterBySearchQuery,
|
||||
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 { 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 { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
|
||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
@ -50,13 +51,13 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import {
|
||||
Account,
|
||||
Type as ActivityType,
|
||||
AssetClass,
|
||||
DataSource,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as ActivityType
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
@ -1013,6 +1014,9 @@ export class PortfolioService {
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
}): 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 { portfolioOrders, transactionPoints } =
|
||||
@ -1041,9 +1045,9 @@ export class PortfolioService {
|
||||
const currentPositions =
|
||||
await portfolioCalculator.getCurrentPositions(startDate);
|
||||
|
||||
const positions = currentPositions.positions.filter(
|
||||
(item) => !item.quantity.eq(0)
|
||||
);
|
||||
let positions = currentPositions.positions.filter(({ quantity }) => {
|
||||
return !quantity.eq(0);
|
||||
});
|
||||
|
||||
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
@ -1066,12 +1070,25 @@ export class PortfolioService {
|
||||
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 {
|
||||
hasErrors: currentPositions.hasErrors,
|
||||
positions: positions.map((position) => {
|
||||
return {
|
||||
...position,
|
||||
assetClass: symbolProfileMap[position.symbol].assetClass,
|
||||
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
|
||||
averagePrice: new Big(position.averagePrice).toNumber(),
|
||||
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
||||
grossPerformancePercentage:
|
||||
@ -1214,12 +1231,6 @@ export class PortfolioService {
|
||||
userId
|
||||
});
|
||||
|
||||
if (isEmpty(orders)) {
|
||||
return {
|
||||
rules: {}
|
||||
};
|
||||
}
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
@ -1228,7 +1239,9 @@ export class PortfolioService {
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const portfolioStart = parseDate(
|
||||
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
|
||||
);
|
||||
const currentPositions =
|
||||
await portfolioCalculator.getCurrentPositions(portfolioStart);
|
||||
|
||||
@ -1249,33 +1262,48 @@ export class PortfolioService {
|
||||
userId
|
||||
});
|
||||
|
||||
const userSettings = <UserSettings>this.request.user.Settings.settings;
|
||||
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
accountClusterRisk: isEmpty(orders)
|
||||
? undefined
|
||||
: await this.rulesService.evaluate(
|
||||
[
|
||||
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,
|
||||
accounts
|
||||
userSettings.emergencyFund
|
||||
)
|
||||
],
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
),
|
||||
currencyClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
positions
|
||||
),
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
positions
|
||||
)
|
||||
],
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
userSettings
|
||||
),
|
||||
fees: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -1285,7 +1313,7 @@ export class PortfolioService {
|
||||
this.getFees({ userCurrency, activities: orders }).toNumber()
|
||||
)
|
||||
],
|
||||
<UserSettings>this.request.user.Settings.settings
|
||||
userSettings
|
||||
)
|
||||
}
|
||||
};
|
||||
|
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);
|
||||
|
||||
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||
currentPermissions = without(
|
||||
currentPermissions,
|
||||
permissions.accessAssistant
|
||||
);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
user.subscription =
|
||||
this.subscriptionService.getSubscription(Subscription);
|
||||
|
@ -78,6 +78,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -146,6 +150,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
@ -262,6 +270,10 @@
|
||||
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/faq</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -320,6 +332,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -388,6 +404,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
@ -594,6 +614,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -662,6 +686,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
@ -714,6 +742,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -782,6 +814,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
|
@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash';
|
||||
|
||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
for (const key in aObject) {
|
||||
if (aObject[key] === null || aObject[key] === null) {
|
||||
if (aObject[key] === null || aObject[key] === undefined) {
|
||||
return true;
|
||||
} else if (isObject(aObject[key])) {
|
||||
return hasNotDefinedValuesInObject(aObject[key]);
|
||||
|
@ -80,6 +80,10 @@ const locales = {
|
||||
'/en/blog/2023/09/ghostfolio-2': {
|
||||
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
|
||||
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 { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
@ -6,16 +7,18 @@ import {
|
||||
UserSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
private accounts: PortfolioDetails['accounts'];
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Investment'
|
||||
});
|
||||
|
||||
this.accounts = accounts;
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
|
@ -1,17 +1,20 @@
|
||||
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 { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
private accounts: PortfolioDetails['accounts'];
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Single Account'
|
||||
});
|
||||
|
||||
this.accounts = accounts;
|
||||
}
|
||||
|
||||
public evaluate() {
|
||||
|
@ -1,17 +1,20 @@
|
||||
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 { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||
private positions: TimelinePosition[];
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private positions: TimelinePosition[]
|
||||
positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Investment: Base Currency'
|
||||
});
|
||||
|
||||
this.positions = positions;
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
|
@ -1,17 +1,20 @@
|
||||
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 { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
private positions: TimelinePosition[];
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private positions: TimelinePosition[]
|
||||
positions: TimelinePosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Investment'
|
||||
});
|
||||
|
||||
this.positions = positions;
|
||||
}
|
||||
|
||||
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 { 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';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
private fees: number;
|
||||
private totalInvestment: number;
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private totalInvestment: number,
|
||||
private fees: number
|
||||
totalInvestment: number,
|
||||
fees: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Investment'
|
||||
name: 'Fee Ratio'
|
||||
});
|
||||
|
||||
this.fees = fees;
|
||||
this.totalInvestment = totalInvestment;
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const feeRatio = this.fees / this.totalInvestment;
|
||||
const feeRatio = this.totalInvestment
|
||||
? this.fees / this.totalInvestment
|
||||
: 0;
|
||||
|
||||
if (feeRatio > ruleSettings.threshold) {
|
||||
return {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountBalance, Prisma } from '@prisma/client';
|
||||
|
||||
@ -13,4 +14,29 @@ export class AccountBalanceService {
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccountBalances({
|
||||
accountId,
|
||||
userId
|
||||
}: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}): Promise<AccountBalancesResponse> {
|
||||
const balances = await this.prismaService.accountBalance.findMany({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
id: true,
|
||||
value: true
|
||||
},
|
||||
where: {
|
||||
accountId,
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
return { balances };
|
||||
}
|
||||
}
|
||||
|
@ -8,14 +8,20 @@ export class ApiService {
|
||||
public buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByAssetSubClasses,
|
||||
filterBySearchQuery,
|
||||
filterByTags
|
||||
}: {
|
||||
filterByAccounts?: string;
|
||||
filterByAssetClasses?: string;
|
||||
filterByAssetSubClasses?: string;
|
||||
filterBySearchQuery?: string;
|
||||
filterByTags?: string;
|
||||
}): Filter[] {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||
const searchQuery = filterBySearchQuery?.toLowerCase();
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
return [
|
||||
@ -31,6 +37,16 @@ export class ApiService {
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...assetSubClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_SUB_CLASS'
|
||||
};
|
||||
}),
|
||||
{
|
||||
id: searchQuery,
|
||||
type: 'SEARCH_QUERY'
|
||||
},
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
|
@ -283,7 +283,6 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
if (symbol.endsWith('.FOREX')) {
|
||||
symbol = symbol.replace('GBX', 'GBp');
|
||||
symbol = symbol.replace('.FOREX', '');
|
||||
symbol = `${DEFAULT_CURRENCY}${symbol}`;
|
||||
}
|
||||
|
||||
return symbol;
|
||||
@ -292,7 +291,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
/**
|
||||
* Converts a symbol to a EOD symbol
|
||||
*
|
||||
* Currency: USDCHF -> CHF.FOREX
|
||||
* Currency: USDCHF -> USDCHF.FOREX
|
||||
*/
|
||||
private convertToEodSymbol(aSymbol: string) {
|
||||
if (
|
||||
@ -304,9 +303,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
|
||||
)
|
||||
) {
|
||||
return `${aSymbol
|
||||
.replace('GBp', 'GBX')
|
||||
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
|
||||
let symbol = aSymbol;
|
||||
symbol = symbol.replace('GBp', 'GBX');
|
||||
|
||||
return `${symbol}.FOREX`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,18 +39,22 @@ export class MarketDataService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
|
||||
const aggregations = await this.prismaService.marketData.aggregate({
|
||||
_max: {
|
||||
public async getMax({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.prismaService.marketData.findFirst({
|
||||
select: {
|
||||
date: true,
|
||||
marketPrice: true
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
marketPrice: 'desc'
|
||||
}
|
||||
],
|
||||
where: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
});
|
||||
|
||||
return aggregations._max.marketPrice;
|
||||
}
|
||||
|
||||
public async getRange({
|
||||
|
@ -52,20 +52,12 @@ export class SymbolProfileService {
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
dataSource: {
|
||||
in: aUniqueAssets.map(({ dataSource }) => {
|
||||
return dataSource;
|
||||
})
|
||||
},
|
||||
symbol: {
|
||||
in: aUniqueAssets.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol
|
||||
};
|
||||
})
|
||||
}
|
||||
})
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
|
@ -21,6 +21,7 @@
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [],
|
||||
"styles": [
|
||||
"apps/client/src/assets/fonts/inter.css",
|
||||
"apps/client/src/styles/theme.scss",
|
||||
"apps/client/src/styles.scss"
|
||||
],
|
||||
@ -103,40 +104,43 @@
|
||||
"options": {
|
||||
"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 -r apps/client/src/locales dist/apps/api/assets"
|
||||
},
|
||||
{
|
||||
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
|
||||
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
||||
},
|
||||
{
|
||||
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
|
||||
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
|
||||
},
|
||||
{
|
||||
"command": "cp CHANGELOG.md dist/apps/client/assets"
|
||||
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
|
||||
},
|
||||
{
|
||||
"command": "cp LICENSE dist/apps/client/assets"
|
||||
"command": "shx cp CHANGELOG.md dist/apps/client/assets"
|
||||
},
|
||||
{
|
||||
"command": "shx cp LICENSE dist/apps/client/assets"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -32,6 +32,7 @@
|
||||
<gf-header
|
||||
class="position-fixed w-100"
|
||||
[currentRoute]="currentRoute"
|
||||
[deviceType]="deviceType"
|
||||
[hasTabs]="hasTabs"
|
||||
[info]="info"
|
||||
[pageTitle]="pageTitle"
|
||||
|
@ -112,6 +112,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.hasTabs =
|
||||
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
|
||||
this.currentRoute === 'account' ||
|
||||
this.currentRoute === 'admin' ||
|
||||
this.currentRoute === 'home' ||
|
||||
this.currentRoute === 'portfolio' ||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Platform } from '@angular/cdk/platform';
|
||||
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 { MatChipsModule } from '@angular/material/chips';
|
||||
import {
|
||||
@ -35,6 +35,7 @@ export function NgxStripeFactory(): string {
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
bootstrap: [AppComponent],
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
AppRoutingModule,
|
||||
@ -72,6 +73,6 @@ export function NgxStripeFactory(): string {
|
||||
useFactory: NgxStripeFactory
|
||||
}
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
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">
|
||||
<ng-container matColumnDef="alias">
|
||||
<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 {
|
||||
@Input() accesses: Access[];
|
||||
@Input() hasPermissionToCreateAccess = false;
|
||||
@Input() showActions: boolean;
|
||||
|
||||
@Output() accessDeleted = new EventEmitter<string>();
|
||||
|
@ -1,3 +1,14 @@
|
||||
<div *ngIf="showActions" 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">
|
||||
<ng-container matColumnDef="status">
|
||||
<th
|
||||
|
@ -34,6 +34,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
@Output() accountDeleted = new EventEmitter<string>();
|
||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||
@Output() transferBalance = new EventEmitter<void>();
|
||||
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
@ -97,6 +98,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
alert(aComment);
|
||||
}
|
||||
|
||||
public onTransferBalance() {
|
||||
this.transferBalance.emit();
|
||||
}
|
||||
|
||||
public onUpdateAccount(aAccount: AccountModel) {
|
||||
this.accountToUpdate.emit(aAccount);
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
||||
@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
|
||||
export class AdminJobsComponent implements OnDestroy, OnInit {
|
||||
public defaultDateTimeFormat: string;
|
||||
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 user: User;
|
||||
|
||||
@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
|
||||
.fetchJobs({ status: aStatus })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ jobs }) => {
|
||||
this.jobs = jobs;
|
||||
this.dataSource = new MatTableDataSource(jobs);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
@ -13,122 +13,158 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
<table class="gf-table w-100">
|
||||
<thead>
|
||||
<tr class="mat-header-row">
|
||||
<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 class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
||||
<th class="mat-header-cell px-1 py-2">
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="jobsActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="index">
|
||||
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
|
||||
#
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
|
||||
{{ element.id }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||
<ng-container i18n>Type</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
|
||||
Asset Profile
|
||||
</ng-container>
|
||||
<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>
|
||||
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onDeleteJobs()">
|
||||
<ng-container i18n>Delete Jobs</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ng-container *ngFor="let job of jobs">
|
||||
<tr class="mat-row">
|
||||
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="arrow-down-circle-outline"
|
||||
></ion-icon>
|
||||
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
|
||||
<span i18n>Asset Profile</span>
|
||||
</ng-container>
|
||||
<ng-container
|
||||
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
|
||||
>
|
||||
<span i18n>Historical Market Data</span>
|
||||
</ng-container>
|
||||
</span>
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
{{ 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>
|
||||
</mat-menu>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||
<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(element.data)">
|
||||
<ng-container i18n>View Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.stacktrace?.length <= 0"
|
||||
(click)="onViewStacktrace(element.stacktrace)"
|
||||
>
|
||||
<ng-container i18n>View Stacktrace</ng-container>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeleteJob(element.id)">
|
||||
<ng-container i18n>Delete Job</ng-container>
|
||||
</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>
|
||||
|
@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
|
||||
import { AdminJobsComponent } from './admin-jobs.component';
|
||||
|
||||
@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
|
||||
MatButtonModule,
|
||||
MatMenuModule,
|
||||
MatSelectModule,
|
||||
MatTableModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -177,7 +177,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ withRefresh }) => {
|
||||
.subscribe(({ withRefresh } = { withRefresh: false }) => {
|
||||
this.marketDataChanged.next(withRefresh);
|
||||
});
|
||||
}
|
||||
|
@ -178,10 +178,20 @@ export class AdminMarketDataComponent
|
||||
}
|
||||
|
||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminService
|
||||
.deleteProfileData({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete this asset profile?`
|
||||
);
|
||||
|
||||
if (confirmation) {
|
||||
this.adminService
|
||||
.deleteProfileData({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public onGather7Days() {
|
||||
@ -342,7 +352,7 @@ export class AdminMarketDataComponent
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ dataSource, symbol }) => {
|
||||
.subscribe(({ dataSource, symbol } = {}) => {
|
||||
if (dataSource && symbol) {
|
||||
this.adminService
|
||||
.addAssetProfile({ dataSource, symbol })
|
||||
|
@ -2,11 +2,4 @@
|
||||
|
||||
:host {
|
||||
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 { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataDetails,
|
||||
ScraperConfiguration,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { MarketData, SymbolProfile } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { parse as csvToJson } from 'papaparse';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -43,12 +45,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public historicalDataAsCsvString: string;
|
||||
public isBenchmark = false;
|
||||
public marketDataDetails: MarketData[] = [];
|
||||
public sectors: {
|
||||
[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>();
|
||||
|
||||
public constructor(
|
||||
@ -67,6 +74,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
this.historicalDataAsCsvString =
|
||||
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
|
||||
|
||||
this.adminService
|
||||
.fetchAdminMarketDataBySymbol({
|
||||
dataSource: this.data.dataSource,
|
||||
@ -135,6 +145,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
.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) {
|
||||
if (withRefresh) {
|
||||
this.initialize();
|
||||
@ -146,9 +179,11 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
.postBenchmark({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
this.dataService.updateInfo();
|
||||
|
||||
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() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -37,13 +37,6 @@
|
||||
>
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@ -58,6 +51,36 @@
|
||||
[symbol]="data.symbol"
|
||||
(marketDataChanged)="onMarketDataChanged($event)"
|
||||
></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="col-6 mb-3">
|
||||
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||
@ -151,6 +174,17 @@
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</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">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<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 { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||
GfPortfolioProportionChartModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
@ -19,35 +19,75 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'h-100' },
|
||||
selector: 'gf-create-asset-profile-dialog',
|
||||
styleUrls: ['./create-asset-profile-dialog.component.scss'],
|
||||
templateUrl: 'create-asset-profile-dialog.html'
|
||||
})
|
||||
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||
public createAssetProfileForm: FormGroup;
|
||||
public mode: 'auto' | 'manual';
|
||||
|
||||
public constructor(
|
||||
public readonly adminService: AdminService,
|
||||
public readonly changeDetectorRef: ChangeDetectorRef,
|
||||
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
||||
public readonly formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.createAssetProfileForm = this.formBuilder.group({
|
||||
searchSymbol: new FormControl(null, [Validators.required])
|
||||
});
|
||||
this.createAssetProfileForm = this.formBuilder.group(
|
||||
{
|
||||
addSymbol: new FormControl(null, [Validators.required]),
|
||||
searchSymbol: new FormControl(null, [Validators.required])
|
||||
},
|
||||
{
|
||||
validators: this.atLeastOneValid
|
||||
}
|
||||
);
|
||||
|
||||
this.mode = 'auto';
|
||||
}
|
||||
|
||||
public onCancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onRadioChange(mode: 'auto' | 'manual') {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
this.dialogRef.close({
|
||||
dataSource:
|
||||
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
|
||||
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||
});
|
||||
this.mode === 'auto'
|
||||
? this.dialogRef.close({
|
||||
dataSource:
|
||||
this.createAssetProfileForm.controls['searchSymbol'].value
|
||||
.dataSource,
|
||||
symbol:
|
||||
this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||
})
|
||||
: this.dialogRef.close({
|
||||
dataSource: 'MANUAL',
|
||||
symbol: this.createAssetProfileForm.controls['addSymbol'].value
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {}
|
||||
|
||||
private atLeastOneValid(control: AbstractControl): ValidationErrors {
|
||||
const addSymbolControl = control.get('addSymbol');
|
||||
const searchSymbolControl = control.get('searchSymbol');
|
||||
|
||||
if (addSymbolControl.valid && searchSymbolControl.valid) {
|
||||
return { atLeastOneValid: true };
|
||||
}
|
||||
|
||||
if (
|
||||
addSymbolControl.valid ||
|
||||
!addSymbolControl ||
|
||||
searchSymbolControl.valid ||
|
||||
!searchSymbolControl
|
||||
) {
|
||||
return { atLeastOneValid: false };
|
||||
}
|
||||
|
||||
return { atLeastOneValid: true };
|
||||
}
|
||||
}
|
||||
|
@ -6,13 +6,35 @@
|
||||
>
|
||||
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
|
||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||
<gf-symbol-autocomplete
|
||||
formControlName="searchSymbol"
|
||||
[includeIndices]="true"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<div class="mb-3">
|
||||
<mat-radio-group
|
||||
color="primary"
|
||||
[value]="mode"
|
||||
(change)="onRadioChange($event.value)"
|
||||
>
|
||||
<mat-radio-button name="auto" value="auto"></mat-radio-button>
|
||||
<label class="m-0" for="auto" i18n>Search</label>
|
||||
<mat-radio-button class="ml-3" name="manual" value="manual">
|
||||
</mat-radio-button>
|
||||
<label class="m-0" for="manual" i18n>Add Manually</label>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<div *ngIf="mode === 'auto'">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||
<gf-symbol-autocomplete
|
||||
formControlName="searchSymbol"
|
||||
[includeIndices]="true"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="mode === 'manual'">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Symbol</mat-label>
|
||||
<input formControlName="addSymbol" matInput />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||
@ -20,7 +42,7 @@
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="!createAssetProfileForm.valid"
|
||||
[disabled]="createAssetProfileForm.hasError('atLeastOneValid')"
|
||||
>
|
||||
<ng-container i18n>Save</ng-container>
|
||||
</button>
|
||||
|
@ -4,6 +4,8 @@ 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 { MatRadioModule } from '@angular/material/radio';
|
||||
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
||||
|
||||
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
||||
@ -17,6 +19,8 @@ import { CreateAssetProfileDialog } from './create-asset-profile-dialog.componen
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatRadioModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||
import { environment } from '@ghostfolio/client/../environments/environment';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public transactionCount: number;
|
||||
public userCount: number;
|
||||
public user: User;
|
||||
public version: string;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -202,15 +204,18 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
this.adminService
|
||||
.fetchAdminData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||
this.exchangeRates = exchangeRates;
|
||||
this.transactionCount = transactionCount;
|
||||
this.userCount = userCount;
|
||||
.subscribe(
|
||||
({ exchangeRates, settings, transactionCount, userCount, version }) => {
|
||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||
this.exchangeRates = exchangeRates;
|
||||
this.transactionCount = transactionCount;
|
||||
this.userCount = userCount;
|
||||
this.version = version;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private generateCouponCode(aLength: number) {
|
||||
|
@ -3,12 +3,18 @@
|
||||
<div class="col">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<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="w-50" i18n>User Count</div>
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
precision="0"
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="userCount"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -17,8 +23,8 @@
|
||||
<div class="w-50" i18n>Activity Count</div>
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
precision="0"
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="transactionCount"
|
||||
></gf-value>
|
||||
<div *ngIf="transactionCount && userCount">
|
||||
@ -72,19 +78,6 @@
|
||||
</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="w-50" i18n>User Signup</div>
|
||||
<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 { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.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 { Platform } from '@prisma/client';
|
||||
import { get } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
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({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@ -40,6 +41,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
@ -114,10 +116,13 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((platforms) => {
|
||||
this.platforms = platforms;
|
||||
|
||||
this.dataSource = new MatTableDataSource(platforms);
|
||||
this.dataSource.sort = this.sort;
|
||||
this.dataSource.sortingDataAccessor = get;
|
||||
|
||||
this.dataService.updateInfo();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
@ -130,7 +135,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
||||
url: null
|
||||
}
|
||||
},
|
||||
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
@ -170,7 +174,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
|
||||
url
|
||||
}
|
||||
},
|
||||
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
@ -15,8 +15,8 @@ export class CreateOrUpdatePlatformDialog {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
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() {
|
@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
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({
|
||||
declarations: [CreateOrUpdatePlatformDialog],
|
||||
|
@ -2,14 +2,13 @@
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h2 class="text-center" i18n>Platforms</h2>
|
||||
<gf-admin-platform></gf-admin-platform>
|
||||
<gf-admin-platform />
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2 class="text-center" i18n>Tags</h2>
|
||||
<gf-admin-tag />
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
|
@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
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';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AdminSettingsComponent],
|
||||
imports: [CommonModule, GfAdminPlatformModule, RouterModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfAdminPlatformModule,
|
||||
GfAdminTagModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
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,34 @@
|
||||
>About</a
|
||||
>
|
||||
</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"
|
||||
[hasPermissionToAccessAdminControl]="
|
||||
hasPermissionToAccessAdminControl
|
||||
"
|
||||
(closed)="closeAssistant()"
|
||||
/>
|
||||
</mat-menu>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<button
|
||||
class="no-min-width px-1"
|
||||
@ -272,7 +300,7 @@
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === routeFeatures,
|
||||
'text-decoration-underline': currentRoute === routeFeatuers
|
||||
'text-decoration-underline': currentRoute === routeFeatures
|
||||
}"
|
||||
[routerLink]="routerLinkFeatures"
|
||||
>Features</a
|
||||
|
@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
.mdc-button {
|
||||
height: unset;
|
||||
height: 100%;
|
||||
|
||||
&:not(.mat-primary) {
|
||||
background-color: transparent;
|
||||
|
@ -2,11 +2,14 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
OnChanges,
|
||||
Output
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatMenuTrigger } from '@angular/material/menu';
|
||||
import { Router } from '@angular/router';
|
||||
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';
|
||||
@ -18,6 +21,7 @@ import {
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
||||
styleUrls: ['./header.component.scss']
|
||||
})
|
||||
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() deviceType: string;
|
||||
@Input() hasTabs: boolean;
|
||||
@Input() info: InfoItem;
|
||||
@Input() pageTitle: string;
|
||||
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
|
||||
|
||||
@Output() signOut = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('assistant') assistantElement: AssistantComponent;
|
||||
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
|
||||
|
||||
public hasPermissionForSocialLogin: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToAccessAdminControl: boolean;
|
||||
public hasPermissionToAccessAssistant: boolean;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public hasPermissionToCreateUser: boolean;
|
||||
public impersonationId: string;
|
||||
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
|
||||
permissions.accessAdminControl
|
||||
);
|
||||
|
||||
this.hasPermissionToAccessAssistant = hasPermission(
|
||||
this.user?.permissions,
|
||||
permissions.accessAssistant
|
||||
);
|
||||
|
||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableFearAndGreedIndex
|
||||
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
|
||||
);
|
||||
}
|
||||
|
||||
public closeAssistant() {
|
||||
this.assistentMenuTriggerElement?.closeMenu();
|
||||
}
|
||||
|
||||
public impersonateAccount(aId: string) {
|
||||
if (aId) {
|
||||
this.impersonationStorageService.setId(aId);
|
||||
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
|
||||
this.isMenuOpen = true;
|
||||
}
|
||||
|
||||
public onOpenAssistant() {
|
||||
this.assistantElement.initialize();
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
this.signOut.next();
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
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 { HeaderComponent } from './header.component';
|
||||
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
|
||||
exports: [HeaderComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfAssistantModule,
|
||||
GfLogoModule,
|
||||
LoginWithAccessTokenDialogModule,
|
||||
MatButtonModule,
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="container">
|
||||
<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="mb-2 text-center text-muted">
|
||||
<small i18n>Last {{ numberOfDays }} Days</small>
|
||||
@ -8,15 +8,15 @@
|
||||
<gf-line-chart
|
||||
class="mb-3"
|
||||
symbol="Fear & Greed Index"
|
||||
yMax="100"
|
||||
yMin="0"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
[yMax]="100"
|
||||
[yMaxLabel]="greedLabel"
|
||||
[yMin]="0"
|
||||
[yMinLabel]="fearLabel"
|
||||
></gf-line-chart>
|
||||
<gf-fear-and-greed-index
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@ -8,6 +8,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
|
||||
@NgModule({
|
||||
declarations: [PortfolioPerformanceComponent],
|
||||
exports: [PortfolioPerformanceComponent],
|
||||
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule]
|
||||
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfPortfolioPerformanceModule {}
|
||||
|
@ -213,11 +213,11 @@
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="h5" i18n>Sectors</div>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[baseCurrency]="data.baseCurrency"
|
||||
[colorScheme]="data.colorScheme"
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[locale]="data.locale"
|
||||
[maxItems]="10"
|
||||
[positions]="sectors"
|
||||
></gf-portfolio-proportion-chart>
|
||||
@ -225,11 +225,11 @@
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="h5" i18n>Countries</div>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[baseCurrency]="data.baseCurrency"
|
||||
[colorScheme]="data.colorScheme"
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[locale]="data.locale"
|
||||
[maxItems]="10"
|
||||
[positions]="countries"
|
||||
></gf-portfolio-proportion-chart>
|
||||
|
@ -4,7 +4,9 @@ import {
|
||||
Inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
||||
@ -17,19 +19,36 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'create-or-update-access-dialog.html'
|
||||
})
|
||||
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||
public accessForm: FormGroup;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
|
||||
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() {
|
||||
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() {
|
||||
this.unsubscribeSubject.next();
|
||||
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>
|
||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Alias</mat-label>
|
||||
<input
|
||||
formControlName="alias"
|
||||
matInput
|
||||
name="alias"
|
||||
type="text"
|
||||
[(ngModel)]="data.access.alias"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<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-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[disabled]="!addAccessForm.form.valid"
|
||||
[mat-dialog-close]="data"
|
||||
type="submit"
|
||||
[disabled]="!accessForm.valid"
|
||||
>
|
||||
<ng-container i18n>Save</ng-container>
|
||||
</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 { MatButtonModule } from '@angular/material/button';
|
||||
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';
|
||||
|
||||
@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 {}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user