Compare commits

..

45 Commits

Author SHA1 Message Date
20f9225daa Release 2.11.0 (#2488) 2023-10-14 19:12:37 +02:00
b6101c6375 Feature/import historical data (#2448)
* Import historical data for an asset

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-14 19:06:27 +02:00
e1022846b9 Bugfix/fix displayed currency of cash balance in account dialog (#2480)
* Fix currency

* Update changelog
2023-10-14 15:01:29 +02:00
9ba79f6721 Improve style (#2478) 2023-10-13 22:11:05 +02:00
0ac97bd112 Transfer cash balance between accounts (#2455)
* Transfer cash balance between accounts

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-13 21:46:49 +02:00
827270704a Bugfix/fix import for activities of type fee and interest (#2474)
* Fix import for activities of type fee and interest

* Update changelog
2023-10-13 19:51:02 +02:00
8634463597 Feature/upgrade prisma to version 5.4.2 (#2477)
* Upgrade prisma to version 5.4.2

* Update changelog
2023-10-13 19:50:06 +02:00
3905782ad6 Fix fab-container (#2476) 2023-10-13 19:49:30 +02:00
5db984ffef Add date column to benchmark component (#2466)
* Add date column to benchmark component
2023-10-12 10:21:00 +02:00
fb3cd4b689 Remove icon (#2467) 2023-10-11 09:58:18 +02:00
3b5a34f6f3 Use fab-button in access management tab (#2456)
* Use fab-button in access management tab

* Refactor fab container

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-11 09:57:35 +02:00
22b43b5bfc Feature/extract locales 20231010 (#2462)
* Update locales

* Update changelog
2023-10-10 20:17:45 +02:00
6c66033eb4 Add date to markets overview by benchmarks (#2436)
* Add date

* Update changelog
2023-10-10 17:31:53 +02:00
162fc25e23 Release 2.10.0 (#2459) 2023-10-09 20:31:34 +02:00
45f385a483 Feature/improve symbol conversion in eod historical data service (#2457)
* Improve conversion of currency symbols

* Update changelog
2023-10-09 20:29:56 +02:00
e9ef911548 Feature/improve search results display in assistant (#2458)
* Only show search results if search is active

* Update changelog
2023-10-09 20:28:39 +02:00
d8d4d8f001 Change jobs table in admin control to mat-table (#2444)
* Change jobs table in admin control to mat-table

* Update changelog
2023-10-09 19:38:33 +02:00
f47c7313af Support enter key press to submit access dialog form (#2437)
* Support enter key press to submit access dialog form

* Update changelog
2023-10-09 19:11:09 +02:00
31f0056a2d Release 2.9.0 (#2454) 2023-10-08 20:34:39 +02:00
550e646079 Feature/introduce assistant (#2451)
* Introduce assistant

* Update changelog
2023-10-08 20:32:00 +02:00
37ff7acf04 Change platform control from select to autocomplete in account dialog (#2429)
* Change platform control from select to autocomplete in account dialog

* Update changelog
2023-10-08 19:17:55 +02:00
8236091477 Feature/add support for search query in portfolio position endpoint (#2443)
* Introduce search query filter

* Update changelog
2023-10-07 19:30:28 +02:00
2a71cb66de Use port numbers from environment variables in docker-compose.dev.yml (#2406) 2023-10-07 17:18:13 +02:00
e60fe48fdd Add dialog for cash transfer between accounts (#2433)
* Add dialog for cash transfer between accounts

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-07 14:46:13 +02:00
d40bc5070a Feature/remove permission to markets overview on home page (#2441)
* Remove show condition for markets overview

* Update changelog
2023-10-07 09:15:54 +02:00
fda4e0ea7d Use application version from API endpoint in Admin Control panel (#2427)
* Use application version from API endpoint

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-07 09:11:32 +02:00
08d696ce33 Align ok.json with ok.csv (#2439) 2023-10-07 08:44:51 +02:00
46614a7c24 Create carousel component for testimonials (#2394)
* Create carousel component for testimonials

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-06 22:12:09 +02:00
02b433eb1e Prevent empty form submission in account dialog (#2428)
* Prevent empty form submission in account dialog
2023-10-05 21:02:35 +02:00
25112a450b Add support for comment in csv import (#2416)
* Add support for comment in csv import (activities)

* Update changelog
2023-10-05 20:42:35 +02:00
727340748b Clean up imports (#2411) 2023-10-05 20:40:31 +02:00
8ad6492477 Feature/various improvements in client (#2434)
* Various improvements

* Update changelog
2023-10-05 20:31:00 +02:00
4af76f6f6d Fix hasNotDefinedValuesInObject() in object.helper.ts (#2421) 2023-10-05 20:29:34 +02:00
10940214a5 Update OSS Friends (#2431) 2023-10-05 20:27:39 +02:00
d9a6c22e1e Add application version to admin endpoint (#2423)
* Add application version to admin endpoint

* Update changelog
2023-10-04 16:15:08 +02:00
692309988c Add Prisma Studio (#2415) 2023-10-04 08:48:50 +02:00
42a54263f9 Release 2.8.0 (#2420) 2023-10-03 20:10:29 +02:00
4fb88859b2 Improve form in account dialog (#2408)
* Improve form in account dialog

* Update changelog
2023-10-03 19:34:04 +02:00
aa24b5e8c6 Feature/reload platform and tag on change (#2417)
* Reload platforms via info

* Reload tags via info

* Update changelog
2023-10-03 18:58:23 +02:00
90e18338f6 Change UX to set an asset profile as a benchmark (#2409)
* Add checkbox functionality to set / unset benchmark

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-02 20:50:12 +02:00
ad5ae938ef Feature/harmonize settings icon of user account page (#2412)
* Harmonize icon

* Update changelog
2023-10-02 11:29:33 +02:00
c9a8dd4958 Support copy-assets Nx target on Windows (#2410)
* Introduce shx plugin
2023-10-02 10:48:36 +02:00
f1ec5e704e Add version to Overview of Admin Control panel (#2414)
* Add version to Overview of Admin Control panel

* Update changelog
2023-10-02 10:05:01 +02:00
f40f0653c2 Add request params (ship, take) for pagination to GET order endpoint (#2382)
* Add request params (ship, take) for pagination to GET order endpoint

* Update changelog
2023-10-02 08:54:21 +02:00
5f7a230fd3 Bugfix/fix sidebar on user account page (#2413)
* Fix sidebar on user account page

* Update changelog
2023-10-01 19:11:48 +02:00
118 changed files with 8066 additions and 2868 deletions

View File

@ -5,6 +5,75 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.11.0 - 2023-10-14
### Added
- Added support to transfer a part of the cash balance from one to another account
- Extended the markets overview by benchmarks (date of last all time high)
- Added support to import historical market data in the admin control panel
### Changed
- Harmonized the style of the create button on the page for granting and revoking public access to share the portfolio
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.3.1` to `5.4.2`
### Fixed
- Fixed `FEE` and `INTEREST` types in the activities import of `csv` files
- Fixed the displayed currency of the cash balance in the create or update account dialog
## 2.10.0 - 2023-10-09
### Added
- Supported enter key press to submit the form of the create or update access dialog
### Changed
- Improved the display of the results in the search for a holding
- Changed the queue jobs view in the admin control panel to an `@angular/material` data table
- Improved the symbol conversion in the _EOD Historical Data_ service
## 2.9.0 - 2023-10-08
### Added
- Added support to search for a holding by `isin`, `name` and `symbol` (experimental)
- Added support for notes in the activities import
- Added support to search in the platform selector of the create or update account dialog
- Added support for a search query in the portfolio position endpoint
- Added the application version to the endpoint `GET api/v1/admin`
- Introduced a carousel component for the testimonial section on the landing page
### Changed
- Displayed the link to the markets overview on the home page without any permission
### Fixed
- Fixed the style of the active features page in the navigation on desktop
## 2.8.0 - 2023-10-03
### Added
- Supported enter key press to submit the form of the create or update account dialog
- Added the application version to the admin control panel
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
### Changed
- Harmonized the settings icon of the user account page
- Improved the usability to set an asset profile as a benchmark
- Reload platforms after making a change in the admin control panel
- Reload tags after making a change in the admin control panel
### Fixed
- Fixed the sidebar navigation on the user account page
## 2.7.0 - 2023-09-30
### Added

View File

@ -18,6 +18,12 @@
### Prisma
#### Access database via GUI
Run `yarn database:gui`
https://www.prisma.io/studio
#### Synchronize schema with database for prototyping
Run `yarn database:push`

View File

@ -8,4 +8,8 @@ export class CreateAccessDto {
@IsOptional()
@IsString()
granteeUserId?: string;
@IsOptional()
@IsString()
type?: 'PUBLIC';
}

View File

@ -29,6 +29,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account')
@ -154,6 +155,58 @@ export class AccountController {
}
}
@Post('transfer-balance')
@UseGuards(AuthGuard('jwt'))
public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id
);
const currentAccountIds = accountsOfUser.map(({ id }) => {
return id;
});
if (
![accountIdFrom, accountIdTo].every((accountId) => {
return currentAccountIds.includes(accountId);
})
) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const { currency } = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdFrom,
amount: -balance,
userId: this.request.user.id
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdTo,
amount: balance,
userId: this.request.user.id
});
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {

View File

@ -109,7 +109,7 @@ export class AccountService {
});
}
public async getAccounts(aUserId: string) {
public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
@ -218,13 +218,13 @@ export class AccountService {
accountId,
amount,
currency,
date,
date = new Date(),
userId
}: {
accountId: string;
amount: number;
currency: string;
date: Date;
date?: Date;
userId: string;
}) {
const { balance, currency: currencyOfAccount } = await this.account({

View File

@ -12,7 +12,7 @@ import { isString } from 'lodash';
export class CreateAccountDto {
@IsOptional()
@IsString()
accountType: AccountType;
accountType?: AccountType;
@IsNumber()
balance: number;

View File

@ -0,0 +1,12 @@
import { IsNumber, IsString } from 'class-validator';
export class TransferBalanceDto {
@IsString()
accountIdFrom: string;
@IsString()
accountIdTo: string;
@IsNumber()
balance: number;
}

View File

@ -12,7 +12,7 @@ import { isString } from 'lodash';
export class UpdateAccountDto {
@IsOptional()
@IsString()
accountType: AccountType;
accountType?: AccountType;
@IsNumber()
balance: number;

View File

@ -43,6 +43,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
@ -313,6 +314,43 @@ export class AdminController {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
date,
marketPrice,
symbol,
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async update(

View File

@ -1,4 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -97,7 +98,8 @@ export class AdminService {
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics()
users: await this.getUsersWithAnalytics(),
version: environment.version
};
}

View 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[];
}

View File

@ -1,6 +1,10 @@
import { IsNumber } from 'class-validator';
import { IsDate, IsNumber, IsOptional } from 'class-validator';
export class UpdateMarketDataDto {
@IsDate()
@IsOptional()
date?: Date;
@IsNumber()
marketPrice: number;
}

View File

@ -10,6 +10,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
@ -32,32 +33,6 @@ export class BenchmarkController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
return {
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
@ -94,4 +69,70 @@ export class BenchmarkController {
);
}
}
@Delete(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async deleteBenchmark(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
const benchmark = await this.benchmarkService.deleteBenchmark({
dataSource,
symbol
});
if (!benchmark) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return benchmark;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
return {
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
}

View File

@ -64,7 +64,7 @@ export class BenchmarkService {
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
const promises: Promise<number>[] = [];
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
@ -85,15 +85,14 @@ export class BenchmarkService {
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh && marketPrice) {
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh,
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
@ -101,6 +100,7 @@ export class BenchmarkService {
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh.date,
performancePercent: performancePercentFromAllTimeHigh
}
}
@ -245,6 +245,43 @@ export class BenchmarkService {
};
}
public async deleteBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
symbol
}
});
if (!assetProfile) {
return null;
}
let benchmarks =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
return symbolProfileId !== assetProfile.id;
});
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
return {
dataSource,
symbol,
id: assetProfile.id,
name: assetProfile.name
};
}
private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}

View File

@ -55,12 +55,8 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = (
await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
})
).map(({ id, name }) => {
return { id, name };
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
let systemMessage: string;

View File

@ -89,7 +89,9 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
@Query('skip') skip?: number,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
@ -105,6 +107,8 @@ export class OrderController {
filters,
userCurrency,
includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});

View File

@ -230,6 +230,8 @@ export class OrderService {
public async getOrders({
filters,
includeDrafts = false,
skip,
take = Number.MAX_SAFE_INTEGER,
types,
userCurrency,
userId,
@ -237,6 +239,8 @@ export class OrderService {
}: {
filters?: Filter[];
includeDrafts?: boolean;
skip?: number;
take?: number;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
@ -315,6 +319,8 @@ export class OrderService {
return (
await this.orders({
skip,
take,
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention

View File

@ -391,12 +391,14 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});

View File

@ -1014,6 +1014,9 @@ export class PortfolioService {
filters?: Filter[];
impersonationId: string;
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
@ -1042,9 +1045,9 @@ export class PortfolioService {
const currentPositions =
await portfolioCalculator.getCurrentPositions(startDate);
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
let positions = currentPositions.positions.filter(({ quantity }) => {
return !quantity.eq(0);
});
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
return {
@ -1067,6 +1070,18 @@ export class PortfolioService {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
}
if (searchQuery) {
positions = positions.filter(({ symbol }) => {
const enhancedSymbolProfile = symbolProfileMap[symbol];
return (
enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) ||
enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) ||
enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery)
);
});
}
return {
hasErrors: currentPositions.hasErrors,
positions: positions.map((position) => {

View File

@ -163,6 +163,13 @@ export class UserService {
let currentPermissions = getPermissions(user.role);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions = without(
currentPermissions,
permissions.accessAssistant
);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);

View File

@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) {
if (aObject[key] === null || aObject[key] === null) {
if (aObject[key] === null || aObject[key] === undefined) {
return true;
} else if (isObject(aObject[key])) {
return hasNotDefinedValuesInObject(aObject[key]);

View File

@ -8,14 +8,17 @@ export class ApiService {
public buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterBySearchQuery?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? [];
return [
@ -31,6 +34,10 @@ export class ApiService {
type: 'ASSET_CLASS'
};
}),
{
id: searchQuery,
type: 'SEARCH_QUERY'
},
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,

View File

@ -283,7 +283,6 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', '');
symbol = `${DEFAULT_CURRENCY}${symbol}`;
}
return symbol;
@ -292,7 +291,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
/**
* Converts a symbol to a EOD symbol
*
* Currency: USDCHF -> CHF.FOREX
* Currency: USDCHF -> USDCHF.FOREX
*/
private convertToEodSymbol(aSymbol: string) {
if (
@ -304,9 +303,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
)
) {
return `${aSymbol
.replace('GBp', 'GBX')
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
let symbol = aSymbol;
symbol = symbol.replace('GBp', 'GBX');
return `${symbol}.FOREX`;
}
}

View File

@ -39,18 +39,22 @@ export class MarketDataService {
});
}
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
const aggregations = await this.prismaService.marketData.aggregate({
_max: {
public async getMax({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.marketData.findFirst({
select: {
date: true,
marketPrice: true
},
orderBy: [
{
marketPrice: 'desc'
}
],
where: {
dataSource,
symbol
}
});
return aggregations._max.marketPrice;
}
public async getRange({

View File

@ -104,40 +104,40 @@
"options": {
"commands": [
{
"command": "mkdir -p dist/apps/client"
"command": "shx mkdir -p dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets dist/apps/client"
"command": "shx cp -r apps/client/src/assets dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets/.well-known dist/apps/client"
"command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client"
},
{
"command": "cp apps/client/src/assets/favicon.ico dist/apps/client"
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
},
{
"command": "cp apps/client/src/assets/index.html dist/apps/client"
"command": "shx cp apps/client/src/assets/index.html dist/apps/client"
},
{
"command": "cp apps/client/src/assets/robots.txt dist/apps/client"
"command": "shx cp apps/client/src/assets/robots.txt dist/apps/client"
},
{
"command": "cp apps/client/src/assets/site.webmanifest dist/apps/client"
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/index.js dist/apps/client"
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
},
{
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
},
{
"command": "cp CHANGELOG.md dist/apps/client/assets"
"command": "shx cp CHANGELOG.md dist/apps/client/assets"
},
{
"command": "cp LICENSE dist/apps/client/assets"
"command": "shx cp LICENSE dist/apps/client/assets"
}
]
}

View File

@ -32,6 +32,7 @@
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[deviceType]="deviceType"
[hasTabs]="hasTabs"
[info]="info"
[pageTitle]="pageTitle"

View File

@ -112,6 +112,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === 'account' ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
this.currentRoute === 'portfolio' ||

View File

@ -1,6 +1,6 @@
import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import {
@ -35,6 +35,7 @@ export function NgxStripeFactory(): string {
}
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
imports: [
AppRoutingModule,
@ -72,6 +73,6 @@ export function NgxStripeFactory(): string {
useFactory: NgxStripeFactory
}
],
bootstrap: [AppComponent]
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}

View File

@ -1,15 +1,3 @@
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
Add Access
</a>
</div>
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>

View File

@ -19,7 +19,6 @@ import { Access } from '@ghostfolio/common/interfaces';
})
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
@Input() hasPermissionToCreateAccess = false;
@Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>();

View File

@ -1,3 +1,14 @@
<div class="d-flex justify-content-end">
<button
class="align-items-center d-flex"
mat-stroked-button
(click)="onTransferBalance()"
>
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
<ng-container i18n>Transfer Cash Balance</ng-container>...
</button>
</div>
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
<ng-container matColumnDef="status">
<th

View File

@ -34,6 +34,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
@Output() transferBalance = new EventEmitter<void>();
@ViewChild(MatSort) sort: MatSort;
@ -97,6 +98,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
alert(aComment);
}
public onTransferBalance() {
this.transferBalance.emit();
}
public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount);
}

View File

@ -6,6 +6,7 @@ import {
OnInit
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MatTableDataSource } from '@angular/material/table';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
export class AdminJobsComponent implements OnDestroy, OnInit {
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
public jobs: AdminJobs['jobs'] = [];
public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
new MatTableDataSource();
public displayedColumns = [
'index',
'type',
'symbol',
'dataSource',
'attempts',
'created',
'finished',
'status',
'actions'
];
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => {
this.jobs = jobs;
this.dataSource = new MatTableDataSource(jobs);
this.changeDetectorRef.markForCheck();
});

View File

@ -13,122 +13,158 @@
</mat-select>
</mat-form-field>
</form>
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
<th class="mat-header-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobsActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="index">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
#
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.id }}
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
Asset Profile
</ng-container>
<ng-container
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'"
i18n
>
Historical Market Data
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.data?.symbol }}
</td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.data?.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="attempts">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<ng-container i18n>Attempts</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.attemptsMade }}
</td>
</ng-container>
<ng-container matColumnDef="created">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Created</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.timestamp | date: defaultDateTimeFormat }}
</td>
</ng-container>
<ng-container matColumnDef="finished">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Finished</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.finishedOn | date: defaultDateTimeFormat }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Status</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ion-icon
*ngIf="element.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="element.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobsActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteJobs()">
<ng-container i18n>Delete Jobs</ng-container>
</button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteJobs()">
<ng-container i18n>Delete Jobs</ng-container>
</button>
</mat-menu>
</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let job of jobs">
<tr class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
<td class="mat-cell px-1 py-2">
<span class="align-items-center d-flex">
<ion-icon
class="mr-1"
name="arrow-down-circle-outline"
></ion-icon>
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
<span i18n>Asset Profile</span>
</ng-container>
<ng-container
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
>
<span i18n>Historical Market Data</span>
</ng-container>
</span>
</td>
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
<td class="mat-cell px-1 py-2 text-right">
{{ job.attemptsMade }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.timestamp | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.finishedOn | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
<ion-icon
*ngIf="job.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="job.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)">
<ng-container i18n>View Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)"
>
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button mat-menu-item (click)="onDeleteJob(job.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
</mat-menu>
</td>
</tr>
</ng-container>
</tbody>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(element.data)">
<ng-container i18n>View Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="element.stacktrace?.length <= 0"
(click)="onViewStacktrace(element.stacktrace)"
>
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button mat-menu-item (click)="onDeleteJob(element.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { AdminJobsComponent } from './admin-jobs.component';
@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatButtonModule,
MatMenuModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -177,7 +177,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ withRefresh }) => {
.subscribe(({ withRefresh } = { withRefresh: false }) => {
this.marketDataChanged.next(withRefresh);
});
}

View File

@ -342,7 +342,7 @@ export class AdminMarketDataComponent
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol }) => {
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })

View File

@ -2,11 +2,4 @@
:host {
display: block;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
}

View File

@ -11,13 +11,15 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { MarketData, SymbolProfile } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -43,12 +45,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: {
[code: string]: { name: string; value: number };
};
public historicalDataAsCsvString: string;
public isBenchmark = false;
public marketDataDetails: MarketData[] = [];
public sectors: {
[name: string]: { name: string; value: number };
};
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -67,6 +74,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public initialize() {
this.historicalDataAsCsvString =
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
this.adminService
.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
@ -135,6 +145,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(() => {});
}
public onImportHistoricalData() {
const marketData = csvToJson(this.historicalDataAsCsvString, {
dynamicTyping: true,
header: true,
skipEmptyLines: true
}).data;
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData: marketData.map(({ date, marketPrice }) => {
return { marketPrice, date: parseISO(date) };
})
},
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();
@ -146,9 +179,11 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
this.dataService.updateInfo();
this.isBenchmark = true;
this.changeDetectorRef.markForCheck();
});
}
@ -185,6 +220,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
this.dataService
.deleteBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.updateInfo();
this.isBenchmark = false;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -37,13 +37,6 @@
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="isBenchmark"
(click)="onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
>
<ng-container i18n>Set as Benchmark</ng-container>
</button>
</mat-menu>
</div>
@ -58,6 +51,36 @@
[symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
matInput
placeholder="e.g. 20230601;1.61"
type="text"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="historicalDataAsCsvString"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
@ -151,6 +174,17 @@
</ng-template>
</ng-container>
</div>
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox
color="primary"
i18n
[checked]="isBenchmark"
(change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
>Benchmark</mat-checkbox
>
</div>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol Mapping</mat-label>

View File

@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfPortfolioProportionChartModule,
GfValueModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatInputModule,
MatMenuModule,

View File

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { environment } from '@ghostfolio/client/../environments/environment';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public transactionCount: number;
public userCount: number;
public user: User;
public version: string;
private unsubscribeSubject = new Subject<void>();
@ -202,15 +204,18 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.exchangeRates = exchangeRates;
this.transactionCount = transactionCount;
this.userCount = userCount;
.subscribe(
({ exchangeRates, settings, transactionCount, userCount, version }) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.exchangeRates = exchangeRates;
this.transactionCount = transactionCount;
this.userCount = userCount;
this.version = version;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
);
}
private generateCouponCode(aLength: number) {

View File

@ -3,12 +3,18 @@
<div class="col">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="d-flex my-3">
<div class="w-50" i18n>Version</div>
<div class="w-50">
<gf-value [value]="version" />
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>User Count</div>
<div class="w-50">
<gf-value
precision="0"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userCount"
></gf-value>
</div>
@ -17,8 +23,8 @@
<div class="w-50" i18n>Activity Count</div>
<div class="w-50">
<gf-value
precision="0"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="transactionCount"
></gf-value>
<div *ngIf="transactionCount && userCount">

View File

@ -13,6 +13,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Platform } from '@prisma/client';
import { get } from 'lodash';
@ -40,6 +41,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
@ -119,6 +121,8 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -13,6 +13,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Tag } from '@prisma/client';
import { get } from 'lodash';
@ -40,6 +41,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
@ -114,10 +116,13 @@ export class AdminTagComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -110,6 +110,31 @@
>About</a
>
</li>
<li *ngIf="hasPermissionToAccessAssistant" class="list-inline-item">
<button
#assistantTrigger="matMenuTrigger"
class="h-100 no-min-width px-2"
mat-button
[mat-menu-trigger-for]="assistantMenu"
[matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()"
>
<ion-icon name="search-outline"></ion-icon>
</button>
<mat-menu
#assistantMenu="matMenu"
class="assistant"
xPosition="before"
[overlapTrigger]="true"
(closed)="assistantElement?.setIsOpen(false)"
>
<gf-assistant
#assistant
[deviceType]="deviceType"
(closed)="closeAssistant()"
/>
</mat-menu>
</li>
<li class="list-inline-item">
<button
class="no-min-width px-1"
@ -272,7 +297,7 @@
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === routeFeatuers
'text-decoration-underline': currentRoute === routeFeatures
}"
[routerLink]="routerLinkFeatures"
>Features</a

View File

@ -22,7 +22,7 @@
}
.mdc-button {
height: unset;
height: 100%;
&:not(.mat-primary) {
background-color: transparent;

View File

@ -2,11 +2,14 @@ import {
ChangeDetectionStrategy,
Component,
EventEmitter,
HostListener,
Input,
OnChanges,
Output
Output,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuTrigger } from '@angular/material/menu';
import { Router } from '@angular/router';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -18,6 +21,7 @@ import {
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnChanges {
@HostListener('window:keydown', ['$event'])
openAssistantWithHotKey(event: KeyboardEvent) {
if (
event.key === '/' &&
event.target instanceof Element &&
event.target?.nodeName?.toLowerCase() !== 'input' &&
event.target?.nodeName?.toLowerCase() !== 'textarea' &&
this.hasPermissionToAccessAssistant
) {
this.assistantElement.setIsOpen(true);
this.assistentMenuTriggerElement.openMenu();
event.preventDefault();
}
}
@Input() currentRoute: string;
@Input() deviceType: string;
@Input() hasTabs: boolean;
@Input() info: InfoItem;
@Input() pageTitle: string;
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
@Output() signOut = new EventEmitter<void>();
@ViewChild('assistant') assistantElement: AssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessAssistant: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateUser: boolean;
public impersonationId: string;
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
permissions.accessAdminControl
);
this.hasPermissionToAccessAssistant = hasPermission(
this.user?.permissions,
permissions.accessAssistant
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
);
}
public closeAssistant() {
this.assistentMenuTriggerElement?.closeMenu();
}
public impersonateAccount(aId: string) {
if (aId) {
this.impersonationStorageService.setId(aId);
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
this.isMenuOpen = true;
}
public onOpenAssistant() {
this.assistantElement.initialize();
}
public onSignOut() {
this.signOut.next();
}

View File

@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfAssistantModule } from '@ghostfolio/ui/assistant';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { HeaderComponent } from './header.component';
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
exports: [HeaderComponent],
imports: [
CommonModule,
GfAssistantModule,
GfLogoModule,
LoginWithAccessTokenDialogModule,
MatButtonModule,

View File

@ -1,6 +1,6 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
<div class="mb-5 row">
<div *ngIf="hasPermissionToAccessFearAndGreedIndex" class="mb-5 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small>
@ -8,15 +8,15 @@
<gf-line-chart
class="mb-3"
symbol="Fear & Greed Index"
yMax="100"
yMin="0"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="user?.settings?.locale"
[showXAxis]="true"
[showYAxis]="true"
[yMax]="100"
[yMaxLabel]="greedLabel"
[yMin]="0"
[yMinLabel]="fearLabel"
></gf-line-chart>
<gf-fear-and-greed-index

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -8,6 +8,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
@NgModule({
declarations: [PortfolioPerformanceComponent],
exports: [PortfolioPerformanceComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule]
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioPerformanceModule {}

View File

@ -213,11 +213,11 @@
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="user?.settings?.locale"
[locale]="data.locale"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
@ -225,11 +225,11 @@
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="user?.settings?.locale"
[locale]="data.locale"
[maxItems]="10"
[positions]="countries"
></gf-portfolio-proportion-chart>

View File

@ -4,7 +4,9 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { Subject } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@ -17,19 +19,36 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-access-dialog.html'
})
export class CreateOrUpdateAccessDialog implements OnDestroy {
public accessForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
private formBuilder: FormBuilder
) {}
ngOnInit() {}
ngOnInit() {
this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias],
type: [this.data.access.type, Validators.required]
});
}
public onCancel() {
this.dialogRef.close();
}
public onSubmit() {
const access: CreateAccessDto = {
alias: this.accessForm.controls['alias'].value,
type: this.accessForm.controls['type'].value
};
this.dialogRef.close({ access });
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -1,33 +1,38 @@
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
<form
class="d-flex flex-column h-100"
[formGroup]="accessForm"
(keyup.enter)="accessForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Grant access</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Alias</mat-label>
<input
formControlName="alias"
matInput
name="alias"
type="text"
[(ngModel)]="data.access.alias"
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.access.type">
<mat-select formControlName="type">
<mat-option i18n value="PUBLIC">Public</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addAccessForm.form.valid"
[mat-dialog-close]="data"
type="submit"
[disabled]="!accessForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>

View File

@ -10,8 +10,18 @@
</h1>
<gf-access-table
[accesses]="accesses"
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
[showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)"
></gf-access-table>
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -7,6 +7,7 @@ import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
import { UserAccountAccessComponent } from './user-account-access.component';
import { MatButtonModule } from '@angular/material/button';
@NgModule({
declarations: [UserAccountAccessComponent],
@ -16,6 +17,7 @@ import { UserAccountAccessComponent } from './user-account-access.component';
GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule,
GfPremiumIndicatorModule,
MatButtonModule,
MatDialogModule,
RouterModule
]

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.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 { 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 { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component';
@Component({
host: { class: 'page' },
@ -67,6 +69,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
} else if (params['transferBalanceDialog']) {
this.openTransferBalanceDialog();
}
});
}
@ -144,6 +148,12 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
});
}
public onTransferBalance() {
this.router.navigate([], {
queryParams: { transferBalanceDialog: true }
});
}
public onUpdateAccount(aAccount: AccountModel) {
this.router.navigate([], {
queryParams: { accountId: aAccount.id, editDialog: true }
@ -267,4 +277,37 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
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 });
});
}
}

View File

@ -14,6 +14,7 @@
[transactionCount]="transactionCount"
(accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)"
(transferBalance)="onTransferBalance()"
></gf-accounts-table>
</div>
</div>

View File

@ -8,6 +8,7 @@ import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-ta
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
import { AccountsPageComponent } from './accounts-page.component';
import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module';
import { GfTransferBalanceDialogModule } from './transfer-balance/transfer-balance-dialog.module';
@NgModule({
declarations: [AccountsPageComponent],
@ -17,6 +18,7 @@ import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-
GfAccountDetailDialogModule,
GfAccountsTableModule,
GfCreateOrUpdateAccountDialogModule,
GfTransferBalanceDialogModule,
MatButtonModule,
RouterModule
],

View File

@ -4,11 +4,4 @@
.accounts {
overflow-x: auto;
}
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
}

View File

@ -4,9 +4,20 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormGroup,
ValidatorFn,
Validators
} from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
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';
@ -18,30 +29,114 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-account-dialog.html'
})
export class CreateOrUpdateAccountDialog implements OnDestroy {
public accountForm: FormGroup;
public currencies: string[] = [];
public platforms: { id: string; name: string }[];
public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[];
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateAccountDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams
private formBuilder: FormBuilder
) {}
ngOnInit() {
public ngOnInit() {
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;
this.platforms = platforms;
this.accountForm = this.formBuilder.group({
accountId: [{ disabled: true, value: this.data.account.id }],
balance: [this.data.account.balance, Validators.required],
comment: [this.data.account.comment],
currency: [this.data.account.currency, Validators.required],
isExcluded: [this.data.account.isExcluded],
name: [this.data.account.name, Validators.required],
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() {
this.dialogRef.close();
}
public onSubmit() {
const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.controls['balance'].value,
comment: this.accountForm.controls['comment'].value,
currency: this.accountForm.controls['currency'].value,
id: this.accountForm.controls['accountId'].value,
isExcluded: this.accountForm.controls['isExcluded'].value,
name: this.accountForm.controls['name'].value,
platformId: this.accountForm.controls['platformId'].value?.id ?? null
};
if (this.data.account.id) {
(account as UpdateAccountDto).id = this.data.account.id;
} else {
delete (account as CreateAccountDto).id;
}
this.dialogRef.close({ account });
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
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);
});
}
}

View File

@ -1,17 +1,26 @@
<form #addAccountForm="ngForm" class="d-flex flex-column h-100">
<form
class="d-flex flex-column h-100"
[formGroup]="accountForm"
(keyup.enter)="accountForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.account.id" i18n mat-dialog-title>Update account</h1>
<h1 *ngIf="!data.account.id" i18n mat-dialog-title>Add account</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.account.name" />
<input
formControlName="name"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select name="currency" required [(value)]="data.account.currency">
<mat-select formControlName="currency">
<mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option
>
@ -22,24 +31,42 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Cash Balance</mat-label>
<input
formControlName="balance"
matInput
name="balance"
required
type="number"
[(ngModel)]="data.account.balance"
(keydown.enter)="$event.stopPropagation()"
/>
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
<span class="ml-2" matTextSuffix
>{{ accountForm.controls['currency'].value }}</span
>
</mat-form-field>
</div>
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>
<mat-select name="platformId" [(value)]="data.account.platformId">
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let platform of platforms" [value]="platform.id"
>{{ platform.name }}</mat-option
<input
formControlName="platformId"
matInput
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>
</div>
<div>
@ -48,40 +75,31 @@
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
formControlName="comment"
matInput
name="comment"
[(ngModel)]="data.account.comment"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="mb-3 px-2">
<mat-checkbox
color="primary"
name="isExcluded"
[(ngModel)]="data.account.isExcluded"
<mat-checkbox color="primary" formControlName="isExcluded"
>Exclude from Analysis</mat-checkbox
>
</div>
<div *ngIf="data.account.id">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account ID</mat-label>
<input
disabled
matInput
name="accountId"
[(ngModel)]="data.account.id"
/>
<input formControlName="accountId" matInput />
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addAccountForm.form.valid"
[mat-dialog-close]="data"
type="submit"
[disabled]="!accountForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>

View File

@ -7,6 +7,8 @@ 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 { MatAutocompleteModule } from '@angular/material/autocomplete';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
@ -15,6 +17,8 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
imports: [
CommonModule,
FormsModule,
GfSymbolIconModule,
MatAutocompleteModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,

View File

@ -0,0 +1,5 @@
import { Account } from '@prisma/client';
export interface TransferBalanceDialogParams {
accounts: Account[];
}

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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 {}

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-mdc-dialog-content {
max-height: unset;
}
}

View File

@ -1,8 +1,6 @@
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 { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -15,7 +13,6 @@ import { takeUntil } from 'rxjs/operators';
})
export class HomePageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public tabs: TabConfiguration[] = [];
public user: User;
@ -23,17 +20,9 @@ export class HomePageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private userService: UserService
) {
const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
globalPermissions,
permissions.enableFearAndGreedIndex
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -57,8 +46,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
{
iconName: 'newspaper-outline',
label: $localize`Markets`,
path: ['/home', 'market'],
showCondition: this.hasPermissionToAccessFearAndGreedIndex
path: ['/home', 'market']
}
];
this.user = state.user;

View File

@ -320,31 +320,36 @@
<div class="row my-5">
<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
</h2>
</div>
<div *ngFor="let testimonial of testimonials" class="col-md-6">
<div class="d-flex flex-row py-3">
<div class="d-flex justify-content-center">
<gf-logo
class="mr-3 mt-2 pt-1"
size="medium"
[showLabel]="false"
></gf-logo>
</div>
<div>
<div>{{ testimonial.quote }}</div>
<div class="mt-2 text-muted">
<a *ngIf="testimonial.url" target="_blank" [href]="testimonial.url"
>{{ testimonial.author }}</a
>
<span *ngIf="!testimonial.url">{{ testimonial.author }}</span>, {{
testimonial.country }}
<div class="col-md-8 offset-md-2">
<gf-carousel [aria-label]="'Testimonials'">
<div *ngFor="let testimonial of testimonials" gf-carousel-item>
<div class="d-flex px-3">
<gf-logo
class="mr-3 mt-2 pt-1"
size="medium"
[showLabel]="false"
></gf-logo>
<div>
<div>{{ testimonial.quote }}</div>
<div class="mt-2 text-muted">
<a
*ngIf="testimonial.url"
target="_blank"
[href]="testimonial.url"
>{{ testimonial.author }}</a
>
<span *ngIf="!testimonial.url">{{ testimonial.author }}</span>,
{{ testimonial.country }}
</div>
</div>
</div>
</div>
</div>
</gf-carousel>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
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 { GfValueModule } from '@ghostfolio/ui/value';
@ -14,6 +15,7 @@ import { LandingPageComponent } from './landing-page.component';
declarations: [LandingPageComponent],
imports: [
CommonModule,
GfCarouselModule,
GfLogoModule,
GfValueModule,
GfWorldMapChartModule,

View File

@ -1,10 +1,3 @@
:host {
display: block;
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
}

View File

@ -8,7 +8,7 @@
<div class="flex-grow-1" mat-dialog-content>
<mat-stepper
#stepper
[animationDuration]="0"
animationDuration="0"
[linear]="true"
[orientation]="stepperOrientation"
[selectedIndex]="importStep"

View File

@ -131,9 +131,9 @@
<div class="row">
<div class="col-lg">
<gf-holdings-table
pageSize="7"
[deviceType]="deviceType"
[hasPermissionToShowValues]="false"
[pageSize]="7"
[positions]="positionsArray"
></gf-holdings-table>
</div>

View File

@ -30,7 +30,7 @@ export class UserAccountPageComponent implements OnDestroy, OnInit {
this.tabs = [
{
iconName: 'cog-outline',
iconName: 'settings-outline',
label: $localize`Settings`,
path: ['/account']
},

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatTabsModule } from '@angular/material/tabs';
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';
@ -17,6 +17,7 @@ import { UserAccountPageComponent } from './user-account-page.component';
GfUserAccountSettingsModule,
MatTabsModule,
UserAccountPageRoutingModule
]
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class UserAccountPageModule {}

View File

@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
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 { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-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) {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
}

View File

@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.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 { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
@ -36,7 +37,6 @@ import {
} from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import { AccountWithValue, DateRange, GroupBy } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { cloneDeep, groupBy, isNumber } from 'lodash';
@ -58,6 +58,7 @@ export class DataService {
ASSET_CLASS: filtersByAssetClass,
ASSET_SUB_CLASS: filtersByAssetSubClass,
PRESET_ID: filtersByPresetId,
SEARCH_QUERY: filtersBySearchQuery,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
@ -100,6 +101,10 @@ export class DataService {
params = params.append('presetId', filtersByPresetId[0].id);
}
if (filtersBySearchQuery) {
params = params.append('query', filtersBySearchQuery[0].id);
}
if (filtersByTag) {
params = params.append(
'tags',
@ -204,6 +209,10 @@ export class DataService {
return this.http.delete<any>(`/api/v1/order/`);
}
public deleteBenchmark({ dataSource, symbol }: UniqueAsset) {
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
}
public deleteOrder(aId: string) {
return this.http.delete<any>(`/api/v1/order/${aId}`);
}
@ -496,4 +505,31 @@ export class DataService {
couponCode
});
}
public transferAccountBalance({
accountIdFrom,
accountIdTo,
balance
}: TransferBalanceDto) {
return this.http.post('/api/v1/account/transfer-balance', {
accountIdFrom,
accountIdTo,
balance
});
}
public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = <'ios' | 'trusted-web-activity'>(
window.localStorage.getItem('utm_source')
);
info.globalPermissions = filterGlobalPermissions(
info.globalPermissions,
utmSource
);
(window as any).info = info;
});
}
}

View File

@ -15,6 +15,7 @@ import { catchError } from 'rxjs/operators';
})
export class ImportActivitiesService {
private static ACCOUNT_KEYS = ['account', 'accountid'];
private static COMMENT_KEYS = ['comment', 'note'];
private static CURRENCY_KEYS = ['ccy', 'currency', 'currencyprimary'];
private static DATA_SOURCE_KEYS = ['datasource'];
private static DATE_KEYS = ['date', 'tradedate'];
@ -52,6 +53,7 @@ export class ImportActivitiesService {
for (const [index, item] of content.entries()) {
activities.push({
accountId: this.parseAccount({ item, userAccounts }),
comment: this.parseComment({ item }),
currency: this.parseCurrency({ content, index, item }),
dataSource: this.parseDataSource({ item }),
date: this.parseDate({ content, index, item }),
@ -122,6 +124,7 @@ export class ImportActivitiesService {
private convertToCreateOrderDto({
accountId,
comment,
date,
fee,
quantity,
@ -132,6 +135,7 @@ export class ImportActivitiesService {
}: Activity): CreateOrderDto {
return {
accountId,
comment,
fee,
quantity,
type,
@ -174,6 +178,18 @@ export class ImportActivitiesService {
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({
content,
index,
@ -321,6 +337,10 @@ export class ImportActivitiesService {
return Type.BUY;
case 'dividend':
return Type.DIVIDEND;
case 'fee':
return Type.FEE;
case 'interest':
return Type.INTEREST;
case 'item':
return Type.ITEM;
case 'liability':

View File

@ -1,11 +1,6 @@
{
"createdAt": "2023-09-25T08:15:38.055Z",
"createdAt": "2023-10-05T00:00:00.000Z",
"data": [
{
"name": "Appsmith",
"description": "Build build custom software on top of your data.",
"href": "https://www.appsmith.com"
},
{
"name": "BoxyHQ",
"description": "BoxyHQs 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.",
"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",
"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

View File

@ -214,6 +214,16 @@ body {
}
}
.mat-mdc-menu-panel {
&.assistant {
max-width: unset !important;
.mat-mdc-menu-content {
padding: 0;
}
}
}
&.is-dark-theme {
background: var(--dark-background);
color: rgba(var(--light-primary-text));
@ -481,6 +491,13 @@ ngx-skeleton-loader {
flex-direction: column;
overflow-y: auto;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
&:not(.has-tabs) {
@media (min-width: 576px) {
padding: 2rem 0;
@ -492,6 +509,12 @@ ngx-skeleton-loader {
padding-bottom: env(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-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;

View File

@ -7,7 +7,7 @@ services:
env_file:
- ../.env
ports:
- 5432:5432
- ${POSTGRES_PORT:-5432}:5432
volumes:
- postgres:/var/lib/postgresql/data
redis:
@ -15,7 +15,7 @@ services:
container_name: redis
restart: unless-stopped
ports:
- 6379:6379
- ${REDIS_PORT:-6379}:6379
volumes:
postgres:

View File

@ -12,4 +12,5 @@ export interface AdminData {
lastActivity: Date;
transactionCount: number;
}[];
version: string;
}

View File

@ -5,6 +5,7 @@ export interface Benchmark {
name: EnhancedSymbolProfile['name'];
performances: {
allTimeHigh: {
date: Date;
performancePercent: number;
};
};

View File

@ -15,6 +15,7 @@ export interface EnhancedSymbolProfile {
dataSource: DataSource;
dateOfFirstActivity?: Date;
id: string;
isin: string | null;
name: string | null;
scraperConfiguration?: ScraperConfiguration | null;
sectors: Sector[];

View File

@ -6,6 +6,7 @@ export interface Filter {
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'PRESET_ID'
| 'SEARCH_QUERY'
| 'SYMBOL'
| 'TAG';
}

View File

@ -1,5 +1,5 @@
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 { Subscription } from './subscription.interface';
@ -13,7 +13,7 @@ export interface InfoItem {
fearAndGreedDataSource?: string;
globalPermissions: string[];
isReadOnlyMode?: boolean;
platforms: { id: string; name: string }[];
platforms: Platform[];
statistics: Statistics;
stripePublicKey?: string;
subscriptions: { [offer in SubscriptionOffer]: Subscription };

View File

@ -3,6 +3,7 @@ import { Role } from '@prisma/client';
export const permissions = {
accessAdminControl: 'accessAdminControl',
accessAssistant: 'accessAssistant',
createAccess: 'createAccess',
createAccount: 'createAccount',
createOrder: 'createOrder',
@ -41,6 +42,7 @@ export function getPermissions(aRole: Role): string[] {
case 'ADMIN':
return [
permissions.accessAdminControl,
permissions.accessAssistant,
permissions.createAccess,
permissions.createAccount,
permissions.createOrder,
@ -63,10 +65,11 @@ export function getPermissions(aRole: Role): string[] {
];
case 'DEMO':
return [permissions.createUserAccount];
return [permissions.accessAssistant, permissions.createUserAccount];
case 'USER':
return [
permissions.accessAssistant,
permissions.createAccess,
permissions.createAccount,
permissions.createOrder,

View File

@ -0,0 +1,52 @@
import { FocusableOption } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostBinding,
Input,
Output,
ViewChild
} from '@angular/core';
import { Position } from '@ghostfolio/common/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-assistant-list-item',
templateUrl: './assistant-list-item.html',
styleUrls: ['./assistant-list-item.scss']
})
export class AssistantListItemComponent implements FocusableOption {
@HostBinding('attr.tabindex') tabindex = -1;
@HostBinding('class.has-focus') get getHasFocus() {
return this.hasFocus;
}
@Input() holding: Position;
@Output() clicked = new EventEmitter<void>();
@ViewChild('link') public linkElement: ElementRef;
public hasFocus = false;
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
public focus() {
this.hasFocus = true;
this.changeDetectorRef.markForCheck();
}
public onClick() {
this.clicked.emit();
}
public removeFocus() {
this.hasFocus = false;
this.changeDetectorRef.markForCheck();
}
}

View File

@ -0,0 +1,12 @@
<a
#link
class="d-block px-2 py-1 text-truncate"
[queryParams]="{
dataSource: holding?.dataSource,
positionDetailDialog: true,
symbol: holding?.symbol
}"
[routerLink]="['/portfolio', 'holdings']"
(click)="onClick()"
>{{ holding?.name }}</a
>

View File

@ -0,0 +1,12 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AssistantListItemComponent } from './assistant-list-item.component';
@NgModule({
declarations: [AssistantListItemComponent],
exports: [AssistantListItemComponent],
imports: [CommonModule, RouterModule]
})
export class GfAssistantListItemModule {}

View File

@ -0,0 +1,19 @@
:host {
display: block;
&.has-focus {
background-color: rgba(var(--palette-primary-500), 1);
a {
color: rgba(var(--light-primary-text));
}
}
}
:host-context(.is-dark-theme) {
&.has-focus {
a {
color: rgba(var(--dark-primary-text));
}
}
}

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