Compare commits
47 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 |
84
CHANGELOG.md
84
CHANGELOG.md
@ -5,12 +5,82 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 2.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
|
## 2.8.0 - 2023-10-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Supported enter key press to submit the form of the create or update account dialog
|
- Supported enter key press to submit the form of the create or update account dialog
|
||||||
- Added the version to the admin control panel
|
- Added the application version to the admin control panel
|
||||||
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
|
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@ -76,13 +146,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### 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
|
- Unlocked the experimental features setting for all users
|
||||||
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
|
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
|
||||||
|
|
||||||
### Fixed
|
### 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
|
## 2.3.0 - 2023-09-17
|
||||||
|
|
||||||
@ -233,7 +303,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### 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
|
- Added a timeout to the _EOD Historical Data_ requests
|
||||||
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
|
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
|
||||||
|
|
||||||
@ -740,7 +810,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Persisted today's market data continuously
|
- Persisted today’s market data continuously
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@ -974,7 +1044,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Filtered activities with type `ITEM` from search results
|
- 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 the _Stripe_ dependencies
|
||||||
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
||||||
|
|
||||||
@ -2648,7 +2718,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Moved the countries and sectors charts in the position detail dialog
|
- 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
|
- Restructured the server modules
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
@ -18,6 +18,12 @@
|
|||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
|
#### Access database via GUI
|
||||||
|
|
||||||
|
Run `yarn database:gui`
|
||||||
|
|
||||||
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `yarn database:push`
|
Run `yarn database:push`
|
||||||
|
@ -8,4 +8,8 @@ export class CreateAccessDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
type?: 'PUBLIC';
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
@ -29,11 +33,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { CreateAccountDto } from './create-account.dto';
|
import { CreateAccountDto } from './create-account.dto';
|
||||||
|
import { TransferBalanceDto } from './transfer-balance.dto';
|
||||||
import { UpdateAccountDto } from './update-account.dto';
|
import { UpdateAccountDto } from './update-account.dto';
|
||||||
|
|
||||||
@Controller('account')
|
@Controller('account')
|
||||||
export class AccountController {
|
export class AccountController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -115,6 +121,18 @@ export class AccountController {
|
|||||||
return accountsWithAggregations.accounts[0];
|
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()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async createAccount(
|
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')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
|
@ -109,7 +109,7 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string) {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
const accounts = await this.accounts({
|
const accounts = await this.accounts({
|
||||||
include: { Order: true, Platform: true },
|
include: { Order: true, Platform: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
@ -218,13 +218,13 @@ export class AccountService {
|
|||||||
accountId,
|
accountId,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
date,
|
date = new Date(),
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
date: Date;
|
date?: Date;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const { balance, currency: currencyOfAccount } = await this.account({
|
const { balance, currency: currencyOfAccount } = await this.account({
|
||||||
|
12
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;
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
@ -12,8 +12,7 @@ import {
|
|||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile
|
||||||
Filter
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
@ -43,12 +42,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||||
|
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
@ -254,6 +255,7 @@ export class AdminController {
|
|||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
@Query('presetId') presetId?: MarketDataPreset,
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
@Query('sortColumn') sortColumn?: string,
|
@Query('sortColumn') sortColumn?: string,
|
||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@ -271,16 +273,10 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAssetSubClasses,
|
||||||
const filters: Filter[] = [
|
filterBySearchQuery
|
||||||
...assetSubClasses.map((assetSubClass) => {
|
});
|
||||||
return <Filter>{
|
|
||||||
id: assetSubClass,
|
|
||||||
type: 'ASSET_SUB_CLASS'
|
|
||||||
};
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
return this.adminService.getMarketData({
|
return this.adminService.getMarketData({
|
||||||
filters,
|
filters,
|
||||||
@ -313,6 +309,43 @@ export class AdminController {
|
|||||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('market-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updateMarketData(
|
||||||
|
@Body() data: UpdateBulkMarketDataDto,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||||
|
({ date, marketPrice }) => ({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
marketPrice,
|
||||||
|
symbol,
|
||||||
|
state: 'CLOSE'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.marketDataService.updateMany({
|
||||||
|
data: dataBulkUpdate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(
|
public async update(
|
||||||
@ -365,8 +398,11 @@ export class AdminController {
|
|||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return this.adminService.addAssetProfile({
|
||||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
currency: this.request.user.Settings.settings.baseCurrency
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -40,10 +41,19 @@ export class AdminService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async addAssetProfile({
|
public async addAssetProfile({
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<SymbolProfile | never> {
|
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
||||||
try {
|
try {
|
||||||
|
if (dataSource === 'MANUAL') {
|
||||||
|
return this.symbolProfileService.add({
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
{ dataSource, symbol }
|
{ dataSource, symbol }
|
||||||
]);
|
]);
|
||||||
@ -97,7 +107,8 @@ export class AdminService {
|
|||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
users: await this.getUsersWithAnalytics()
|
users: await this.getUsersWithAnalytics(),
|
||||||
|
version: environment.version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,10 +140,14 @@ export class AdminService {
|
|||||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchQuery = filters.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
filters,
|
filters,
|
||||||
(filter) => {
|
({ type }) => {
|
||||||
return filter.type;
|
return type;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -145,6 +160,14 @@ export class AdminService {
|
|||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
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) {
|
if (sortColumn) {
|
||||||
orderBy = [{ [sortColumn]: sortDirection }];
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
@ -171,7 +194,9 @@ export class AdminService {
|
|||||||
assetSubClass: true,
|
assetSubClass: true,
|
||||||
comment: true,
|
comment: true,
|
||||||
countries: true,
|
countries: true,
|
||||||
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
select: { date: true },
|
select: { date: true },
|
||||||
@ -192,7 +217,9 @@ export class AdminService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
countries,
|
countries,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
Order,
|
Order,
|
||||||
sectors,
|
sectors,
|
||||||
symbol
|
symbol
|
||||||
@ -211,8 +238,10 @@ export class AdminService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
countriesCount,
|
countriesCount,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
symbol,
|
symbol,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
@ -339,6 +368,8 @@ export class AdminService {
|
|||||||
symbol,
|
symbol,
|
||||||
assetClass: 'CASH',
|
assetClass: 'CASH',
|
||||||
countriesCount: 0,
|
countriesCount: 0,
|
||||||
|
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||||
|
name: symbol,
|
||||||
sectorsCount: 0
|
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 {
|
export class UpdateMarketDataDto {
|
||||||
|
@IsDate()
|
||||||
|
@IsOptional()
|
||||||
|
date?: Date;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,7 @@ export class WebAuthService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateRegistrationOptions(opts);
|
const options = await generateRegistrationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -88,10 +88,16 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedRegistrationResponse;
|
let verification: VerifiedRegistrationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyRegistrationResponseOpts = {
|
const opts: VerifyRegistrationResponseOpts = {
|
||||||
credential,
|
|
||||||
expectedChallenge,
|
expectedChallenge,
|
||||||
expectedOrigin: this.expectedOrigin,
|
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);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -117,8 +123,8 @@ export class WebAuthService {
|
|||||||
*/
|
*/
|
||||||
existingDevice = await this.deviceService.createAuthDevice({
|
existingDevice = await this.deviceService.createAuthDevice({
|
||||||
counter,
|
counter,
|
||||||
credentialPublicKey,
|
credentialId: Buffer.from(credentialID),
|
||||||
credentialId: credentialID,
|
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -152,7 +158,7 @@ export class WebAuthService {
|
|||||||
userVerification: 'preferred'
|
userVerification: 'preferred'
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateAuthenticationOptions(opts);
|
const options = await generateAuthenticationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -181,7 +187,6 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedAuthenticationResponse;
|
let verification: VerifiedAuthenticationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAuthenticationResponseOpts = {
|
const opts: VerifyAuthenticationResponseOpts = {
|
||||||
credential,
|
|
||||||
authenticator: {
|
authenticator: {
|
||||||
credentialID: device.credentialId,
|
credentialID: device.credentialId,
|
||||||
credentialPublicKey: device.credentialPublicKey,
|
credentialPublicKey: device.credentialPublicKey,
|
||||||
@ -189,9 +194,16 @@ export class WebAuthService {
|
|||||||
},
|
},
|
||||||
expectedChallenge: `${user.authChallenge}`,
|
expectedChallenge: `${user.authChallenge}`,
|
||||||
expectedOrigin: this.expectedOrigin,
|
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) {
|
} catch (error) {
|
||||||
Logger.error(error, 'WebAuthService');
|
Logger.error(error, 'WebAuthService');
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
|
@ -64,7 +64,7 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
@ -85,15 +85,14 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
let performancePercentFromAllTimeHigh = 0;
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
if (allTimeHigh && marketPrice) {
|
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
allTimeHigh,
|
allTimeHigh.marketPrice,
|
||||||
marketPrice
|
marketPrice
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
storeInCache = false;
|
storeInCache = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
performancePercentFromAllTimeHigh
|
performancePercentFromAllTimeHigh
|
||||||
@ -101,6 +100,7 @@ export class BenchmarkService {
|
|||||||
name: benchmarkAssetProfiles[index].name,
|
name: benchmarkAssetProfiles[index].name,
|
||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
|
date: allTimeHigh.date,
|
||||||
performancePercent: performancePercentFromAllTimeHigh
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,12 +55,8 @@ export class InfoService {
|
|||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = (
|
const platforms = await this.platformService.getPlatforms({
|
||||||
await this.platformService.getPlatforms({
|
|
||||||
orderBy: { name: 'asc' }
|
orderBy: { name: 'asc' }
|
||||||
})
|
|
||||||
).map(({ id, name }) => {
|
|
||||||
return { id, name };
|
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
let systemMessage: string;
|
||||||
|
|
||||||
|
@ -391,12 +391,14 @@ export class PortfolioController {
|
|||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
|
filterBySearchQuery,
|
||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1014,6 +1014,9 @@ export class PortfolioService {
|
|||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
|
const searchQuery = filters.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
@ -1042,9 +1045,9 @@ export class PortfolioService {
|
|||||||
const currentPositions =
|
const currentPositions =
|
||||||
await portfolioCalculator.getCurrentPositions(startDate);
|
await portfolioCalculator.getCurrentPositions(startDate);
|
||||||
|
|
||||||
const positions = currentPositions.positions.filter(
|
let positions = currentPositions.positions.filter(({ quantity }) => {
|
||||||
(item) => !item.quantity.eq(0)
|
return !quantity.eq(0);
|
||||||
);
|
});
|
||||||
|
|
||||||
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
@ -1067,12 +1070,25 @@ export class PortfolioService {
|
|||||||
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
|
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
positions = positions.filter(({ symbol }) => {
|
||||||
|
const enhancedSymbolProfile = symbolProfileMap[symbol];
|
||||||
|
|
||||||
|
return (
|
||||||
|
enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) ||
|
||||||
|
enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) ||
|
||||||
|
enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasErrors: currentPositions.hasErrors,
|
hasErrors: currentPositions.hasErrors,
|
||||||
positions: positions.map((position) => {
|
positions: positions.map((position) => {
|
||||||
return {
|
return {
|
||||||
...position,
|
...position,
|
||||||
assetClass: symbolProfileMap[position.symbol].assetClass,
|
assetClass: symbolProfileMap[position.symbol].assetClass,
|
||||||
|
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
|
||||||
averagePrice: new Big(position.averagePrice).toNumber(),
|
averagePrice: new Big(position.averagePrice).toNumber(),
|
||||||
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
||||||
grossPerformancePercentage:
|
grossPerformancePercentage:
|
||||||
|
@ -163,6 +163,13 @@ export class UserService {
|
|||||||
|
|
||||||
let currentPermissions = getPermissions(user.role);
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
|
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||||
|
currentPermissions = without(
|
||||||
|
currentPermissions,
|
||||||
|
permissions.accessAssistant
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash';
|
|||||||
|
|
||||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||||
for (const key in aObject) {
|
for (const key in aObject) {
|
||||||
if (aObject[key] === null || aObject[key] === null) {
|
if (aObject[key] === null || aObject[key] === undefined) {
|
||||||
return true;
|
return true;
|
||||||
} else if (isObject(aObject[key])) {
|
} else if (isObject(aObject[key])) {
|
||||||
return hasNotDefinedValuesInObject(aObject[key]);
|
return hasNotDefinedValuesInObject(aObject[key]);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AccountBalance, Prisma } from '@prisma/client';
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@ -13,4 +14,29 @@ export class AccountBalanceService {
|
|||||||
data
|
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({
|
public buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
|
filterByAssetSubClasses,
|
||||||
|
filterBySearchQuery,
|
||||||
filterByTags
|
filterByTags
|
||||||
}: {
|
}: {
|
||||||
filterByAccounts?: string;
|
filterByAccounts?: string;
|
||||||
filterByAssetClasses?: string;
|
filterByAssetClasses?: string;
|
||||||
|
filterByAssetSubClasses?: string;
|
||||||
|
filterBySearchQuery?: string;
|
||||||
filterByTags?: string;
|
filterByTags?: string;
|
||||||
}): Filter[] {
|
}): Filter[] {
|
||||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||||
|
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||||
|
const searchQuery = filterBySearchQuery?.toLowerCase();
|
||||||
const tagIds = filterByTags?.split(',') ?? [];
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -31,6 +37,16 @@ export class ApiService {
|
|||||||
type: 'ASSET_CLASS'
|
type: 'ASSET_CLASS'
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
...assetSubClasses.map((assetClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetClass,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
id: searchQuery,
|
||||||
|
type: 'SEARCH_QUERY'
|
||||||
|
},
|
||||||
...tagIds.map((tagId) => {
|
...tagIds.map((tagId) => {
|
||||||
return <Filter>{
|
return <Filter>{
|
||||||
id: tagId,
|
id: tagId,
|
||||||
|
@ -283,7 +283,6 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
if (symbol.endsWith('.FOREX')) {
|
if (symbol.endsWith('.FOREX')) {
|
||||||
symbol = symbol.replace('GBX', 'GBp');
|
symbol = symbol.replace('GBX', 'GBp');
|
||||||
symbol = symbol.replace('.FOREX', '');
|
symbol = symbol.replace('.FOREX', '');
|
||||||
symbol = `${DEFAULT_CURRENCY}${symbol}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return symbol;
|
return symbol;
|
||||||
@ -292,7 +291,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
/**
|
/**
|
||||||
* Converts a symbol to a EOD symbol
|
* Converts a symbol to a EOD symbol
|
||||||
*
|
*
|
||||||
* Currency: USDCHF -> CHF.FOREX
|
* Currency: USDCHF -> USDCHF.FOREX
|
||||||
*/
|
*/
|
||||||
private convertToEodSymbol(aSymbol: string) {
|
private convertToEodSymbol(aSymbol: string) {
|
||||||
if (
|
if (
|
||||||
@ -304,9 +303,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
|
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return `${aSymbol
|
let symbol = aSymbol;
|
||||||
.replace('GBp', 'GBX')
|
symbol = symbol.replace('GBp', 'GBX');
|
||||||
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
|
|
||||||
|
return `${symbol}.FOREX`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,18 +39,22 @@ export class MarketDataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
|
public async getMax({ dataSource, symbol }: UniqueAsset) {
|
||||||
const aggregations = await this.prismaService.marketData.aggregate({
|
return this.prismaService.marketData.findFirst({
|
||||||
_max: {
|
select: {
|
||||||
|
date: true,
|
||||||
marketPrice: true
|
marketPrice: true
|
||||||
},
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
marketPrice: 'desc'
|
||||||
|
}
|
||||||
|
],
|
||||||
where: {
|
where: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return aggregations._max.marketPrice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRange({
|
public async getRange({
|
||||||
|
@ -52,20 +52,12 @@ export class SymbolProfileService {
|
|||||||
SymbolProfileOverrides: true
|
SymbolProfileOverrides: true
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
{
|
return {
|
||||||
dataSource: {
|
dataSource,
|
||||||
in: aUniqueAssets.map(({ dataSource }) => {
|
symbol
|
||||||
return dataSource;
|
};
|
||||||
})
|
})
|
||||||
},
|
|
||||||
symbol: {
|
|
||||||
in: aUniqueAssets.map(({ symbol }) => {
|
|
||||||
return symbol;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||||
|
@ -124,6 +124,9 @@
|
|||||||
{
|
{
|
||||||
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
||||||
},
|
},
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
<gf-header
|
<gf-header
|
||||||
class="position-fixed w-100"
|
class="position-fixed w-100"
|
||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
|
[deviceType]="deviceType"
|
||||||
[hasTabs]="hasTabs"
|
[hasTabs]="hasTabs"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
[pageTitle]="pageTitle"
|
[pageTitle]="pageTitle"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Platform } from '@angular/cdk/platform';
|
import { Platform } from '@angular/cdk/platform';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import {
|
import {
|
||||||
@ -35,6 +35,7 @@ export function NgxStripeFactory(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
bootstrap: [AppComponent],
|
||||||
declarations: [AppComponent],
|
declarations: [AppComponent],
|
||||||
imports: [
|
imports: [
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
@ -72,6 +73,6 @@ export function NgxStripeFactory(): string {
|
|||||||
useFactory: NgxStripeFactory
|
useFactory: NgxStripeFactory
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
bootstrap: [AppComponent]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
@ -1,15 +1,3 @@
|
|||||||
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
|
|
||||||
<a
|
|
||||||
color="primary"
|
|
||||||
i18n
|
|
||||||
mat-flat-button
|
|
||||||
[queryParams]="{ createDialog: true }"
|
|
||||||
[routerLink]="[]"
|
|
||||||
>
|
|
||||||
Add Access
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="alias">
|
<ng-container matColumnDef="alias">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
|
||||||
|
@ -19,7 +19,6 @@ import { Access } from '@ghostfolio/common/interfaces';
|
|||||||
})
|
})
|
||||||
export class AccessTableComponent implements OnChanges, OnInit {
|
export class AccessTableComponent implements OnChanges, OnInit {
|
||||||
@Input() accesses: Access[];
|
@Input() accesses: Access[];
|
||||||
@Input() hasPermissionToCreateAccess = false;
|
|
||||||
@Input() showActions: boolean;
|
@Input() showActions: boolean;
|
||||||
|
|
||||||
@Output() accessDeleted = new EventEmitter<string>();
|
@Output() accessDeleted = new EventEmitter<string>();
|
||||||
|
@ -1,3 +1,14 @@
|
|||||||
|
<div *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">
|
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th
|
<th
|
||||||
|
@ -34,6 +34,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
@Output() accountDeleted = new EventEmitter<string>();
|
@Output() accountDeleted = new EventEmitter<string>();
|
||||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||||
|
@Output() transferBalance = new EventEmitter<void>();
|
||||||
|
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
@ -97,6 +98,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
alert(aComment);
|
alert(aComment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onTransferBalance() {
|
||||||
|
this.transferBalance.emit();
|
||||||
|
}
|
||||||
|
|
||||||
public onUpdateAccount(aAccount: AccountModel) {
|
public onUpdateAccount(aAccount: AccountModel) {
|
||||||
this.accountToUpdate.emit(aAccount);
|
this.accountToUpdate.emit(aAccount);
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
||||||
@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
export class AdminJobsComponent implements OnDestroy, OnInit {
|
export class AdminJobsComponent implements OnDestroy, OnInit {
|
||||||
public defaultDateTimeFormat: string;
|
public defaultDateTimeFormat: string;
|
||||||
public filterForm: FormGroup;
|
public filterForm: FormGroup;
|
||||||
public jobs: AdminJobs['jobs'] = [];
|
public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
|
||||||
|
new MatTableDataSource();
|
||||||
|
public displayedColumns = [
|
||||||
|
'index',
|
||||||
|
'type',
|
||||||
|
'symbol',
|
||||||
|
'dataSource',
|
||||||
|
'attempts',
|
||||||
|
'created',
|
||||||
|
'finished',
|
||||||
|
'status',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
|
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
|
|||||||
.fetchJobs({ status: aStatus })
|
.fetchJobs({ status: aStatus })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ jobs }) => {
|
.subscribe(({ jobs }) => {
|
||||||
this.jobs = jobs;
|
this.dataSource = new MatTableDataSource(jobs);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -13,18 +13,115 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
<table class="gf-table w-100">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<thead>
|
<ng-container matColumnDef="index">
|
||||||
<tr class="mat-header-row">
|
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
#
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
{{ element.id }}
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
|
</td>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
</ng-container>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
<ng-container matColumnDef="type">
|
||||||
<th class="mat-header-cell px-1 py-2">
|
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||||
|
<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
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
@ -39,69 +136,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
|
||||||
</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
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
@ -111,24 +146,25 @@
|
|||||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||||
<button mat-menu-item (click)="onViewData(job.data)">
|
<button mat-menu-item (click)="onViewData(element.data)">
|
||||||
<ng-container i18n>View Data</ng-container>
|
<ng-container i18n>View Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="job.stacktrace?.length <= 0"
|
[disabled]="element.stacktrace?.length <= 0"
|
||||||
(click)="onViewStacktrace(job.stacktrace)"
|
(click)="onViewStacktrace(element.stacktrace)"
|
||||||
>
|
>
|
||||||
<ng-container i18n>View Stacktrace</ng-container>
|
<ng-container i18n>View Stacktrace</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item (click)="onDeleteJob(job.id)">
|
<button mat-menu-item (click)="onDeleteJob(element.id)">
|
||||||
<ng-container i18n>Delete Job</ng-container>
|
<ng-container i18n>Delete Job</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</tbody>
|
|
||||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
|
||||||
import { AdminJobsComponent } from './admin-jobs.component';
|
import { AdminJobsComponent } from './admin-jobs.component';
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
|
MatTableModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -177,7 +177,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
dialogRef
|
dialogRef
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ withRefresh }) => {
|
.subscribe(({ withRefresh } = { withRefresh: false }) => {
|
||||||
this.marketDataChanged.next(withRefresh);
|
this.marketDataChanged.next(withRefresh);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -178,10 +178,20 @@ export class AdminMarketDataComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this asset profile?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.deleteProfileData({ dataSource, symbol })
|
.deleteProfileData({ dataSource, symbol })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {});
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGather7Days() {
|
public onGather7Days() {
|
||||||
@ -342,7 +352,7 @@ export class AdminMarketDataComponent
|
|||||||
dialogRef
|
dialogRef
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ dataSource, symbol }) => {
|
.subscribe(({ dataSource, symbol } = {}) => {
|
||||||
if (dataSource && symbol) {
|
if (dataSource && symbol) {
|
||||||
this.adminService
|
this.adminService
|
||||||
.addAssetProfile({ dataSource, symbol })
|
.addAssetProfile({ dataSource, symbol })
|
||||||
|
@ -2,11 +2,4 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
.fab-container {
|
|
||||||
bottom: 2rem;
|
|
||||||
position: fixed;
|
|
||||||
right: 2rem;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -11,13 +11,15 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
|||||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
ScraperConfiguration,
|
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
import { translate } from '@ghostfolio/ui/i18n';
|
||||||
import { MarketData, SymbolProfile } from '@prisma/client';
|
import { MarketData, SymbolProfile } from '@prisma/client';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { parse as csvToJson } from 'papaparse';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -43,12 +45,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
public countries: {
|
public countries: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public historicalDataAsCsvString: string;
|
||||||
public isBenchmark = false;
|
public isBenchmark = false;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public marketDataDetails: MarketData[] = [];
|
||||||
public sectors: {
|
public sectors: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
|
||||||
|
new Date(),
|
||||||
|
DATE_FORMAT
|
||||||
|
)};123.45`;
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -67,6 +74,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public initialize() {
|
public initialize() {
|
||||||
|
this.historicalDataAsCsvString =
|
||||||
|
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
|
||||||
|
|
||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminMarketDataBySymbol({
|
.fetchAdminMarketDataBySymbol({
|
||||||
dataSource: this.data.dataSource,
|
dataSource: this.data.dataSource,
|
||||||
@ -135,6 +145,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onImportHistoricalData() {
|
||||||
|
const marketData = csvToJson(this.historicalDataAsCsvString, {
|
||||||
|
dynamicTyping: true,
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true
|
||||||
|
}).data;
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.postMarketData({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
marketData: {
|
||||||
|
marketData: marketData.map(({ date, marketPrice }) => {
|
||||||
|
return { marketPrice, date: parseISO(date) };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.initialize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
if (withRefresh) {
|
if (withRefresh) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
@ -51,6 +51,36 @@
|
|||||||
[symbol]="data.symbol"
|
[symbol]="data.symbol"
|
||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
></gf-admin-market-data-detail>
|
></gf-admin-market-data-detail>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||||
|
<mat-label>
|
||||||
|
<ng-container i18n>Historical Data</ng-container> (CSV)
|
||||||
|
</mat-label>
|
||||||
|
<textarea
|
||||||
|
cdkAutosizeMaxRows="5"
|
||||||
|
cdkTextareaAutosize
|
||||||
|
matInput
|
||||||
|
placeholder="e.g. 20230601;1.61"
|
||||||
|
type="text"
|
||||||
|
[ngModelOptions]="{standalone: true}"
|
||||||
|
[(ngModel)]="historicalDataAsCsvString"
|
||||||
|
(keyup.enter)="$event.stopPropagation()"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-2">
|
||||||
|
<button
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
(click)="onImportHistoricalData()"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Import</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 mb-3">
|
<div class="col-6 mb-3">
|
||||||
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
Component,
|
||||||
Inject,
|
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
|
AbstractControl,
|
||||||
FormBuilder,
|
FormBuilder,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
|
ValidationErrors,
|
||||||
Validators
|
Validators
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { MatDialogRef } from '@angular/material/dialog';
|
import { MatDialogRef } from '@angular/material/dialog';
|
||||||
@ -19,35 +19,75 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
host: { class: 'h-100' },
|
host: { class: 'h-100' },
|
||||||
selector: 'gf-create-asset-profile-dialog',
|
selector: 'gf-create-asset-profile-dialog',
|
||||||
|
styleUrls: ['./create-asset-profile-dialog.component.scss'],
|
||||||
templateUrl: 'create-asset-profile-dialog.html'
|
templateUrl: 'create-asset-profile-dialog.html'
|
||||||
})
|
})
|
||||||
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||||
public createAssetProfileForm: FormGroup;
|
public createAssetProfileForm: FormGroup;
|
||||||
|
public mode: 'auto' | 'manual';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly adminService: AdminService,
|
public readonly adminService: AdminService,
|
||||||
public readonly changeDetectorRef: ChangeDetectorRef,
|
|
||||||
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
||||||
public readonly formBuilder: FormBuilder
|
public readonly formBuilder: FormBuilder
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.createAssetProfileForm = this.formBuilder.group({
|
this.createAssetProfileForm = this.formBuilder.group(
|
||||||
|
{
|
||||||
|
addSymbol: new FormControl(null, [Validators.required]),
|
||||||
searchSymbol: new FormControl(null, [Validators.required])
|
searchSymbol: new FormControl(null, [Validators.required])
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
validators: this.atLeastOneValid
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.mode = 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCancel() {
|
public onCancel() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onRadioChange(mode: 'auto' | 'manual') {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
public onSubmit() {
|
public onSubmit() {
|
||||||
this.dialogRef.close({
|
this.mode === 'auto'
|
||||||
|
? this.dialogRef.close({
|
||||||
dataSource:
|
dataSource:
|
||||||
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
|
this.createAssetProfileForm.controls['searchSymbol'].value
|
||||||
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
.dataSource,
|
||||||
|
symbol:
|
||||||
|
this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||||
|
})
|
||||||
|
: this.dialogRef.close({
|
||||||
|
dataSource: 'MANUAL',
|
||||||
|
symbol: this.createAssetProfileForm.controls['addSymbol'].value
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {}
|
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,6 +6,21 @@
|
|||||||
>
|
>
|
||||||
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
|
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
|
||||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
|
<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-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||||
<gf-symbol-autocomplete
|
<gf-symbol-autocomplete
|
||||||
@ -14,13 +29,20 @@
|
|||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</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>
|
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
type="submit"
|
type="submit"
|
||||||
[disabled]="!createAssetProfileForm.valid"
|
[disabled]="createAssetProfileForm.hasError('atLeastOneValid')"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Save</ng-container>
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
@ -4,6 +4,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatRadioModule } from '@angular/material/radio';
|
||||||
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
||||||
|
|
||||||
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
||||||
@ -17,6 +19,8 @@ import { CreateAssetProfileDialog } from './create-asset-profile-dialog.componen
|
|||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatRadioModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -43,7 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public userCount: number;
|
public userCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
public version = environment.version;
|
public version: string;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -204,15 +204,18 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
this.adminService
|
this.adminService
|
||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
.subscribe(
|
||||||
|
({ exchangeRates, settings, transactionCount, userCount, version }) => {
|
||||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||||
this.exchangeRates = exchangeRates;
|
this.exchangeRates = exchangeRates;
|
||||||
this.transactionCount = transactionCount;
|
this.transactionCount = transactionCount;
|
||||||
this.userCount = userCount;
|
this.userCount = userCount;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCouponCode(aLength: number) {
|
private generateCouponCode(aLength: number) {
|
||||||
|
@ -5,14 +5,16 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Version</div>
|
<div class="w-50" i18n>Version</div>
|
||||||
<div class="w-50">{{ version }}</div>
|
<div class="w-50">
|
||||||
|
<gf-value [value]="version" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>User Count</div>
|
<div class="w-50" i18n>User Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<gf-value
|
<gf-value
|
||||||
precision="0"
|
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="0"
|
||||||
[value]="userCount"
|
[value]="userCount"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
@ -21,8 +23,8 @@
|
|||||||
<div class="w-50" i18n>Activity Count</div>
|
<div class="w-50" i18n>Activity Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<gf-value
|
<gf-value
|
||||||
precision="0"
|
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="0"
|
||||||
[value]="transactionCount"
|
[value]="transactionCount"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
<div *ngIf="transactionCount && userCount">
|
<div *ngIf="transactionCount && userCount">
|
||||||
|
@ -110,6 +110,34 @@
|
|||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li *ngIf="hasPermissionToAccessAssistant" class="list-inline-item">
|
||||||
|
<button
|
||||||
|
#assistantTrigger="matMenuTrigger"
|
||||||
|
class="h-100 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[mat-menu-trigger-for]="assistantMenu"
|
||||||
|
[matMenuTriggerRestoreFocus]="false"
|
||||||
|
(menuOpened)="onOpenAssistant()"
|
||||||
|
>
|
||||||
|
<ion-icon name="search-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu
|
||||||
|
#assistantMenu="matMenu"
|
||||||
|
class="assistant"
|
||||||
|
xPosition="before"
|
||||||
|
[overlapTrigger]="true"
|
||||||
|
(closed)="assistantElement?.setIsOpen(false)"
|
||||||
|
>
|
||||||
|
<gf-assistant
|
||||||
|
#assistant
|
||||||
|
[deviceType]="deviceType"
|
||||||
|
[hasPermissionToAccessAdminControl]="
|
||||||
|
hasPermissionToAccessAdminControl
|
||||||
|
"
|
||||||
|
(closed)="closeAssistant()"
|
||||||
|
/>
|
||||||
|
</mat-menu>
|
||||||
|
</li>
|
||||||
<li class="list-inline-item">
|
<li class="list-inline-item">
|
||||||
<button
|
<button
|
||||||
class="no-min-width px-1"
|
class="no-min-width px-1"
|
||||||
@ -272,7 +300,7 @@
|
|||||||
mat-flat-button
|
mat-flat-button
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'font-weight-bold': currentRoute === routeFeatures,
|
'font-weight-bold': currentRoute === routeFeatures,
|
||||||
'text-decoration-underline': currentRoute === routeFeatuers
|
'text-decoration-underline': currentRoute === routeFeatures
|
||||||
}"
|
}"
|
||||||
[routerLink]="routerLinkFeatures"
|
[routerLink]="routerLinkFeatures"
|
||||||
>Features</a
|
>Features</a
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mdc-button {
|
.mdc-button {
|
||||||
height: unset;
|
height: 100%;
|
||||||
|
|
||||||
&:not(.mat-primary) {
|
&:not(.mat-primary) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
@ -2,11 +2,14 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
Output
|
Output,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatMenuTrigger } from '@angular/material/menu';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -18,6 +21,7 @@ import {
|
|||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
|||||||
styleUrls: ['./header.component.scss']
|
styleUrls: ['./header.component.scss']
|
||||||
})
|
})
|
||||||
export class HeaderComponent implements OnChanges {
|
export class HeaderComponent implements OnChanges {
|
||||||
|
@HostListener('window:keydown', ['$event'])
|
||||||
|
openAssistantWithHotKey(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key === '/' &&
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target?.nodeName?.toLowerCase() !== 'input' &&
|
||||||
|
event.target?.nodeName?.toLowerCase() !== 'textarea' &&
|
||||||
|
this.hasPermissionToAccessAssistant
|
||||||
|
) {
|
||||||
|
this.assistantElement.setIsOpen(true);
|
||||||
|
this.assistentMenuTriggerElement.openMenu();
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Input() currentRoute: string;
|
@Input() currentRoute: string;
|
||||||
|
@Input() deviceType: string;
|
||||||
@Input() hasTabs: boolean;
|
@Input() hasTabs: boolean;
|
||||||
@Input() info: InfoItem;
|
@Input() info: InfoItem;
|
||||||
@Input() pageTitle: string;
|
@Input() pageTitle: string;
|
||||||
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
|
|||||||
|
|
||||||
@Output() signOut = new EventEmitter<void>();
|
@Output() signOut = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('assistant') assistantElement: AssistantComponent;
|
||||||
|
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
|
||||||
|
|
||||||
public hasPermissionForSocialLogin: boolean;
|
public hasPermissionForSocialLogin: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessAdminControl: boolean;
|
public hasPermissionToAccessAdminControl: boolean;
|
||||||
|
public hasPermissionToAccessAssistant: boolean;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public hasPermissionToCreateUser: boolean;
|
public hasPermissionToCreateUser: boolean;
|
||||||
public impersonationId: string;
|
public impersonationId: string;
|
||||||
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
|
|||||||
permissions.accessAdminControl
|
permissions.accessAdminControl
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToAccessAssistant = hasPermission(
|
||||||
|
this.user?.permissions,
|
||||||
|
permissions.accessAssistant
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
this.info?.globalPermissions,
|
this.info?.globalPermissions,
|
||||||
permissions.enableFearAndGreedIndex
|
permissions.enableFearAndGreedIndex
|
||||||
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public closeAssistant() {
|
||||||
|
this.assistentMenuTriggerElement?.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
public impersonateAccount(aId: string) {
|
public impersonateAccount(aId: string) {
|
||||||
if (aId) {
|
if (aId) {
|
||||||
this.impersonationStorageService.setId(aId);
|
this.impersonationStorageService.setId(aId);
|
||||||
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
this.isMenuOpen = true;
|
this.isMenuOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onOpenAssistant() {
|
||||||
|
this.assistantElement.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
this.signOut.next();
|
this.signOut.next();
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||||
|
import { GfAssistantModule } from '@ghostfolio/ui/assistant';
|
||||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||||
|
|
||||||
import { HeaderComponent } from './header.component';
|
import { HeaderComponent } from './header.component';
|
||||||
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
|
|||||||
exports: [HeaderComponent],
|
exports: [HeaderComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfAssistantModule,
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
LoginWithAccessTokenDialogModule,
|
LoginWithAccessTokenDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
|
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
|
||||||
<div class="mb-5 row">
|
<div *ngIf="hasPermissionToAccessFearAndGreedIndex" class="mb-5 row">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<div class="mb-2 text-center text-muted">
|
<div class="mb-2 text-center text-muted">
|
||||||
<small i18n>Last {{ numberOfDays }} Days</small>
|
<small i18n>Last {{ numberOfDays }} Days</small>
|
||||||
@ -8,15 +8,15 @@
|
|||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
symbol="Fear & Greed Index"
|
symbol="Fear & Greed Index"
|
||||||
yMax="100"
|
|
||||||
yMin="0"
|
|
||||||
[colorScheme]="user?.settings?.colorScheme"
|
[colorScheme]="user?.settings?.colorScheme"
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
[isAnimated]="true"
|
[isAnimated]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
|
[yMax]="100"
|
||||||
[yMaxLabel]="greedLabel"
|
[yMaxLabel]="greedLabel"
|
||||||
|
[yMin]="0"
|
||||||
[yMinLabel]="fearLabel"
|
[yMinLabel]="fearLabel"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<gf-fear-and-greed-index
|
<gf-fear-and-greed-index
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [PortfolioPerformanceComponent],
|
declarations: [PortfolioPerformanceComponent],
|
||||||
exports: [PortfolioPerformanceComponent],
|
exports: [PortfolioPerformanceComponent],
|
||||||
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule]
|
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfPortfolioPerformanceModule {}
|
export class GfPortfolioPerformanceModule {}
|
||||||
|
@ -213,11 +213,11 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="h5" i18n>Sectors</div>
|
<div class="h5" i18n>Sectors</div>
|
||||||
<gf-portfolio-proportion-chart
|
<gf-portfolio-proportion-chart
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="data.baseCurrency"
|
||||||
[colorScheme]="data.colorScheme"
|
[colorScheme]="data.colorScheme"
|
||||||
[isInPercent]="true"
|
[isInPercent]="true"
|
||||||
[keys]="['name']"
|
[keys]="['name']"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="data.locale"
|
||||||
[maxItems]="10"
|
[maxItems]="10"
|
||||||
[positions]="sectors"
|
[positions]="sectors"
|
||||||
></gf-portfolio-proportion-chart>
|
></gf-portfolio-proportion-chart>
|
||||||
@ -225,11 +225,11 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<div class="h5" i18n>Countries</div>
|
<div class="h5" i18n>Countries</div>
|
||||||
<gf-portfolio-proportion-chart
|
<gf-portfolio-proportion-chart
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="data.baseCurrency"
|
||||||
[colorScheme]="data.colorScheme"
|
[colorScheme]="data.colorScheme"
|
||||||
[isInPercent]="true"
|
[isInPercent]="true"
|
||||||
[keys]="['name']"
|
[keys]="['name']"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="data.locale"
|
||||||
[maxItems]="10"
|
[maxItems]="10"
|
||||||
[positions]="countries"
|
[positions]="countries"
|
||||||
></gf-portfolio-proportion-chart>
|
></gf-portfolio-proportion-chart>
|
||||||
|
@ -4,7 +4,9 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
||||||
@ -17,19 +19,36 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
|||||||
templateUrl: 'create-or-update-access-dialog.html'
|
templateUrl: 'create-or-update-access-dialog.html'
|
||||||
})
|
})
|
||||||
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||||
|
public accessForm: FormGroup;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
|
||||||
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
|
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
|
private formBuilder: FormBuilder
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {}
|
ngOnInit() {
|
||||||
|
this.accessForm = this.formBuilder.group({
|
||||||
|
alias: [this.data.access.alias],
|
||||||
|
type: [this.data.access.type, Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onCancel() {
|
public onCancel() {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
const access: CreateAccessDto = {
|
||||||
|
alias: this.accessForm.controls['alias'].value,
|
||||||
|
type: this.accessForm.controls['type'].value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dialogRef.close({ access });
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
|
@ -1,33 +1,38 @@
|
|||||||
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
|
<form
|
||||||
|
class="d-flex flex-column h-100"
|
||||||
|
[formGroup]="accessForm"
|
||||||
|
(keyup.enter)="accessForm.valid && onSubmit()"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
<h1 i18n mat-dialog-title>Grant access</h1>
|
<h1 i18n mat-dialog-title>Grant access</h1>
|
||||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Alias</mat-label>
|
<mat-label i18n>Alias</mat-label>
|
||||||
<input
|
<input
|
||||||
|
formControlName="alias"
|
||||||
matInput
|
matInput
|
||||||
name="alias"
|
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="data.access.alias"
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Type</mat-label>
|
<mat-label i18n>Type</mat-label>
|
||||||
<mat-select name="type" required [(value)]="data.access.type">
|
<mat-select formControlName="type">
|
||||||
<mat-option i18n value="PUBLIC">Public</mat-option>
|
<mat-option i18n value="PUBLIC">Public</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-content-end" mat-dialog-actions>
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!addAccessForm.form.valid"
|
type="submit"
|
||||||
[mat-dialog-close]="data"
|
[disabled]="!accessForm.valid"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Save</ng-container>
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
@ -10,8 +10,18 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<gf-access-table
|
<gf-access-table
|
||||||
[accesses]="accesses"
|
[accesses]="accesses"
|
||||||
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
|
|
||||||
[showActions]="hasPermissionToDeleteAccess"
|
[showActions]="hasPermissionToDeleteAccess"
|
||||||
(accessDeleted)="onDeleteAccess($event)"
|
(accessDeleted)="onDeleteAccess($event)"
|
||||||
></gf-access-table>
|
></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>
|
</div>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
||||||
@ -16,6 +17,7 @@ import { UserAccountAccessComponent } from './user-account-access.component';
|
|||||||
GfCreateOrUpdateAccessDialogModule,
|
GfCreateOrUpdateAccessDialogModule,
|
||||||
GfPortfolioAccessTableModule,
|
GfPortfolioAccessTableModule,
|
||||||
GfPremiumIndicatorModule,
|
GfPremiumIndicatorModule,
|
||||||
|
MatButtonModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
]
|
]
|
||||||
|
@ -3,10 +3,9 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit
|
||||||
ViewChild
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
|
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import {
|
import {
|
||||||
STAY_SIGNED_IN,
|
STAY_SIGNED_IN,
|
||||||
@ -29,14 +28,12 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './user-account-settings.html'
|
templateUrl: './user-account-settings.html'
|
||||||
})
|
})
|
||||||
export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||||
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
|
||||||
signInWithFingerprintElement: MatCheckbox;
|
|
||||||
|
|
||||||
public appearancePlaceholder = $localize`Auto`;
|
public appearancePlaceholder = $localize`Auto`;
|
||||||
public baseCurrency: string;
|
public baseCurrency: string;
|
||||||
public currencies: string[] = [];
|
public currencies: string[] = [];
|
||||||
public hasPermissionToUpdateViewMode: boolean;
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
|
public isWebAuthnEnabled: boolean;
|
||||||
public language = document.documentElement.lang;
|
public language = document.documentElement.lang;
|
||||||
public locales = [
|
public locales = [
|
||||||
'de',
|
'de',
|
||||||
@ -120,7 +117,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) {
|
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
|
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -158,7 +155,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onRestrictedViewChange(aEvent: MatCheckboxChange) {
|
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ isRestrictedView: aEvent.checked })
|
.putUserSetting({ isRestrictedView: aEvent.checked })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -176,7 +173,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) {
|
public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
|
||||||
if (aEvent.checked) {
|
if (aEvent.checked) {
|
||||||
this.registerDevice();
|
this.registerDevice();
|
||||||
} else {
|
} else {
|
||||||
@ -192,7 +189,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onViewModeChange(aEvent: MatCheckboxChange) {
|
public onViewModeChange(aEvent: MatSlideToggleChange) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
|
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
@ -250,9 +247,8 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private update() {
|
private update() {
|
||||||
if (this.signInWithFingerprintElement) {
|
this.isWebAuthnEnabled = this.webAuthnService.isEnabled() ?? false;
|
||||||
this.signInWithFingerprintElement.checked =
|
|
||||||
this.webAuthnService.isEnabled() ?? false;
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,12 +11,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<mat-checkbox
|
<mat-slide-toggle
|
||||||
color="primary"
|
color="primary"
|
||||||
|
hideIcon="true"
|
||||||
[checked]="user.settings.isRestrictedView"
|
[checked]="user.settings.isRestrictedView"
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
(change)="onRestrictedViewChange($event)"
|
(change)="onRestrictedViewChange($event)"
|
||||||
></mat-checkbox>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex mt-4 py-1">
|
<div class="d-flex mt-4 py-1">
|
||||||
@ -139,12 +140,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<mat-checkbox
|
<mat-slide-toggle
|
||||||
color="primary"
|
color="primary"
|
||||||
|
hideIcon="true"
|
||||||
[checked]="user.settings.viewMode === 'ZEN'"
|
[checked]="user.settings.viewMode === 'ZEN'"
|
||||||
[disabled]="!hasPermissionToUpdateViewMode"
|
[disabled]="!hasPermissionToUpdateViewMode"
|
||||||
(change)="onViewModeChange($event)"
|
(change)="onViewModeChange($event)"
|
||||||
></mat-checkbox>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="align-items-center d-flex mt-4 py-1">
|
<div class="align-items-center d-flex mt-4 py-1">
|
||||||
@ -153,12 +155,13 @@
|
|||||||
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div>
|
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<mat-checkbox
|
<mat-slide-toggle
|
||||||
#toggleSignInWithFingerprintEnabledElement
|
|
||||||
color="primary"
|
color="primary"
|
||||||
|
hideIcon="true"
|
||||||
|
[checked]="isWebAuthnEnabled === true"
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
(change)="onSignInWithFingerprintChange($event)"
|
(change)="onSignInWithFingerprintChange($event)"
|
||||||
></mat-checkbox>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -172,12 +175,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<mat-checkbox
|
<mat-slide-toggle
|
||||||
color="primary"
|
color="primary"
|
||||||
|
hideIcon="true"
|
||||||
[checked]="user.settings.isExperimentalFeatures"
|
[checked]="user.settings.isExperimentalFeatures"
|
||||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||||
(change)="onExperimentalFeaturesChange($event)"
|
(change)="onExperimentalFeaturesChange($event)"
|
||||||
></mat-checkbox>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="align-items-center d-flex mt-4 py-1">
|
<div class="align-items-center d-flex mt-4 py-1">
|
||||||
|
@ -3,9 +3,9 @@ import { NgModule } from '@angular/core';
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
@ -20,9 +20,9 @@ import { UserAccountSettingsComponent } from './user-account-settings.component'
|
|||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatCheckboxModule,
|
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
]
|
]
|
||||||
|
@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
|||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
|
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
|
||||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||||
import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
|
import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
|
||||||
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
|
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
|
||||||
@ -16,6 +17,7 @@ import { Subject, Subscription } from 'rxjs';
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
|
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
|
||||||
|
import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'page' },
|
host: { class: 'page' },
|
||||||
@ -28,7 +30,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToCreateAccount: boolean;
|
public hasPermissionToCreateAccount: boolean;
|
||||||
public hasPermissionToDeleteAccount: boolean;
|
public hasPermissionToUpdateAccount: boolean;
|
||||||
public routeQueryParams: Subscription;
|
public routeQueryParams: Subscription;
|
||||||
public totalBalanceInBaseCurrency = 0;
|
public totalBalanceInBaseCurrency = 0;
|
||||||
public totalValueInBaseCurrency = 0;
|
public totalValueInBaseCurrency = 0;
|
||||||
@ -67,6 +69,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
} else {
|
} else {
|
||||||
this.router.navigate(['.'], { relativeTo: this.route });
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
}
|
}
|
||||||
|
} else if (params['transferBalanceDialog']) {
|
||||||
|
this.openTransferBalanceDialog();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -91,9 +95,9 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
this.user.permissions,
|
this.user.permissions,
|
||||||
permissions.createAccount
|
permissions.createAccount
|
||||||
);
|
);
|
||||||
this.hasPermissionToDeleteAccount = hasPermission(
|
this.hasPermissionToUpdateAccount = hasPermission(
|
||||||
this.user.permissions,
|
this.user.permissions,
|
||||||
permissions.deleteAccount
|
permissions.updateAccount
|
||||||
);
|
);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
@ -144,6 +148,12 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onTransferBalance() {
|
||||||
|
this.router.navigate([], {
|
||||||
|
queryParams: { transferBalanceDialog: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onUpdateAccount(aAccount: AccountModel) {
|
public onUpdateAccount(aAccount: AccountModel) {
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
queryParams: { accountId: aAccount.id, editDialog: true }
|
queryParams: { accountId: aAccount.id, editDialog: true }
|
||||||
@ -267,4 +277,37 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
this.router.navigate(['.'], { relativeTo: this.route });
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openTransferBalanceDialog(): void {
|
||||||
|
const dialogRef = this.dialog.open(TransferBalanceDialog, {
|
||||||
|
data: {
|
||||||
|
accounts: this.accounts
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data: any) => {
|
||||||
|
if (data) {
|
||||||
|
const { accountIdFrom, accountIdTo, balance }: TransferBalanceDto =
|
||||||
|
data?.account;
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.transferAccountBalance({
|
||||||
|
accountIdFrom,
|
||||||
|
accountIdTo,
|
||||||
|
balance
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.fetchAccounts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,12 +8,13 @@
|
|||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[deviceType]="deviceType"
|
[deviceType]="deviceType"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
|
[showActions]="!hasImpersonationId && hasPermissionToUpdateAccount && !user.settings.isRestrictedView"
|
||||||
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
|
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
|
||||||
[totalValueInBaseCurrency]="totalValueInBaseCurrency"
|
[totalValueInBaseCurrency]="totalValueInBaseCurrency"
|
||||||
[transactionCount]="transactionCount"
|
[transactionCount]="transactionCount"
|
||||||
(accountDeleted)="onDeleteAccount($event)"
|
(accountDeleted)="onDeleteAccount($event)"
|
||||||
(accountToUpdate)="onUpdateAccount($event)"
|
(accountToUpdate)="onUpdateAccount($event)"
|
||||||
|
(transferBalance)="onTransferBalance()"
|
||||||
></gf-accounts-table>
|
></gf-accounts-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,6 +8,7 @@ import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-ta
|
|||||||
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
|
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
|
||||||
import { AccountsPageComponent } from './accounts-page.component';
|
import { AccountsPageComponent } from './accounts-page.component';
|
||||||
import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module';
|
import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module';
|
||||||
|
import { GfTransferBalanceDialogModule } from './transfer-balance/transfer-balance-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AccountsPageComponent],
|
declarations: [AccountsPageComponent],
|
||||||
@ -17,6 +18,7 @@ import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-
|
|||||||
GfAccountDetailDialogModule,
|
GfAccountDetailDialogModule,
|
||||||
GfAccountsTableModule,
|
GfAccountsTableModule,
|
||||||
GfCreateOrUpdateAccountDialogModule,
|
GfCreateOrUpdateAccountDialogModule,
|
||||||
|
GfTransferBalanceDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
],
|
],
|
||||||
|
@ -4,11 +4,4 @@
|
|||||||
.accounts {
|
.accounts {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab-container {
|
|
||||||
position: fixed;
|
|
||||||
right: 2rem;
|
|
||||||
bottom: 2rem;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -4,12 +4,20 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import {
|
||||||
|
AbstractControl,
|
||||||
|
FormBuilder,
|
||||||
|
FormGroup,
|
||||||
|
ValidatorFn,
|
||||||
|
Validators
|
||||||
|
} from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { Subject } from 'rxjs';
|
import { Platform } from '@prisma/client';
|
||||||
|
import { Observable, Subject } from 'rxjs';
|
||||||
|
import { map, startWith } from 'rxjs/operators';
|
||||||
|
|
||||||
import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
|
import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -23,7 +31,8 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
|
|||||||
export class CreateOrUpdateAccountDialog implements OnDestroy {
|
export class CreateOrUpdateAccountDialog implements OnDestroy {
|
||||||
public accountForm: FormGroup;
|
public accountForm: FormGroup;
|
||||||
public currencies: string[] = [];
|
public currencies: string[] = [];
|
||||||
public platforms: { id: string; name: string }[];
|
public filteredPlatforms: Observable<Platform[]>;
|
||||||
|
public platforms: Platform[];
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -34,7 +43,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
|||||||
private formBuilder: FormBuilder
|
private formBuilder: FormBuilder
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
public ngOnInit() {
|
||||||
const { currencies, platforms } = this.dataService.fetchInfo();
|
const { currencies, platforms } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
this.currencies = currencies;
|
this.currencies = currencies;
|
||||||
@ -47,8 +56,41 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
|||||||
currency: [this.data.account.currency, Validators.required],
|
currency: [this.data.account.currency, Validators.required],
|
||||||
isExcluded: [this.data.account.isExcluded],
|
isExcluded: [this.data.account.isExcluded],
|
||||||
name: [this.data.account.name, Validators.required],
|
name: [this.data.account.name, Validators.required],
|
||||||
platformId: [this.data.account.platformId]
|
platformId: [
|
||||||
|
this.platforms.find(({ id }) => {
|
||||||
|
return id === this.data.account.platformId;
|
||||||
|
}),
|
||||||
|
this.autocompleteObjectValidator()
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.filteredPlatforms = this.accountForm
|
||||||
|
.get('platformId')
|
||||||
|
.valueChanges.pipe(
|
||||||
|
startWith(''),
|
||||||
|
map((value) => {
|
||||||
|
const name = typeof value === 'string' ? value : value?.name;
|
||||||
|
return name ? this.filter(name as string) : this.platforms.slice();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public autoCompleteCheck() {
|
||||||
|
const inputValue = this.accountForm.controls['platformId'].value;
|
||||||
|
|
||||||
|
if (typeof inputValue === 'string') {
|
||||||
|
const matchingEntry = this.platforms.find(({ name }) => {
|
||||||
|
return name === inputValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingEntry) {
|
||||||
|
this.accountForm.controls['platformId'].setValue(matchingEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public displayFn(platform: Platform) {
|
||||||
|
return platform?.name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCancel() {
|
public onCancel() {
|
||||||
@ -63,7 +105,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
|||||||
id: this.accountForm.controls['accountId'].value,
|
id: this.accountForm.controls['accountId'].value,
|
||||||
isExcluded: this.accountForm.controls['isExcluded'].value,
|
isExcluded: this.accountForm.controls['isExcluded'].value,
|
||||||
name: this.accountForm.controls['name'].value,
|
name: this.accountForm.controls['name'].value,
|
||||||
platformId: this.accountForm.controls['platformId'].value
|
platformId: this.accountForm.controls['platformId'].value?.id ?? null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.data.account.id) {
|
if (this.data.account.id) {
|
||||||
@ -79,4 +121,22 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
|||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private autocompleteObjectValidator(): ValidatorFn {
|
||||||
|
return (control: AbstractControl) => {
|
||||||
|
if (control.value && typeof control.value === 'string') {
|
||||||
|
return { invalidAutocompleteObject: { value: control.value } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private filter(value: string): Platform[] {
|
||||||
|
const filterValue = value.toLowerCase();
|
||||||
|
|
||||||
|
return this.platforms.filter(({ name }) => {
|
||||||
|
return name.toLowerCase().startsWith(filterValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Name</mat-label>
|
<mat-label i18n>Name</mat-label>
|
||||||
<input formControlName="name" matInput />
|
<input
|
||||||
|
formControlName="name"
|
||||||
|
matInput
|
||||||
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -26,19 +30,43 @@
|
|||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Cash Balance</mat-label>
|
<mat-label i18n>Cash Balance</mat-label>
|
||||||
<input formControlName="balance" matInput type="number" />
|
<input
|
||||||
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
|
formControlName="balance"
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
<span class="ml-2" matTextSuffix
|
||||||
|
>{{ accountForm.controls['currency'].value }}</span
|
||||||
|
>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">
|
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Platform</mat-label>
|
<mat-label i18n>Platform</mat-label>
|
||||||
<mat-select formControlName="platformId">
|
<input
|
||||||
<mat-option [value]="null"></mat-option>
|
formControlName="platformId"
|
||||||
<mat-option *ngFor="let platform of platforms" [value]="platform.id"
|
matInput
|
||||||
>{{ platform.name }}</mat-option
|
type="text"
|
||||||
|
[matAutocomplete]="auto"
|
||||||
|
(blur)="autoCompleteCheck()"
|
||||||
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
|
||||||
|
<mat-option
|
||||||
|
*ngFor="let platformEntry of filteredPlatforms | async"
|
||||||
|
[value]="platformEntry"
|
||||||
>
|
>
|
||||||
</mat-select>
|
<span class="d-flex">
|
||||||
|
<gf-symbol-icon
|
||||||
|
class="mr-1"
|
||||||
|
[tooltip]="platformEntry.name"
|
||||||
|
[url]="platformEntry.url"
|
||||||
|
></gf-symbol-icon>
|
||||||
|
<span>{{ platformEntry.name }}</span>
|
||||||
|
</span>
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -66,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-content-end" mat-dialog-actions>
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||||
|
|
||||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
|
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
|
||||||
|
|
||||||
@ -15,6 +17,8 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
GfSymbolIconModule,
|
||||||
|
MatAutocompleteModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Account } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface TransferBalanceDialogParams {
|
||||||
|
accounts: Account[];
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
|
||||||
|
import { Account } from '@prisma/client';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { TransferBalanceDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'h-100' },
|
||||||
|
selector: 'gf-transfer-balance-dialog',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
styleUrls: ['./transfer-balance-dialog.scss'],
|
||||||
|
templateUrl: 'transfer-balance-dialog.html'
|
||||||
|
})
|
||||||
|
export class TransferBalanceDialog implements OnDestroy {
|
||||||
|
public accounts: Account[] = [];
|
||||||
|
public currency: string;
|
||||||
|
public transferBalanceForm: FormGroup;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: TransferBalanceDialogParams,
|
||||||
|
public dialogRef: MatDialogRef<TransferBalanceDialog>,
|
||||||
|
private formBuilder: FormBuilder
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.accounts = this.data.accounts;
|
||||||
|
|
||||||
|
this.transferBalanceForm = this.formBuilder.group({
|
||||||
|
balance: [0, Validators.required],
|
||||||
|
fromAccount: ['', Validators.required],
|
||||||
|
toAccount: ['', Validators.required]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.transferBalanceForm.get('fromAccount').valueChanges.subscribe((id) => {
|
||||||
|
this.currency = this.accounts.find((account) => {
|
||||||
|
return account.id === id;
|
||||||
|
}).currency;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
const account: TransferBalanceDto = {
|
||||||
|
accountIdFrom: this.transferBalanceForm.controls['fromAccount'].value,
|
||||||
|
accountIdTo: this.transferBalanceForm.controls['toAccount'].value,
|
||||||
|
balance: this.transferBalanceForm.controls['balance'].value
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dialogRef.close({ account });
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
<form
|
||||||
|
class="d-flex flex-column h-100"
|
||||||
|
[formGroup]="transferBalanceForm"
|
||||||
|
(keyup.enter)="transferBalanceForm.valid && onSubmit()"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
|
<h1 i18n mat-dialog-title>Transfer Cash Balance</h1>
|
||||||
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>From</mat-label>
|
||||||
|
<mat-select formControlName="fromAccount">
|
||||||
|
<mat-option *ngFor="let account of accounts" [value]="account.id"
|
||||||
|
>{{ account.name }}</mat-option
|
||||||
|
>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>To</mat-label>
|
||||||
|
<mat-select formControlName="toAccount">
|
||||||
|
<mat-option *ngFor="let account of accounts" [value]="account.id"
|
||||||
|
>{{ account.name }}</mat-option
|
||||||
|
>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Value</mat-label>
|
||||||
|
<input
|
||||||
|
formControlName="balance"
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
<span class="ml-2" matTextSuffix>{{ currency }}</span>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
|
<button
|
||||||
|
color="primary"
|
||||||
|
mat-flat-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="!transferBalanceForm.valid"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Transfer</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,24 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
|
import { TransferBalanceDialog } from './transfer-balance-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [TransferBalanceDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GfTransferBalanceDialogModule {}
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-mdc-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,6 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
|
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -15,7 +13,6 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
})
|
})
|
||||||
export class HomePageComponent implements OnDestroy, OnInit {
|
export class HomePageComponent implements OnDestroy, OnInit {
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
|
||||||
public tabs: TabConfiguration[] = [];
|
public tabs: TabConfiguration[] = [];
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
@ -23,17 +20,9 @@ export class HomePageComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
const { globalPermissions } = this.dataService.fetchInfo();
|
|
||||||
|
|
||||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
|
||||||
globalPermissions,
|
|
||||||
permissions.enableFearAndGreedIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@ -57,8 +46,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
|
|||||||
{
|
{
|
||||||
iconName: 'newspaper-outline',
|
iconName: 'newspaper-outline',
|
||||||
label: $localize`Markets`,
|
label: $localize`Markets`,
|
||||||
path: ['/home', 'market'],
|
path: ['/home', 'market']
|
||||||
showCondition: this.hasPermissionToAccessFearAndGreedIndex
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
@ -320,32 +320,37 @@
|
|||||||
|
|
||||||
<div class="row my-5">
|
<div class="row my-5">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h2 class="h4 mb-1 text-center" i18n>
|
<h2 class="h4 mb-3 text-center" i18n>
|
||||||
What our <strong>users</strong> are saying
|
What our <strong>users</strong> are saying
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div *ngFor="let testimonial of testimonials" class="col-md-6">
|
<div class="col-md-8 offset-md-2">
|
||||||
<div class="d-flex flex-row py-3">
|
<gf-carousel [aria-label]="'Testimonials'">
|
||||||
<div class="d-flex justify-content-center">
|
<div *ngFor="let testimonial of testimonials" gf-carousel-item>
|
||||||
|
<div class="d-flex px-3">
|
||||||
<gf-logo
|
<gf-logo
|
||||||
class="mr-3 mt-2 pt-1"
|
class="mr-3 mt-2 pt-1"
|
||||||
size="medium"
|
size="medium"
|
||||||
[showLabel]="false"
|
[showLabel]="false"
|
||||||
></gf-logo>
|
></gf-logo>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div>{{ testimonial.quote }}</div>
|
<div>{{ testimonial.quote }}</div>
|
||||||
<div class="mt-2 text-muted">
|
<div class="mt-2 text-muted">
|
||||||
—
|
—
|
||||||
<a *ngIf="testimonial.url" target="_blank" [href]="testimonial.url"
|
<a
|
||||||
|
*ngIf="testimonial.url"
|
||||||
|
target="_blank"
|
||||||
|
[href]="testimonial.url"
|
||||||
>{{ testimonial.author }}</a
|
>{{ testimonial.author }}</a
|
||||||
>
|
>
|
||||||
<span *ngIf="!testimonial.url">{{ testimonial.author }}</span>, {{
|
<span *ngIf="!testimonial.url">{{ testimonial.author }}</span>,
|
||||||
testimonial.country }}
|
{{ testimonial.country }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</gf-carousel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="hasPermissionForSubscription" class="row my-5">
|
<div *ngIf="hasPermissionForSubscription" class="row my-5">
|
||||||
|
@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||||
|
import { GfCarouselModule } from '@ghostfolio/ui/carousel';
|
||||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ import { LandingPageComponent } from './landing-page.component';
|
|||||||
declarations: [LandingPageComponent],
|
declarations: [LandingPageComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfCarouselModule,
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
GfWorldMapChartModule,
|
GfWorldMapChartModule,
|
||||||
|
@ -1,10 +1,3 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
.fab-container {
|
|
||||||
position: fixed;
|
|
||||||
right: 2rem;
|
|
||||||
bottom: 2rem;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<mat-stepper
|
<mat-stepper
|
||||||
#stepper
|
#stepper
|
||||||
[animationDuration]="0"
|
animationDuration="0"
|
||||||
[linear]="true"
|
[linear]="true"
|
||||||
[orientation]="stepperOrientation"
|
[orientation]="stepperOrientation"
|
||||||
[selectedIndex]="importStep"
|
[selectedIndex]="importStep"
|
||||||
|
@ -131,9 +131,9 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<gf-holdings-table
|
<gf-holdings-table
|
||||||
pageSize="7"
|
|
||||||
[deviceType]="deviceType"
|
[deviceType]="deviceType"
|
||||||
[hasPermissionToShowValues]="false"
|
[hasPermissionToShowValues]="false"
|
||||||
|
[pageSize]="7"
|
||||||
[positions]="positionsArray"
|
[positions]="positionsArray"
|
||||||
></gf-holdings-table>
|
></gf-holdings-table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { GfUserAccountAccessModule } from '@ghostfolio/client/components/user-account-access/user-account-access.module';
|
import { GfUserAccountAccessModule } from '@ghostfolio/client/components/user-account-access/user-account-access.module';
|
||||||
import { GfUserAccountMembershipModule } from '@ghostfolio/client/components/user-account-membership/user-account-membership.module';
|
import { GfUserAccountMembershipModule } from '@ghostfolio/client/components/user-account-membership/user-account-membership.module';
|
||||||
@ -17,6 +17,7 @@ import { UserAccountPageComponent } from './user-account-page.component';
|
|||||||
GfUserAccountSettingsModule,
|
GfUserAccountSettingsModule,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
UserAccountPageRoutingModule
|
UserAccountPageRoutingModule
|
||||||
]
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class UserAccountPageModule {}
|
export class UserAccountPageModule {}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||||
|
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
|
||||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||||
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
|
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
|
||||||
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
|
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
|
||||||
@ -214,6 +215,20 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public postMarketData({
|
||||||
|
dataSource,
|
||||||
|
marketData,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
marketData: UpdateBulkMarketDataDto;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
const url = `/api/v1/admin/market-data/${dataSource}/${symbol}`;
|
||||||
|
|
||||||
|
return this.http.post<MarketData>(url, marketData);
|
||||||
|
}
|
||||||
|
|
||||||
public postPlatform(aPlatform: CreatePlatformDto) {
|
public postPlatform(aPlatform: CreatePlatformDto) {
|
||||||
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
|
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
|
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
|
||||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
@ -36,7 +37,6 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
|
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
|
||||||
import { AccountWithValue, DateRange, GroupBy } from '@ghostfolio/common/types';
|
import { AccountWithValue, DateRange, GroupBy } from '@ghostfolio/common/types';
|
||||||
import { translate } from '@ghostfolio/ui/i18n';
|
|
||||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
||||||
@ -58,6 +58,7 @@ export class DataService {
|
|||||||
ASSET_CLASS: filtersByAssetClass,
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
ASSET_SUB_CLASS: filtersByAssetSubClass,
|
ASSET_SUB_CLASS: filtersByAssetSubClass,
|
||||||
PRESET_ID: filtersByPresetId,
|
PRESET_ID: filtersByPresetId,
|
||||||
|
SEARCH_QUERY: filtersBySearchQuery,
|
||||||
TAG: filtersByTag
|
TAG: filtersByTag
|
||||||
} = groupBy(filters, (filter) => {
|
} = groupBy(filters, (filter) => {
|
||||||
return filter.type;
|
return filter.type;
|
||||||
@ -100,6 +101,10 @@ export class DataService {
|
|||||||
params = params.append('presetId', filtersByPresetId[0].id);
|
params = params.append('presetId', filtersByPresetId[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filtersBySearchQuery) {
|
||||||
|
params = params.append('query', filtersBySearchQuery[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
if (filtersByTag) {
|
if (filtersByTag) {
|
||||||
params = params.append(
|
params = params.append(
|
||||||
'tags',
|
'tags',
|
||||||
@ -501,6 +506,18 @@ export class DataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public transferAccountBalance({
|
||||||
|
accountIdFrom,
|
||||||
|
accountIdTo,
|
||||||
|
balance
|
||||||
|
}: TransferBalanceDto) {
|
||||||
|
return this.http.post('/api/v1/account/transfer-balance', {
|
||||||
|
accountIdFrom,
|
||||||
|
accountIdTo,
|
||||||
|
balance
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public updateInfo() {
|
public updateInfo() {
|
||||||
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
|
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
|
||||||
const utmSource = <'ios' | 'trusted-web-activity'>(
|
const utmSource = <'ios' | 'trusted-web-activity'>(
|
||||||
|
@ -15,6 +15,7 @@ import { catchError } from 'rxjs/operators';
|
|||||||
})
|
})
|
||||||
export class ImportActivitiesService {
|
export class ImportActivitiesService {
|
||||||
private static ACCOUNT_KEYS = ['account', 'accountid'];
|
private static ACCOUNT_KEYS = ['account', 'accountid'];
|
||||||
|
private static COMMENT_KEYS = ['comment', 'note'];
|
||||||
private static CURRENCY_KEYS = ['ccy', 'currency', 'currencyprimary'];
|
private static CURRENCY_KEYS = ['ccy', 'currency', 'currencyprimary'];
|
||||||
private static DATA_SOURCE_KEYS = ['datasource'];
|
private static DATA_SOURCE_KEYS = ['datasource'];
|
||||||
private static DATE_KEYS = ['date', 'tradedate'];
|
private static DATE_KEYS = ['date', 'tradedate'];
|
||||||
@ -52,6 +53,7 @@ export class ImportActivitiesService {
|
|||||||
for (const [index, item] of content.entries()) {
|
for (const [index, item] of content.entries()) {
|
||||||
activities.push({
|
activities.push({
|
||||||
accountId: this.parseAccount({ item, userAccounts }),
|
accountId: this.parseAccount({ item, userAccounts }),
|
||||||
|
comment: this.parseComment({ item }),
|
||||||
currency: this.parseCurrency({ content, index, item }),
|
currency: this.parseCurrency({ content, index, item }),
|
||||||
dataSource: this.parseDataSource({ item }),
|
dataSource: this.parseDataSource({ item }),
|
||||||
date: this.parseDate({ content, index, item }),
|
date: this.parseDate({ content, index, item }),
|
||||||
@ -122,6 +124,7 @@ export class ImportActivitiesService {
|
|||||||
|
|
||||||
private convertToCreateOrderDto({
|
private convertToCreateOrderDto({
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
@ -132,6 +135,7 @@ export class ImportActivitiesService {
|
|||||||
}: Activity): CreateOrderDto {
|
}: Activity): CreateOrderDto {
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
@ -174,6 +178,18 @@ export class ImportActivitiesService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseComment({ item }: { item: any }) {
|
||||||
|
item = this.lowercaseKeys(item);
|
||||||
|
|
||||||
|
for (const key of ImportActivitiesService.COMMENT_KEYS) {
|
||||||
|
if (item[key]) {
|
||||||
|
return item[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private parseCurrency({
|
private parseCurrency({
|
||||||
content,
|
content,
|
||||||
index,
|
index,
|
||||||
@ -321,6 +337,10 @@ export class ImportActivitiesService {
|
|||||||
return Type.BUY;
|
return Type.BUY;
|
||||||
case 'dividend':
|
case 'dividend':
|
||||||
return Type.DIVIDEND;
|
return Type.DIVIDEND;
|
||||||
|
case 'fee':
|
||||||
|
return Type.FEE;
|
||||||
|
case 'interest':
|
||||||
|
return Type.INTEREST;
|
||||||
case 'item':
|
case 'item':
|
||||||
return Type.ITEM;
|
return Type.ITEM;
|
||||||
case 'liability':
|
case 'liability':
|
||||||
|
@ -88,7 +88,9 @@ export class WebAuthnService {
|
|||||||
{ deviceId }
|
{ deviceId }
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(startAuthentication),
|
switchMap((requestOptionsJSON) =>
|
||||||
|
startAuthentication(requestOptionsJSON, true)
|
||||||
|
),
|
||||||
switchMap((assertionResponse) => {
|
switchMap((assertionResponse) => {
|
||||||
return this.http.post<{ authToken: string }>(
|
return this.http.post<{ authToken: string }>(
|
||||||
`/api/v1/auth/webauthn/verify-assertion`,
|
`/api/v1/auth/webauthn/verify-assertion`,
|
||||||
|
@ -1,11 +1,6 @@
|
|||||||
{
|
{
|
||||||
"createdAt": "2023-09-25T08:15:38.055Z",
|
"createdAt": "2023-10-05T00:00:00.000Z",
|
||||||
"data": [
|
"data": [
|
||||||
{
|
|
||||||
"name": "Appsmith",
|
|
||||||
"description": "Build build custom software on top of your data.",
|
|
||||||
"href": "https://www.appsmith.com"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "BoxyHQ",
|
"name": "BoxyHQ",
|
||||||
"description": "BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
|
"description": "BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
|
||||||
@ -66,11 +61,6 @@
|
|||||||
"description": "Mockoon is the easiest and quickest way to design and run mock REST APIs.",
|
"description": "Mockoon is the easiest and quickest way to design and run mock REST APIs.",
|
||||||
"href": "https://mockoon.com"
|
"href": "https://mockoon.com"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Novu",
|
|
||||||
"description": "The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.",
|
|
||||||
"href": "https://novu.co"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "OpenBB",
|
"name": "OpenBB",
|
||||||
"description": "Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
|
"description": "Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -214,6 +214,16 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mat-mdc-menu-panel {
|
||||||
|
&.assistant {
|
||||||
|
max-width: unset !important;
|
||||||
|
|
||||||
|
.mat-mdc-menu-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.is-dark-theme {
|
&.is-dark-theme {
|
||||||
background: var(--dark-background);
|
background: var(--dark-background);
|
||||||
color: rgba(var(--light-primary-text));
|
color: rgba(var(--light-primary-text));
|
||||||
@ -452,6 +462,15 @@ ngx-skeleton-loader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix for https://github.com/angular/components/issues/26818
|
||||||
|
*/
|
||||||
|
.mat-mdc-slide-toggle {
|
||||||
|
.mdc-switch__track {
|
||||||
|
background-color: rgba(var(--palette-primary-500), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mat-stepper-vertical,
|
.mat-stepper-vertical,
|
||||||
.mat-stepper-horizontal {
|
.mat-stepper-horizontal {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@ -481,6 +500,13 @@ ngx-skeleton-loader {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.fab-container {
|
||||||
|
bottom: 2rem;
|
||||||
|
position: fixed;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
&:not(.has-tabs) {
|
&:not(.has-tabs) {
|
||||||
@media (min-width: 576px) {
|
@media (min-width: 576px) {
|
||||||
padding: 2rem 0;
|
padding: 2rem 0;
|
||||||
@ -492,6 +518,12 @@ ngx-skeleton-loader {
|
|||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
padding-bottom: constant(safe-area-inset-bottom);
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
|
|
||||||
|
.fab-container {
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
bottom: 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mat-mdc-tab-nav-bar {
|
.mat-mdc-tab-nav-bar {
|
||||||
--mat-tab-header-active-focus-indicator-color: transparent;
|
--mat-tab-header-active-focus-indicator-color: transparent;
|
||||||
--mat-tab-header-active-hover-indicator-color: transparent;
|
--mat-tab-header-active-hover-indicator-color: transparent;
|
||||||
|
@ -7,7 +7,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- ${POSTGRES_PORT:-5432}:5432
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
redis:
|
redis:
|
||||||
@ -15,7 +15,7 @@ services:
|
|||||||
container_name: redis
|
container_name: redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- ${REDIS_PORT:-6379}:6379
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
|
@ -12,4 +12,5 @@ export interface AdminData {
|
|||||||
lastActivity: Date;
|
lastActivity: Date;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
}[];
|
}[];
|
||||||
|
version: string;
|
||||||
}
|
}
|
||||||
|
@ -9,9 +9,11 @@ export interface AdminMarketDataItem {
|
|||||||
assetClass?: AssetClass;
|
assetClass?: AssetClass;
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass;
|
||||||
countriesCount: number;
|
countriesCount: number;
|
||||||
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
date?: Date;
|
date?: Date;
|
||||||
marketDataItemCount: number;
|
marketDataItemCount: number;
|
||||||
|
name: string;
|
||||||
sectorsCount: number;
|
sectorsCount: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ export interface Benchmark {
|
|||||||
name: EnhancedSymbolProfile['name'];
|
name: EnhancedSymbolProfile['name'];
|
||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
|
date: Date;
|
||||||
performancePercent: number;
|
performancePercent: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -15,6 +15,7 @@ export interface EnhancedSymbolProfile {
|
|||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
dateOfFirstActivity?: Date;
|
dateOfFirstActivity?: Date;
|
||||||
id: string;
|
id: string;
|
||||||
|
isin: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
scraperConfiguration?: ScraperConfiguration | null;
|
scraperConfiguration?: ScraperConfiguration | null;
|
||||||
sectors: Sector[];
|
sectors: Sector[];
|
||||||
|
@ -6,6 +6,7 @@ export interface Filter {
|
|||||||
| 'ASSET_CLASS'
|
| 'ASSET_CLASS'
|
||||||
| 'ASSET_SUB_CLASS'
|
| 'ASSET_SUB_CLASS'
|
||||||
| 'PRESET_ID'
|
| 'PRESET_ID'
|
||||||
|
| 'SEARCH_QUERY'
|
||||||
| 'SYMBOL'
|
| 'SYMBOL'
|
||||||
| 'TAG';
|
| 'TAG';
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,7 @@ import type { PortfolioReport } from './portfolio-report.interface';
|
|||||||
import type { PortfolioSummary } from './portfolio-summary.interface';
|
import type { PortfolioSummary } from './portfolio-summary.interface';
|
||||||
import type { Position } from './position.interface';
|
import type { Position } from './position.interface';
|
||||||
import type { Product } from './product';
|
import type { Product } from './product';
|
||||||
|
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
|
||||||
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
|
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
|
||||||
import type { ResponseError } from './responses/errors.interface';
|
import type { ResponseError } from './responses/errors.interface';
|
||||||
import type { ImportResponse } from './responses/import-response.interface';
|
import type { ImportResponse } from './responses/import-response.interface';
|
||||||
@ -49,6 +50,7 @@ import type { User } from './user.interface';
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Access,
|
Access,
|
||||||
|
AccountBalancesResponse,
|
||||||
Accounts,
|
Accounts,
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminJobs,
|
AdminJobs,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||||
import { SymbolProfile, Tag } from '@prisma/client';
|
import { Platform, SymbolProfile, Tag } from '@prisma/client';
|
||||||
|
|
||||||
import { Statistics } from './statistics.interface';
|
import { Statistics } from './statistics.interface';
|
||||||
import { Subscription } from './subscription.interface';
|
import { Subscription } from './subscription.interface';
|
||||||
@ -13,7 +13,7 @@ export interface InfoItem {
|
|||||||
fearAndGreedDataSource?: string;
|
fearAndGreedDataSource?: string;
|
||||||
globalPermissions: string[];
|
globalPermissions: string[];
|
||||||
isReadOnlyMode?: boolean;
|
isReadOnlyMode?: boolean;
|
||||||
platforms: { id: string; name: string }[];
|
platforms: Platform[];
|
||||||
statistics: Statistics;
|
statistics: Statistics;
|
||||||
stripePublicKey?: string;
|
stripePublicKey?: string;
|
||||||
subscriptions: { [offer in SubscriptionOffer]: Subscription };
|
subscriptions: { [offer in SubscriptionOffer]: Subscription };
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { AssetClass, DataSource } from '@prisma/client';
|
import { MarketState } from '@ghostfolio/common/types';
|
||||||
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
import { MarketState } from '../types';
|
|
||||||
|
|
||||||
export interface Position {
|
export interface Position {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
|
assetSubClass: AssetSubClass;
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user