Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
00a2b60eb5 | |||
fcbf2f1645 | |||
460266a501 | |||
9fe90273c7 | |||
4078229fe6 | |||
609c03f174 | |||
e7d4641d13 | |||
cc1d9811e0 | |||
35450ac004 | |||
9c18f48a32 | |||
87529490c3 | |||
893e76f83f | |||
06ba7a4b1b | |||
c68d113d27 | |||
69e3bee52c | |||
cea569c987 | |||
2a38a16f6b | |||
0f9455cf02 | |||
d4afa03505 | |||
c9237146e2 | |||
faad65b6f3 | |||
e459c72100 | |||
a8add30125 | |||
b535aee91d | |||
4434d0315f | |||
8b10695353 | |||
e82dcc8ace |
47
CHANGELOG.md
47
CHANGELOG.md
@ -5,6 +5,53 @@ 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.49.0 - 2024-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added a button to apply the active filters in the assistant
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the assistant from experimental to general availability
|
||||
- Improved the usability by reloading the content with a logo click on the home page
|
||||
- Upgraded `yahoo-finance2` from version `2.9.0` to `2.9.1`
|
||||
|
||||
## 2.48.1 - 2024-02-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added the missing data provider information to the _CoinGecko_ service
|
||||
|
||||
## 2.48.0 - 2024-02-05
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the assistant by an asset class selector (experimental)
|
||||
- Added the data provider information to the search endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the account selector in the assistant (experimental)
|
||||
- Improved the usability of the tag selector in the assistant (experimental)
|
||||
- Improved the error logs for a timeout in the data provider services
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Upgraded `prettier` from version `3.2.4` to `3.2.5`
|
||||
|
||||
## 2.47.0 - 2024-02-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the tag selector to only show used tags in the assistant (experimental)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `prettier` from version `3.2.1` to `3.2.4`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a rendering issue caused by the date range selector in the assistant (experimental)
|
||||
- Fixed an issue with the currency conversion in the investment timeline
|
||||
- Fixed the export in the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
|
||||
## 2.46.0 - 2024-01-28
|
||||
|
||||
### Added
|
||||
|
@ -280,6 +280,10 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
|
||||
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
## Analytics
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
@ -10,6 +11,7 @@ import { ExportService } from './export.service';
|
||||
@Controller('export')
|
||||
export class ExportController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly exportService: ExportService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
@ -17,10 +19,20 @@ export class ExportController {
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async export(
|
||||
@Query('activityIds') activityIds?: string[]
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('activityIds') activityIds?: string[],
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Export> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
return this.exportService.export({
|
||||
activityIds,
|
||||
filters,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -12,6 +13,7 @@ import { ExportService } from './export.service';
|
||||
@Module({
|
||||
imports: [
|
||||
AccountModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -13,10 +13,12 @@ export class ExportService {
|
||||
|
||||
public async export({
|
||||
activityIds,
|
||||
filters,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activityIds?: string[];
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
@ -42,6 +44,7 @@ export class ExportService {
|
||||
);
|
||||
|
||||
let { activities } = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
includeDrafts: true,
|
||||
|
@ -64,16 +64,13 @@ export class ImportController {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
try {
|
||||
const activities = await this.importService.import({
|
||||
isDryRun,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
accountsDto: importData.accounts ?? [],
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
return { activities };
|
||||
|
@ -21,7 +21,8 @@ import {
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountWithPlatform,
|
||||
OrderWithAccount
|
||||
OrderWithAccount,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
@ -138,17 +139,16 @@ export class ImportService {
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
user
|
||||
}: {
|
||||
accountsDto: Partial<CreateAccountDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<Activity[]> {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
const userCurrency = user.Settings.settings.baseCurrency;
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||
@ -171,7 +171,7 @@ export class ImportService {
|
||||
);
|
||||
|
||||
// If there is no account or if the account belongs to a different user then create a new account
|
||||
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
|
||||
let oldAccountId: string;
|
||||
const platformId = account.platformId;
|
||||
|
||||
@ -184,7 +184,7 @@ export class ImportService {
|
||||
|
||||
let accountObject: Prisma.AccountCreateInput = {
|
||||
...account,
|
||||
User: { connect: { id: userId } }
|
||||
User: { connect: { id: user.id } }
|
||||
};
|
||||
|
||||
if (
|
||||
@ -200,7 +200,7 @@ export class ImportService {
|
||||
|
||||
const newAccount = await this.accountService.createAccount(
|
||||
accountObject,
|
||||
userId
|
||||
user.id
|
||||
);
|
||||
|
||||
// Store the new to old account ID mappings for updating activities
|
||||
@ -231,16 +231,17 @@ export class ImportService {
|
||||
|
||||
const assetProfiles = await this.validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport
|
||||
maxActivitiesToImport,
|
||||
user
|
||||
});
|
||||
|
||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||
activitiesDto,
|
||||
userCurrency,
|
||||
userId
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||
const accounts = (await this.accountService.getAccounts(user.id)).map(
|
||||
({ id, name }) => {
|
||||
return { id, name };
|
||||
}
|
||||
@ -345,7 +346,6 @@ export class ImportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccount?.id,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
@ -374,7 +374,8 @@ export class ImportService {
|
||||
},
|
||||
Account: validatedAccount,
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
userId: user.id
|
||||
};
|
||||
} else {
|
||||
if (error) {
|
||||
@ -388,7 +389,6 @@ export class ImportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccount?.id,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
@ -406,7 +406,8 @@ export class ImportService {
|
||||
}
|
||||
},
|
||||
updateAccountBalance: false,
|
||||
User: { connect: { id: userId } }
|
||||
User: { connect: { id: user.id } },
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
|
||||
@ -553,10 +554,12 @@ export class ImportService {
|
||||
|
||||
private async validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport
|
||||
maxActivitiesToImport,
|
||||
user
|
||||
}: {
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
user: UserWithSettings;
|
||||
}) {
|
||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
@ -583,6 +586,21 @@ export class ImportService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
user.subscription.type === 'Basic'
|
||||
) {
|
||||
const dataProvider = this.dataProviderService.getDataProvider(
|
||||
DataSource[dataSource]
|
||||
);
|
||||
|
||||
if (dataProvider.getDataProviderInfo().isPremium) {
|
||||
throw new Error(
|
||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const assetProfile = {
|
||||
currency,
|
||||
...(
|
||||
|
@ -1,8 +0,0 @@
|
||||
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TimelineInfoInterface {
|
||||
maxNetPerformance: Big;
|
||||
minNetPerformance: Big;
|
||||
timelinePeriods: TimelinePeriod[];
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TimelinePeriod {
|
||||
date: string;
|
||||
grossPerformance: Big;
|
||||
investment: Big;
|
||||
netPerformance: Big;
|
||||
value: Big;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
export type Accuracy = 'day' | 'month' | 'year';
|
||||
|
||||
export interface TimelineSpecification {
|
||||
accuracy: Accuracy;
|
||||
start: string;
|
||||
}
|
@ -68,14 +68,20 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-22')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-22')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -135,7 +141,8 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: new Big('12.6') }
|
||||
{ date: '2021-11-01', investment: 0 },
|
||||
{ date: '2021-12-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -57,14 +57,20 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-30')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-30')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -123,7 +129,8 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: new Big('273.2') }
|
||||
{ date: '2021-11-01', investment: 273.2 },
|
||||
{ date: '2021-12-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -81,14 +81,20 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2015-01-01')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2015-01-01')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -155,42 +161,43 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2015-02-01', investment: new Big('0') },
|
||||
{ date: '2015-03-01', investment: new Big('0') },
|
||||
{ date: '2015-04-01', investment: new Big('0') },
|
||||
{ date: '2015-05-01', investment: new Big('0') },
|
||||
{ date: '2015-06-01', investment: new Big('0') },
|
||||
{ date: '2015-07-01', investment: new Big('0') },
|
||||
{ date: '2015-08-01', investment: new Big('0') },
|
||||
{ date: '2015-09-01', investment: new Big('0') },
|
||||
{ date: '2015-10-01', investment: new Big('0') },
|
||||
{ date: '2015-11-01', investment: new Big('0') },
|
||||
{ date: '2015-12-01', investment: new Big('0') },
|
||||
{ date: '2016-01-01', investment: new Big('0') },
|
||||
{ date: '2016-02-01', investment: new Big('0') },
|
||||
{ date: '2016-03-01', investment: new Big('0') },
|
||||
{ date: '2016-04-01', investment: new Big('0') },
|
||||
{ date: '2016-05-01', investment: new Big('0') },
|
||||
{ date: '2016-06-01', investment: new Big('0') },
|
||||
{ date: '2016-07-01', investment: new Big('0') },
|
||||
{ date: '2016-08-01', investment: new Big('0') },
|
||||
{ date: '2016-09-01', investment: new Big('0') },
|
||||
{ date: '2016-10-01', investment: new Big('0') },
|
||||
{ date: '2016-11-01', investment: new Big('0') },
|
||||
{ date: '2016-12-01', investment: new Big('0') },
|
||||
{ date: '2017-01-01', investment: new Big('0') },
|
||||
{ date: '2017-02-01', investment: new Big('0') },
|
||||
{ date: '2017-03-01', investment: new Big('0') },
|
||||
{ date: '2017-04-01', investment: new Big('0') },
|
||||
{ date: '2017-05-01', investment: new Big('0') },
|
||||
{ date: '2017-06-01', investment: new Big('0') },
|
||||
{ date: '2017-07-01', investment: new Big('0') },
|
||||
{ date: '2017-08-01', investment: new Big('0') },
|
||||
{ date: '2017-09-01', investment: new Big('0') },
|
||||
{ date: '2017-10-01', investment: new Big('0') },
|
||||
{ date: '2017-11-01', investment: new Big('0') },
|
||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||
{ date: '2015-01-01', investment: 637.0853345999999 },
|
||||
{ date: '2015-02-01', investment: 0 },
|
||||
{ date: '2015-03-01', investment: 0 },
|
||||
{ date: '2015-04-01', investment: 0 },
|
||||
{ date: '2015-05-01', investment: 0 },
|
||||
{ date: '2015-06-01', investment: 0 },
|
||||
{ date: '2015-07-01', investment: 0 },
|
||||
{ date: '2015-08-01', investment: 0 },
|
||||
{ date: '2015-09-01', investment: 0 },
|
||||
{ date: '2015-10-01', investment: 0 },
|
||||
{ date: '2015-11-01', investment: 0 },
|
||||
{ date: '2015-12-01', investment: 0 },
|
||||
{ date: '2016-01-01', investment: 0 },
|
||||
{ date: '2016-02-01', investment: 0 },
|
||||
{ date: '2016-03-01', investment: 0 },
|
||||
{ date: '2016-04-01', investment: 0 },
|
||||
{ date: '2016-05-01', investment: 0 },
|
||||
{ date: '2016-06-01', investment: 0 },
|
||||
{ date: '2016-07-01', investment: 0 },
|
||||
{ date: '2016-08-01', investment: 0 },
|
||||
{ date: '2016-09-01', investment: 0 },
|
||||
{ date: '2016-10-01', investment: 0 },
|
||||
{ date: '2016-11-01', investment: 0 },
|
||||
{ date: '2016-12-01', investment: 0 },
|
||||
{ date: '2017-01-01', investment: 0 },
|
||||
{ date: '2017-02-01', investment: 0 },
|
||||
{ date: '2017-03-01', investment: 0 },
|
||||
{ date: '2017-04-01', investment: 0 },
|
||||
{ date: '2017-05-01', investment: 0 },
|
||||
{ date: '2017-06-01', investment: 0 },
|
||||
{ date: '2017-07-01', investment: 0 },
|
||||
{ date: '2017-08-01', investment: 0 },
|
||||
{ date: '2017-09-01', investment: 0 },
|
||||
{ date: '2017-10-01', investment: 0 },
|
||||
{ date: '2017-11-01', investment: 0 },
|
||||
{ date: '2017-12-01', investment: -318.54266729999995 },
|
||||
{ date: '2018-01-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -70,14 +70,20 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2023-01-03')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2023-01-03')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -137,7 +143,31 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2023-01-01', investment: new Big('89.12') }
|
||||
{ date: '2023-01-01', investment: 82.329056 },
|
||||
{
|
||||
date: '2023-02-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-03-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-04-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-05-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-06-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-07-01',
|
||||
investment: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -45,14 +45,20 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: new Date()
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
new Date()
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -68,14 +68,20 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2022-03-07')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -137,8 +143,8 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||
{ date: '2022-04-01', investment: new Big('-85.73') }
|
||||
{ date: '2022-03-01', investment: 151.6 },
|
||||
{ date: '2022-04-01', investment: -75.8 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -68,9 +68,9 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2022-03-07')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2022-03-07')
|
||||
@ -78,13 +78,16 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(chartData[0]).toEqual({
|
||||
date: '2022-03-07',
|
||||
investmentValueWithCurrencyEffect: 151.6,
|
||||
netPerformance: 0,
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||
@ -97,6 +100,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
expect(chartData[chartData.length - 1]).toEqual({
|
||||
date: '2022-04-11',
|
||||
investmentValueWithCurrencyEffect: 0,
|
||||
netPerformance: 19.86,
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
|
||||
@ -163,8 +167,8 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||
{ date: '2022-04-01', investment: new Big('-171.46') }
|
||||
{ date: '2022-03-01', investment: 151.6 },
|
||||
{ date: '2022-04-01', investment: -151.6 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
HistoricalDataItem,
|
||||
InvestmentItem,
|
||||
ResponseError,
|
||||
SymbolMetrics,
|
||||
TimelinePosition
|
||||
@ -15,41 +16,19 @@ import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
addMilliseconds,
|
||||
addMonths,
|
||||
addYears,
|
||||
differenceInDays,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
max,
|
||||
min,
|
||||
set,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import {
|
||||
cloneDeep,
|
||||
first,
|
||||
flatten,
|
||||
isNumber,
|
||||
last,
|
||||
sortBy,
|
||||
uniq
|
||||
} from 'lodash';
|
||||
import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||
import {
|
||||
Accuracy,
|
||||
TimelineSpecification
|
||||
} from './interfaces/timeline-specification.interface';
|
||||
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||
|
||||
@ -193,7 +172,15 @@ export class PortfolioCalculator {
|
||||
this.transactionPoints = transactionPoints;
|
||||
}
|
||||
|
||||
public async getChartData(start: Date, end = new Date(Date.now()), step = 1) {
|
||||
public async getChartData({
|
||||
end = new Date(Date.now()),
|
||||
start,
|
||||
step = 1
|
||||
}: {
|
||||
end?: Date;
|
||||
start: Date;
|
||||
step?: number;
|
||||
}): Promise<HistoricalDataItem[]> {
|
||||
const symbols: { [symbol: string]: boolean } = {};
|
||||
|
||||
const transactionPointsBeforeEndDate =
|
||||
@ -217,13 +204,15 @@ export class PortfolioCalculator {
|
||||
dates.push(resetHours(end));
|
||||
}
|
||||
|
||||
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
symbols[item.symbol] = true;
|
||||
if (transactionPointsBeforeEndDate.length > 0) {
|
||||
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
symbols[item.symbol] = true;
|
||||
}
|
||||
}
|
||||
|
||||
const { dataProviderInfos, values: marketSymbols } =
|
||||
@ -262,6 +251,7 @@ export class PortfolioCalculator {
|
||||
|
||||
const accumulatedValuesByDate: {
|
||||
[date: string]: {
|
||||
investmentValueWithCurrencyEffect: Big;
|
||||
totalCurrentValue: Big;
|
||||
totalCurrentValueWithCurrencyEffect: Big;
|
||||
totalInvestmentValue: Big;
|
||||
@ -277,7 +267,8 @@ export class PortfolioCalculator {
|
||||
[symbol: string]: {
|
||||
currentValues: { [date: string]: Big };
|
||||
currentValuesWithCurrencyEffect: { [date: string]: Big };
|
||||
investmentValues: { [date: string]: Big };
|
||||
investmentValuesAccumulated: { [date: string]: Big };
|
||||
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big };
|
||||
investmentValuesWithCurrencyEffect: { [date: string]: Big };
|
||||
netPerformanceValues: { [date: string]: Big };
|
||||
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big };
|
||||
@ -290,7 +281,8 @@ export class PortfolioCalculator {
|
||||
const {
|
||||
currentValues,
|
||||
currentValuesWithCurrencyEffect,
|
||||
investmentValues,
|
||||
investmentValuesAccumulated,
|
||||
investmentValuesAccumulatedWithCurrencyEffect,
|
||||
investmentValuesWithCurrencyEffect,
|
||||
netPerformanceValues,
|
||||
netPerformanceValuesWithCurrencyEffect,
|
||||
@ -310,7 +302,8 @@ export class PortfolioCalculator {
|
||||
valuesBySymbol[symbol] = {
|
||||
currentValues,
|
||||
currentValuesWithCurrencyEffect,
|
||||
investmentValues,
|
||||
investmentValuesAccumulated,
|
||||
investmentValuesAccumulatedWithCurrencyEffect,
|
||||
investmentValuesWithCurrencyEffect,
|
||||
netPerformanceValues,
|
||||
netPerformanceValuesWithCurrencyEffect,
|
||||
@ -332,8 +325,13 @@ export class PortfolioCalculator {
|
||||
symbolValues.currentValuesWithCurrencyEffect?.[dateString] ??
|
||||
new Big(0);
|
||||
|
||||
const investmentValue =
|
||||
symbolValues.investmentValues?.[dateString] ?? new Big(0);
|
||||
const investmentValueAccumulated =
|
||||
symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0);
|
||||
|
||||
const investmentValueAccumulatedWithCurrencyEffect =
|
||||
symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[
|
||||
dateString
|
||||
] ?? new Big(0);
|
||||
|
||||
const investmentValueWithCurrencyEffect =
|
||||
symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ??
|
||||
@ -355,6 +353,10 @@ export class PortfolioCalculator {
|
||||
] ?? new Big(0);
|
||||
|
||||
accumulatedValuesByDate[dateString] = {
|
||||
investmentValueWithCurrencyEffect: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.investmentValueWithCurrencyEffect ?? new Big(0)
|
||||
).add(investmentValueWithCurrencyEffect),
|
||||
totalCurrentValue: (
|
||||
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||
).add(currentValue),
|
||||
@ -365,11 +367,11 @@ export class PortfolioCalculator {
|
||||
totalInvestmentValue: (
|
||||
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
|
||||
new Big(0)
|
||||
).add(investmentValue),
|
||||
).add(investmentValueAccumulated),
|
||||
totalInvestmentValueWithCurrencyEffect: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
|
||||
).add(investmentValueWithCurrencyEffect),
|
||||
).add(investmentValueAccumulatedWithCurrencyEffect),
|
||||
totalNetPerformanceValue: (
|
||||
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
|
||||
new Big(0)
|
||||
@ -392,6 +394,7 @@ export class PortfolioCalculator {
|
||||
|
||||
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
|
||||
const {
|
||||
investmentValueWithCurrencyEffect,
|
||||
totalCurrentValue,
|
||||
totalCurrentValueWithCurrencyEffect,
|
||||
totalInvestmentValue,
|
||||
@ -421,6 +424,8 @@ export class PortfolioCalculator {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformanceInPercentageWithCurrencyEffect,
|
||||
investmentValueWithCurrencyEffect:
|
||||
investmentValueWithCurrencyEffect.toNumber(),
|
||||
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||
netPerformanceWithCurrencyEffect:
|
||||
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
|
||||
@ -685,196 +690,27 @@ export class PortfolioCalculator {
|
||||
});
|
||||
}
|
||||
|
||||
public getInvestmentsByGroup(
|
||||
groupBy: GroupBy
|
||||
): { date: string; investment: Big }[] {
|
||||
if (this.orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
public getInvestmentsByGroup({
|
||||
data,
|
||||
groupBy
|
||||
}: {
|
||||
data: HistoricalDataItem[];
|
||||
groupBy: GroupBy;
|
||||
}): InvestmentItem[] {
|
||||
const groupedData: { [dateGroup: string]: Big } = {};
|
||||
|
||||
const investments: { date: string; investment: Big }[] = [];
|
||||
let currentDate: Date;
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
for (const [index, order] of this.orders.entries()) {
|
||||
if (
|
||||
isSameYear(parseDate(order.date), currentDate) &&
|
||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||
) {
|
||||
// Same group: Add up investments
|
||||
investmentByGroup = investmentByGroup.plus(
|
||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
);
|
||||
} else {
|
||||
// New group: Store previous group and reset
|
||||
if (currentDate) {
|
||||
investments.push({
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = parseDate(order.date);
|
||||
investmentByGroup = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
}
|
||||
|
||||
if (index === this.orders.length - 1) {
|
||||
// Store current group (latest order)
|
||||
investments.push({
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fill in the missing dates with investment = 0
|
||||
const startDate = parseDate(first(this.orders).date);
|
||||
const endDate = parseDate(last(this.orders).date);
|
||||
|
||||
const allDates: string[] = [];
|
||||
currentDate = startDate;
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
allDates.push(
|
||||
format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
)
|
||||
for (const { date, investmentValueWithCurrencyEffect } of data) {
|
||||
const dateGroup =
|
||||
groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4);
|
||||
groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus(
|
||||
investmentValueWithCurrencyEffect
|
||||
);
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
for (const date of allDates) {
|
||||
const existingInvestment = investments.find((investment) => {
|
||||
return investment.date === date;
|
||||
});
|
||||
|
||||
if (!existingInvestment) {
|
||||
investments.push({ date, investment: new Big(0) });
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(investments, ({ date }) => {
|
||||
return date;
|
||||
});
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
endDate: string
|
||||
): Promise<TimelineInfoInterface> {
|
||||
if (timelineSpecification.length === 0) {
|
||||
return {
|
||||
maxNetPerformance: new Big(0),
|
||||
minNetPerformance: new Big(0),
|
||||
timelinePeriods: []
|
||||
};
|
||||
}
|
||||
|
||||
const startDate = timelineSpecification[0].start;
|
||||
const start = parseDate(startDate);
|
||||
const end = parseDate(endDate);
|
||||
|
||||
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||
let i = 0;
|
||||
let j = -1;
|
||||
for (
|
||||
let currentDate = start;
|
||||
!isAfter(currentDate, end);
|
||||
currentDate = this.addToDate(
|
||||
currentDate,
|
||||
timelineSpecification[i].accuracy
|
||||
)
|
||||
) {
|
||||
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
||||
i++;
|
||||
}
|
||||
while (
|
||||
j + 1 < this.transactionPoints.length &&
|
||||
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
|
||||
let periodEndDate = currentDate;
|
||||
if (timelineSpecification[i].accuracy === 'day') {
|
||||
let nextEndDate = end;
|
||||
if (j + 1 < this.transactionPoints.length) {
|
||||
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
||||
}
|
||||
periodEndDate = min([
|
||||
addMonths(currentDate, 3),
|
||||
max([currentDate, nextEndDate])
|
||||
]);
|
||||
}
|
||||
const timePeriodForDates = this.getTimePeriodForDate(
|
||||
j,
|
||||
currentDate,
|
||||
endOfDay(periodEndDate)
|
||||
);
|
||||
currentDate = periodEndDate;
|
||||
if (timePeriodForDates != null) {
|
||||
timelinePeriodPromises.push(timePeriodForDates);
|
||||
}
|
||||
}
|
||||
|
||||
let minNetPerformance = new Big(0);
|
||||
let maxNetPerformance = new Big(0);
|
||||
|
||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||
timelinePeriodPromises
|
||||
);
|
||||
|
||||
try {
|
||||
minNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((minPerformance, current) => {
|
||||
if (minPerformance.lt(current)) {
|
||||
return minPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
|
||||
maxNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((maxPerformance, current) => {
|
||||
if (maxPerformance.gt(current)) {
|
||||
return maxPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
|
||||
const timelinePeriods = timelineInfoInterfaces.map(
|
||||
(timelineInfo) => timelineInfo.timelinePeriods
|
||||
);
|
||||
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: flatten(timelinePeriods)
|
||||
};
|
||||
return Object.keys(groupedData).map((dateGroup) => ({
|
||||
date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`,
|
||||
investment: groupedData[dateGroup].toNumber()
|
||||
}));
|
||||
}
|
||||
|
||||
private calculateOverallPerformance(positions: TimelinePosition[]) {
|
||||
@ -983,123 +819,6 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
private async getTimePeriodForDate(
|
||||
j: number,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<TimelineInfoInterface> {
|
||||
let investment: Big = new Big(0);
|
||||
let fees: Big = new Big(0);
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
if (j >= 0) {
|
||||
const currencies: { [name: string]: string } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
currencies[item.symbol] = item.currency;
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.plus(item.investment);
|
||||
fees = fees.plus(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
const { values } = await this.currentRateService.getValues({
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endOfDay(endDate)
|
||||
}
|
||||
});
|
||||
marketSymbols = values;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
error,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: TimelinePeriod[] = [];
|
||||
let maxNetPerformance: Big = null;
|
||||
let minNetPerformance: Big = null;
|
||||
for (
|
||||
let currentDate = startDate;
|
||||
isBefore(currentDate, endDate);
|
||||
currentDate = addDays(currentDate, 1)
|
||||
) {
|
||||
let value = new Big(0);
|
||||
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
||||
let invalid = false;
|
||||
if (j >= 0) {
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
if (
|
||||
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
||||
) {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
value = value.plus(
|
||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!invalid) {
|
||||
const grossPerformance = value.minus(investment);
|
||||
const netPerformance = grossPerformance.minus(fees);
|
||||
if (
|
||||
minNetPerformance === null ||
|
||||
minNetPerformance.gt(netPerformance)
|
||||
) {
|
||||
minNetPerformance = netPerformance;
|
||||
}
|
||||
if (
|
||||
maxNetPerformance === null ||
|
||||
maxNetPerformance.lt(netPerformance)
|
||||
) {
|
||||
maxNetPerformance = netPerformance;
|
||||
}
|
||||
|
||||
const result = {
|
||||
grossPerformance,
|
||||
investment,
|
||||
netPerformance,
|
||||
value,
|
||||
date: currentDateAsString
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: results
|
||||
};
|
||||
}
|
||||
|
||||
private getFactor(type: TypeOfOrder) {
|
||||
let factor: number;
|
||||
|
||||
@ -1118,17 +837,6 @@ export class PortfolioCalculator {
|
||||
return factor;
|
||||
}
|
||||
|
||||
private addToDate(date: Date, accuracy: Accuracy): Date {
|
||||
switch (accuracy) {
|
||||
case 'day':
|
||||
return addDays(date, 1);
|
||||
case 'month':
|
||||
return addMonths(date, 1);
|
||||
case 'year':
|
||||
return addYears(date, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private getSymbolMetrics({
|
||||
end,
|
||||
exchangeRates,
|
||||
@ -1165,7 +873,10 @@ export class PortfolioCalculator {
|
||||
let initialValueWithCurrencyEffect: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
let investmentAtStartDateWithCurrencyEffect: Big;
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
const investmentValuesAccumulated: { [date: string]: Big } = {};
|
||||
const investmentValuesAccumulatedWithCurrencyEffect: {
|
||||
[date: string]: Big;
|
||||
} = {};
|
||||
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastAveragePriceWithCurrencyEffect = new Big(0);
|
||||
@ -1207,7 +918,8 @@ export class PortfolioCalculator {
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
initialValueWithCurrencyEffect: new Big(0),
|
||||
investmentValues: {},
|
||||
investmentValuesAccumulated: {},
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {},
|
||||
investmentValuesWithCurrencyEffect: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
@ -1246,7 +958,8 @@ export class PortfolioCalculator {
|
||||
hasErrors: true,
|
||||
initialValue: new Big(0),
|
||||
initialValueWithCurrencyEffect: new Big(0),
|
||||
investmentValues: {},
|
||||
investmentValuesAccumulated: {},
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {},
|
||||
investmentValuesWithCurrencyEffect: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
@ -1639,11 +1352,15 @@ export class PortfolioCalculator {
|
||||
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
|
||||
);
|
||||
|
||||
investmentValues[order.date] = totalInvestment;
|
||||
investmentValuesAccumulated[order.date] = totalInvestment;
|
||||
|
||||
investmentValuesWithCurrencyEffect[order.date] =
|
||||
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
|
||||
totalInvestmentWithCurrencyEffect;
|
||||
|
||||
investmentValuesWithCurrencyEffect[order.date] = (
|
||||
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
|
||||
).add(transactionInvestmentWithCurrencyEffect);
|
||||
|
||||
timeWeightedInvestmentValues[order.date] =
|
||||
totalInvestmentDays > 0
|
||||
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
|
||||
@ -1801,7 +1518,8 @@ export class PortfolioCalculator {
|
||||
grossPerformancePercentageWithCurrencyEffect,
|
||||
initialValue,
|
||||
initialValueWithCurrencyEffect,
|
||||
investmentValues,
|
||||
investmentValuesAccumulated,
|
||||
investmentValuesAccumulatedWithCurrencyEffect,
|
||||
investmentValuesWithCurrencyEffect,
|
||||
netPerformancePercentage,
|
||||
netPerformancePercentageWithCurrencyEffect,
|
||||
@ -1823,15 +1541,4 @@ export class PortfolioCalculator {
|
||||
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
|
||||
};
|
||||
}
|
||||
|
||||
private isNextItemActive(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
currentDate: Date,
|
||||
i: number
|
||||
) {
|
||||
return (
|
||||
i + 1 < timelineSpecification.length &&
|
||||
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -79,7 +79,7 @@ import {
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
|
||||
import { isEmpty, last, uniq, uniqBy } from 'lodash';
|
||||
|
||||
import {
|
||||
HistoricalDataContainer,
|
||||
@ -293,77 +293,32 @@ export class PortfolioService {
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const { items } = await this.getChart({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
portfolioOrders,
|
||||
transactionPoints,
|
||||
userId,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
withDataDecimation: false
|
||||
});
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy) {
|
||||
investments = portfolioCalculator
|
||||
.getInvestmentsByGroup(groupBy)
|
||||
.map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
});
|
||||
|
||||
// Add investment of current group
|
||||
const dateOfCurrentGroup = format(
|
||||
set(new Date(), {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : new Date().getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
);
|
||||
const investmentOfCurrentGroup = investments.filter(({ date }) => {
|
||||
return date === dateOfCurrentGroup;
|
||||
investments = portfolioCalculator.getInvestmentsByGroup({
|
||||
groupBy,
|
||||
data: items
|
||||
});
|
||||
|
||||
if (investmentOfCurrentGroup.length <= 0) {
|
||||
investments.push({
|
||||
date: dateOfCurrentGroup,
|
||||
investment: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
investments = portfolioCalculator
|
||||
.getInvestments()
|
||||
.map(({ date, investment }) => {
|
||||
return {
|
||||
date,
|
||||
investment: investment.toNumber()
|
||||
};
|
||||
});
|
||||
|
||||
// Add investment of today
|
||||
const investmentOfToday = investments.filter(({ date }) => {
|
||||
return date === format(new Date(), DATE_FORMAT);
|
||||
investments = items.map(({ date, investmentValueWithCurrencyEffect }) => {
|
||||
return {
|
||||
date,
|
||||
investment: investmentValueWithCurrencyEffect
|
||||
};
|
||||
});
|
||||
|
||||
if (investmentOfToday.length <= 0) {
|
||||
const pastInvestments = investments.filter(({ date }) => {
|
||||
return isBefore(parseDate(date), new Date());
|
||||
});
|
||||
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||
|
||||
investments.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
investment: lastInvestment?.investment ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
investments = sortBy(investments, ({ date }) => {
|
||||
return date;
|
||||
});
|
||||
|
||||
const startDate = this.getStartDate(
|
||||
dateRange,
|
||||
parseDate(investments[0]?.date)
|
||||
);
|
||||
|
||||
investments = investments.filter(({ date }) => {
|
||||
return !isBefore(parseDate(date), startDate);
|
||||
});
|
||||
|
||||
let streaks: PortfolioInvestments['streaks'];
|
||||
|
||||
if (savingsRate) {
|
||||
@ -1448,7 +1403,8 @@ export class PortfolioService {
|
||||
portfolioOrders,
|
||||
transactionPoints,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withDataDecimation = true
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
impersonationId: string;
|
||||
@ -1456,6 +1412,7 @@ export class PortfolioService {
|
||||
transactionPoints: TransactionPoint[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withDataDecimation?: boolean;
|
||||
}): Promise<HistoricalDataContainer> {
|
||||
if (transactionPoints.length === 0) {
|
||||
return {
|
||||
@ -1481,16 +1438,18 @@ export class PortfolioService {
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), startDate);
|
||||
const step = Math.round(
|
||||
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
|
||||
);
|
||||
let step = 1;
|
||||
|
||||
const items = await portfolioCalculator.getChartData(
|
||||
startDate,
|
||||
endDate,
|
||||
step
|
||||
);
|
||||
if (withDataDecimation) {
|
||||
const daysInMarket = differenceInDays(new Date(), startDate);
|
||||
step = Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS));
|
||||
}
|
||||
|
||||
const items = await portfolioCalculator.getChartData({
|
||||
step,
|
||||
end: endDate,
|
||||
start: startDate
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
|
||||
export interface LookupItem {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
currency: string;
|
||||
dataProviderInfo: DataProviderInfo;
|
||||
dataSource: DataSource;
|
||||
name: string;
|
||||
symbol: string;
|
||||
|
@ -42,6 +42,10 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
'filters.accounts'?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
'filters.assetClasses'?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -114,6 +114,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-fina</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -456,6 +460,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-fina</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -818,6 +826,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-fina</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1026,6 +1038,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-fina</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import * as Alphavantage from 'alphavantage';
|
||||
@ -44,6 +45,12 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({}: GetDividendsParams) {
|
||||
return {};
|
||||
}
|
||||
@ -118,6 +125,7 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
assetClass: undefined,
|
||||
assetSubClass: undefined,
|
||||
currency: bestMatch['8. currency'],
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
dataSource: this.getName(),
|
||||
name: bestMatch['2. name'],
|
||||
symbol: bestMatch['1. symbol']
|
||||
|
@ -80,7 +80,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
message = `RequestError: The operation to get the asset profile for ${aSymbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
@ -91,6 +91,14 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
return response;
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false,
|
||||
name: 'CoinGecko',
|
||||
url: 'https://coingecko.com'
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({}: GetDividendsParams) {
|
||||
return {};
|
||||
}
|
||||
@ -195,7 +203,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
@ -235,6 +243,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
dataSource: this.getName()
|
||||
};
|
||||
});
|
||||
@ -242,7 +251,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
@ -252,11 +261,4 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
name: 'CoinGecko',
|
||||
url: 'https://coingecko.com'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -107,6 +107,31 @@ export class DataProviderService {
|
||||
return response;
|
||||
}
|
||||
|
||||
public getDataProvider(providerName: DataSource) {
|
||||
for (const dataProviderInterface of this.dataProviderInterfaces) {
|
||||
if (this.dataProviderMapping[dataProviderInterface.getName()]) {
|
||||
const mappedDataProviderInterface = this.dataProviderInterfaces.find(
|
||||
(currentDataProviderInterface) => {
|
||||
return (
|
||||
currentDataProviderInterface.getName() ===
|
||||
this.dataProviderMapping[dataProviderInterface.getName()]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (mappedDataProviderInterface) {
|
||||
return mappedDataProviderInterface;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataProviderInterface.getName() === providerName) {
|
||||
return dataProviderInterface;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No data provider has been found.');
|
||||
}
|
||||
|
||||
public getDataSourceForExchangeRates(): DataSource {
|
||||
return DataSource[
|
||||
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||
@ -520,20 +545,15 @@ export class DataProviderService {
|
||||
return { items: lookupItems };
|
||||
}
|
||||
|
||||
let dataSources = this.configurationService.get('DATA_SOURCES');
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
user.subscription.type === 'Basic'
|
||||
) {
|
||||
dataSources = dataSources.filter((dataSource) => {
|
||||
return !this.isPremiumDataSource(DataSource[dataSource]);
|
||||
let dataProviderServices = this.configurationService
|
||||
.get('DATA_SOURCES')
|
||||
.map((dataSource) => {
|
||||
return this.getDataProvider(DataSource[dataSource]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const dataSource of dataSources) {
|
||||
for (const dataProviderService of dataProviderServices) {
|
||||
promises.push(
|
||||
this.getDataProvider(DataSource[dataSource]).search({
|
||||
dataProviderService.search({
|
||||
includeIndices,
|
||||
query
|
||||
})
|
||||
@ -555,6 +575,16 @@ export class DataProviderService {
|
||||
})
|
||||
.sort(({ name: name1 }, { name: name2 }) => {
|
||||
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||
})
|
||||
.map((lookupItem) => {
|
||||
if (
|
||||
!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') ||
|
||||
user.subscription.type === 'Premium'
|
||||
) {
|
||||
lookupItem.dataProviderInfo.isPremium = false;
|
||||
}
|
||||
|
||||
return lookupItem;
|
||||
});
|
||||
|
||||
return {
|
||||
@ -562,31 +592,6 @@ export class DataProviderService {
|
||||
};
|
||||
}
|
||||
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
for (const dataProviderInterface of this.dataProviderInterfaces) {
|
||||
if (this.dataProviderMapping[dataProviderInterface.getName()]) {
|
||||
const mappedDataProviderInterface = this.dataProviderInterfaces.find(
|
||||
(currentDataProviderInterface) => {
|
||||
return (
|
||||
currentDataProviderInterface.getName() ===
|
||||
this.dataProviderMapping[dataProviderInterface.getName()]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (mappedDataProviderInterface) {
|
||||
return mappedDataProviderInterface;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataProviderInterface.getName() === providerName) {
|
||||
return dataProviderInterface;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No data provider has been found.');
|
||||
}
|
||||
|
||||
private hasCurrency({
|
||||
currency,
|
||||
dataGatheringItems
|
||||
@ -602,14 +607,6 @@ export class DataProviderService {
|
||||
});
|
||||
}
|
||||
|
||||
private isPremiumDataSource(aDataSource: DataSource) {
|
||||
const premiumDataSources: DataSource[] = [
|
||||
DataSource.EOD_HISTORICAL_DATA,
|
||||
DataSource.FINANCIAL_MODELING_PREP
|
||||
];
|
||||
return premiumDataSources.includes(aDataSource);
|
||||
}
|
||||
|
||||
private transformHistoricalData({
|
||||
allData,
|
||||
currency,
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
REPLACE_NAME_PARTS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
@ -58,6 +59,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: true
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
@ -271,7 +278,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
@ -312,7 +319,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
dataSource,
|
||||
name,
|
||||
symbol,
|
||||
currency: this.convertCurrency(currency)
|
||||
currency: this.convertCurrency(currency),
|
||||
dataProviderInfo: this.getDataProviderInfo()
|
||||
};
|
||||
}
|
||||
)
|
||||
@ -423,7 +431,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
@ -45,6 +45,14 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: true,
|
||||
name: 'Financial Modeling Prep',
|
||||
url: 'https://financialmodelingprep.com/developer/docs'
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({}: GetDividendsParams) {
|
||||
return {};
|
||||
}
|
||||
@ -143,7 +151,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
@ -192,7 +200,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
@ -202,11 +210,4 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
name: 'Financial Modeling Prep',
|
||||
url: 'https://financialmodelingprep.com/developer/docs'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
@ -40,6 +41,12 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({}: GetDividendsParams) {
|
||||
return {};
|
||||
}
|
||||
@ -177,7 +184,11 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
}
|
||||
});
|
||||
|
||||
return { items };
|
||||
return {
|
||||
items: items.map((item) => {
|
||||
return { ...item, dataProviderInfo: this.getDataProviderInfo() };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private async getSheet({
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
|
||||
@ -11,6 +12,8 @@ export interface DataProviderInterface {
|
||||
|
||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||
|
||||
getDataProviderInfo(): DataProviderInfo;
|
||||
|
||||
getDividends({ from, granularity, symbol, to }: GetDividendsParams): Promise<{
|
||||
[date: string]: IDataProviderHistoricalResponse;
|
||||
}>;
|
||||
|
@ -18,7 +18,10 @@ import {
|
||||
extractNumberFromString,
|
||||
getYesterday
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { ScraperConfiguration } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
ScraperConfiguration
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import * as cheerio from 'cheerio';
|
||||
@ -59,6 +62,12 @@ export class ManualService implements DataProviderInterface {
|
||||
return assetProfile;
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({}: GetDividendsParams) {
|
||||
return {};
|
||||
}
|
||||
@ -214,7 +223,11 @@ export class ManualService implements DataProviderInterface {
|
||||
return !isUUID(symbol);
|
||||
});
|
||||
|
||||
return { items };
|
||||
return {
|
||||
items: items.map((item) => {
|
||||
return { ...item, dataProviderInfo: this.getDataProviderInfo() };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
public async test(scraperConfiguration: ScraperConfiguration) {
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
@ -37,6 +38,12 @@ export class RapidApiService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({}: GetDividendsParams) {
|
||||
return {};
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { addDays, format, isSameDay } from 'date-fns';
|
||||
@ -47,6 +48,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
@ -283,6 +290,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
assetSubClass,
|
||||
symbol,
|
||||
currency: marketDataItem.currency,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
dataSource: this.getName(),
|
||||
name: this.yahooFinanceDataEnhancerService.formatName({
|
||||
longName: quote.longname,
|
||||
|
@ -10,7 +10,6 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Sort, SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
@ -43,7 +42,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
public currency: string;
|
||||
public dataSource: MatTableDataSource<OrderWithAccount>;
|
||||
public equity: number;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToDeleteAccountBalance: boolean;
|
||||
public historicalDataItems: HistoricalDataItem[];
|
||||
public holdings: PortfolioPosition[];
|
||||
@ -65,7 +63,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<AccountDetailDialog>,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.userService.stateChanged
|
||||
@ -136,13 +133,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((impersonationId) => {
|
||||
this.hasImpersonationId = !!impersonationId;
|
||||
});
|
||||
|
||||
this.fetchAccountBalances();
|
||||
this.fetchActivities();
|
||||
this.fetchPortfolioPerformance();
|
||||
@ -165,20 +155,12 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
let activityIds = [];
|
||||
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
activityIds = this.dataSource.data.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
} else {
|
||||
activityIds = this.activities.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
}
|
||||
let activityIds = this.dataSource.data.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchExport(activityIds)
|
||||
.fetchExport({ activityIds })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data) => {
|
||||
downloadAsFile({
|
||||
@ -215,36 +197,21 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
private fetchActivities() {
|
||||
this.isLoadingActivities = true;
|
||||
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
|
||||
sortColumn: this.sortColumn,
|
||||
sortDirection: this.sortDirection
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities, count }) => {
|
||||
this.dataSource = new MatTableDataSource(activities);
|
||||
this.totalItems = count;
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
|
||||
sortColumn: this.sortColumn,
|
||||
sortDirection: this.sortDirection
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities, count }) => {
|
||||
this.dataSource = new MatTableDataSource(activities);
|
||||
this.totalItems = count;
|
||||
|
||||
this.isLoadingActivities = false;
|
||||
this.isLoadingActivities = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities }) => {
|
||||
this.activities = activities;
|
||||
|
||||
this.isLoadingActivities = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
private fetchPortfolioPerformance() {
|
||||
@ -268,7 +235,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
return {
|
||||
date,
|
||||
value:
|
||||
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||
this.data.hasImpersonationId ||
|
||||
this.user.settings.isRestrictedView
|
||||
? netWorthInPercentage
|
||||
: netWorth
|
||||
};
|
||||
|
@ -25,7 +25,7 @@
|
||||
class="h-100"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[isInPercent]="data.hasImpersonationId || user.settings.isRestrictedView"
|
||||
[isLoading]="isLoadingChart"
|
||||
[locale]="user?.settings?.locale"
|
||||
/>
|
||||
@ -86,13 +86,12 @@
|
||||
<ion-icon name="swap-vertical-outline" />
|
||||
<div class="d-none d-sm-block ml-2" i18n>Activities</div>
|
||||
</ng-template>
|
||||
<gf-activities-table-lazy
|
||||
*ngIf="user?.settings?.isExperimentalFeatures === true"
|
||||
<gf-activities-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[dataSource]="dataSource"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
|
||||
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -103,19 +102,6 @@
|
||||
(export)="onExport()"
|
||||
(sortChanged)="onSortChanged($event)"
|
||||
/>
|
||||
<gf-activities-table
|
||||
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||
[activities]="activities"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="true"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="false"
|
||||
(export)="onExport()"
|
||||
/>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
@ -126,7 +112,7 @@
|
||||
[accountBalances]="accountBalances"
|
||||
[accountId]="data.accountId"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
|
||||
[showActions]="!data.hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
|
||||
(accountBalanceDeleted)="onDeleteAccountBalance($event)"
|
||||
/>
|
||||
</mat-tab>
|
||||
|
@ -8,7 +8,6 @@ import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-heade
|
||||
import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module';
|
||||
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
||||
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
|
||||
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
@ -21,7 +20,6 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
|
||||
CommonModule,
|
||||
GfAccountBalancesModule,
|
||||
GfActivitiesTableModule,
|
||||
GfActivitiesTableLazyModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfHoldingsTableModule,
|
||||
|
@ -6,11 +6,12 @@
|
||||
mat-button
|
||||
[ngClass]="{ 'w-100': hasTabs }"
|
||||
[routerLink]="['/']"
|
||||
(click)="onLogoClick()"
|
||||
>
|
||||
<gf-logo class="px-2" [label]="pageTitle" />
|
||||
</a>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<span class="gf-spacer"></span>
|
||||
<ul class="alig-items-center d-flex list-inline m-0 px-2">
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
@ -119,11 +120,7 @@
|
||||
[matMenuTriggerRestoreFocus]="false"
|
||||
(menuOpened)="onOpenAssistant()"
|
||||
>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<ion-icon class="rotate-90" name="options-outline" />
|
||||
} @else {
|
||||
<ion-icon name="search-outline" />
|
||||
}
|
||||
<ion-icon class="rotate-90" name="options-outline" />
|
||||
</button>
|
||||
<mat-menu
|
||||
#assistantMenu="matMenu"
|
||||
@ -324,7 +321,7 @@
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<span class="gf-spacer"></span>
|
||||
<ul class="alig-items-center d-flex list-inline m-0 px-2">
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
|
@ -38,10 +38,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ import { MatMenuTrigger } from '@angular/material/menu';
|
||||
import { Router } from '@angular/router';
|
||||
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
|
||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||
import { LayoutService } from '@ghostfolio/client/core/layout.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import {
|
||||
@ -89,6 +90,7 @@ export class HeaderComponent implements OnChanges {
|
||||
private dataService: DataService,
|
||||
private dialog: MatDialog,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private layoutService: LayoutService,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private tokenStorageService: TokenStorageService,
|
||||
@ -170,6 +172,8 @@ export class HeaderComponent implements OnChanges {
|
||||
|
||||
if (filter.type === 'ACCOUNT') {
|
||||
filtersType = 'accounts';
|
||||
} else if (filter.type === 'ASSET_CLASS') {
|
||||
filtersType = 'assetClasses';
|
||||
} else if (filter.type === 'TAG') {
|
||||
filtersType = 'tags';
|
||||
}
|
||||
@ -190,6 +194,12 @@ export class HeaderComponent implements OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
public onLogoClick() {
|
||||
if (this.currentRoute === 'home' || this.currentRoute === 'zen') {
|
||||
this.layoutService.getShouldReloadSubject().next();
|
||||
}
|
||||
}
|
||||
|
||||
public onMenuClosed() {
|
||||
this.isMenuOpen = false;
|
||||
}
|
||||
|
@ -1,12 +1,4 @@
|
||||
<div class="container justify-content-center p-3">
|
||||
<div *ngIf="!user?.settings?.isExperimentalFeatures" class="mb-3 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="positions === undefined"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
|
||||
<mat-card appearance="outlined">
|
||||
|
@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { LayoutService } from '@ghostfolio/client/core/layout.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
LineChartItem,
|
||||
@ -43,6 +44,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private layoutService: LayoutService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.userService.stateChanged
|
||||
@ -73,6 +75,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.layoutService.shouldReloadContent$
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.showDetails =
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
@ -96,17 +96,6 @@
|
||||
[showDetails]="showDetails"
|
||||
[unit]="unit"
|
||||
/>
|
||||
<div
|
||||
*ngIf="showDetails && !user?.settings?.isExperimentalFeatures"
|
||||
class="text-center"
|
||||
>
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="isLoadingPerformance"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||
|
||||
@ -16,7 +15,6 @@ import { HomeOverviewComponent } from './home-overview.component';
|
||||
GfLineChartModule,
|
||||
GfNoTransactionsInfoModule,
|
||||
GfPortfolioPerformanceModule,
|
||||
GfToggleModule,
|
||||
MatButtonModule,
|
||||
RouterModule
|
||||
],
|
||||
|
@ -268,20 +268,12 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
let activityIds = [];
|
||||
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
activityIds = this.dataSource.data.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
} else {
|
||||
activityIds = this.activities.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
}
|
||||
let activityIds = this.dataSource.data.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchExport(activityIds)
|
||||
.fetchExport({ activityIds })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data) => {
|
||||
downloadAsFile({
|
||||
|
@ -249,13 +249,12 @@
|
||||
<div class="row" [ngClass]="{ 'd-none': !activities?.length }">
|
||||
<div class="col mb-3">
|
||||
<div class="h5 mb-0" i18n>Activities</div>
|
||||
<gf-activities-table-lazy
|
||||
*ngIf="user?.settings?.isExperimentalFeatures === true"
|
||||
<gf-activities-table
|
||||
[baseCurrency]="data.baseCurrency"
|
||||
[dataSource]="dataSource"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
|
||||
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
@ -267,20 +266,6 @@
|
||||
[totalItems]="totalItems"
|
||||
(export)="onExport()"
|
||||
/>
|
||||
<gf-activities-table
|
||||
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||
[activities]="activities"
|
||||
[baseCurrency]="data.baseCurrency"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="true"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
[showActions]="false"
|
||||
[showNameColumn]="false"
|
||||
(export)="onExport()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -5,7 +5,6 @@ import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
@ -20,7 +19,6 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfActivitiesTableLazyModule,
|
||||
GfDataProviderCreditsModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
|
19
apps/client/src/app/core/layout.service.ts
Normal file
19
apps/client/src/app/core/layout.service.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LayoutService {
|
||||
public shouldReloadContent$: Observable<void>;
|
||||
|
||||
private shouldReloadSubject = new Subject<void>();
|
||||
|
||||
public constructor() {
|
||||
this.shouldReloadContent$ = this.shouldReloadSubject.asObservable();
|
||||
}
|
||||
|
||||
public getShouldReloadSubject() {
|
||||
return this.shouldReloadSubject;
|
||||
}
|
||||
}
|
@ -50,11 +50,8 @@
|
||||
You can sign up via the “<a [routerLink]="routerLinkRegister"
|
||||
>Get Started</a
|
||||
>” button at the top of the page. You have multiple options to join
|
||||
Ghostfolio: Create an account with a security token, using
|
||||
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
|
||||
>Internet Identity</a
|
||||
>
|
||||
or <i>Google Sign</i>. We will guide you to set up your portfolio.
|
||||
Ghostfolio: Create an account with a security token or
|
||||
<i>Google Sign</i>. We will guide you to set up your portfolio.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
@ -75,11 +72,9 @@
|
||||
></mat-card-header
|
||||
>
|
||||
<mat-card-content>
|
||||
Yes, the authentication systems (via security token or
|
||||
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
|
||||
>Internet Identity</a
|
||||
>) enable you to sign in securely and anonymously to Ghostfolio. There
|
||||
is no need for an e-mail address, phone number, or a username.
|
||||
Yes, the authentication system via security token enables you to sign
|
||||
in securely and anonymously to Ghostfolio. There is no need for an
|
||||
e-mail address, phone number, or a username.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
|
@ -121,43 +121,25 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public fetchActivities() {
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: this.userService.getFilters(),
|
||||
skip: this.pageIndex * this.pageSize,
|
||||
sortColumn: this.sortColumn,
|
||||
sortDirection: this.sortDirection,
|
||||
take: this.pageSize
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities, count }) => {
|
||||
this.dataSource = new MatTableDataSource(activities);
|
||||
this.totalItems = count;
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: this.userService.getFilters(),
|
||||
skip: this.pageIndex * this.pageSize,
|
||||
sortColumn: this.sortColumn,
|
||||
sortDirection: this.sortDirection,
|
||||
take: this.pageSize
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities, count }) => {
|
||||
this.dataSource = new MatTableDataSource(activities);
|
||||
this.totalItems = count;
|
||||
|
||||
if (this.hasPermissionToCreateActivity && this.totalItems <= 0) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
if (this.hasPermissionToCreateActivity && this.totalItems <= 0) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.dataService
|
||||
.fetchActivities({})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities }) => {
|
||||
this.activities = activities;
|
||||
|
||||
if (
|
||||
this.hasPermissionToCreateActivity &&
|
||||
this.activities?.length <= 0
|
||||
) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onChangePage(page: PageEvent) {
|
||||
@ -199,8 +181,14 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onExport(activityIds?: string[]) {
|
||||
let fetchExportParams: any = { activityIds };
|
||||
|
||||
if (!activityIds) {
|
||||
fetchExportParams = { filters: this.userService.getFilters() };
|
||||
}
|
||||
|
||||
this.dataService
|
||||
.fetchExport(activityIds)
|
||||
.fetchExport(fetchExportParams)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data) => {
|
||||
for (const activity of data.activities) {
|
||||
@ -220,7 +208,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
public onExportDrafts(activityIds?: string[]) {
|
||||
this.dataService
|
||||
.fetchExport(activityIds)
|
||||
.fetchExport({ activityIds })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data) => {
|
||||
downloadAsFile({
|
||||
|
@ -2,8 +2,7 @@
|
||||
<div class="mb-3 row">
|
||||
<div class="col">
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1>
|
||||
<gf-activities-table-lazy
|
||||
*ngIf="user?.settings?.isExperimentalFeatures === true"
|
||||
<gf-activities-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[dataSource]="dataSource"
|
||||
[deviceType]="deviceType"
|
||||
@ -20,31 +19,13 @@
|
||||
(activityToClone)="onCloneActivity($event)"
|
||||
(activityToUpdate)="onUpdateActivity($event)"
|
||||
(deleteAllActivities)="onDeleteAllActivities()"
|
||||
(export)="onExport($event)"
|
||||
(export)="onExport()"
|
||||
(exportDrafts)="onExportDrafts($event)"
|
||||
(import)="onImport()"
|
||||
(importDividends)="onImportDividends()"
|
||||
(pageChanged)="onChangePage($event)"
|
||||
(sortChanged)="onSortChanged($event)"
|
||||
/>
|
||||
<gf-activities-table
|
||||
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||
[activities]="activities"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
|
||||
(activityDeleted)="onDeleteActivity($event)"
|
||||
(activityToClone)="onCloneActivity($event)"
|
||||
(activityToUpdate)="onUpdateActivity($event)"
|
||||
(deleteAllActivities)="onDeleteAllActivities()"
|
||||
(export)="onExport($event)"
|
||||
(exportDrafts)="onExportDrafts($event)"
|
||||
(import)="onImport()"
|
||||
(importDividends)="onImportDividends()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -4,7 +4,6 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
|
||||
import { ActivitiesPageRoutingModule } from './activities-page-routing.module';
|
||||
@ -18,7 +17,6 @@ import { GfImportActivitiesDialogModule } from './import-activities-dialog/impor
|
||||
ActivitiesPageRoutingModule,
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfActivitiesTableLazyModule,
|
||||
GfCreateOrUpdateActivityDialogModule,
|
||||
GfImportActivitiesDialogModule,
|
||||
MatButtonModule,
|
||||
|
@ -116,8 +116,8 @@
|
||||
</ng-template>
|
||||
<div class="pt-3">
|
||||
<ng-container *ngIf="errorMessages?.length === 0; else errorMessage">
|
||||
<gf-activities-table-lazy
|
||||
*ngIf="importStep === 1 && data?.user?.settings?.isExperimentalFeatures === true"
|
||||
<gf-activities-table
|
||||
*ngIf="importStep === 1"
|
||||
[baseCurrency]="data?.user?.settings?.baseCurrency"
|
||||
[dataSource]="dataSource"
|
||||
[deviceType]="data?.deviceType"
|
||||
@ -137,23 +137,6 @@
|
||||
[totalItems]="totalItems"
|
||||
(selectedActivities)="updateSelection($event)"
|
||||
/>
|
||||
<gf-activities-table
|
||||
*ngIf="importStep === 1 && data?.user?.settings?.isExperimentalFeatures !== true"
|
||||
[activities]="activities"
|
||||
[baseCurrency]="data?.user?.settings?.baseCurrency"
|
||||
[deviceType]="data?.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="false"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data?.user?.settings?.locale"
|
||||
[pageSize]="maxSafeInteger"
|
||||
[showActions]="false"
|
||||
[showCheckbox]="true"
|
||||
[showFooter]="false"
|
||||
[showSymbolColumn]="false"
|
||||
(selectedActivities)="updateSelection($event)"
|
||||
/>
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button mat-button (click)="onReset(stepper)">
|
||||
<ng-container i18n>Back</ng-container>
|
||||
|
@ -13,7 +13,6 @@ import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-heade
|
||||
import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||
|
||||
import { ImportActivitiesDialog } from './import-activities-dialog.component';
|
||||
|
||||
@ -23,7 +22,6 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfActivitiesTableModule,
|
||||
GfActivitiesTableLazyModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfFileDropModule,
|
||||
|
@ -11,7 +11,6 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Filter,
|
||||
PortfolioDetails,
|
||||
PortfolioPosition,
|
||||
UniqueAsset,
|
||||
@ -24,7 +23,7 @@ import { Account, AssetClass, DataSource, Platform } from '@prisma/client';
|
||||
import { isNumber } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-allocations-page',
|
||||
@ -38,8 +37,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
value: number;
|
||||
};
|
||||
};
|
||||
public activeFilters: Filter[] = [];
|
||||
public allFilters: Filter[];
|
||||
public continents: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
@ -47,7 +44,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public deviceType: string;
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public hasImpersonationId: boolean;
|
||||
public isLoading = false;
|
||||
public markets: {
|
||||
@ -60,7 +56,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
value: number;
|
||||
};
|
||||
};
|
||||
public placeholder = '';
|
||||
public platforms: {
|
||||
[id: string]: Pick<Platform, 'name'> & {
|
||||
id: string;
|
||||
@ -135,98 +130,34 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
this.hasImpersonationId = !!impersonationId;
|
||||
});
|
||||
|
||||
this.filters$
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((filters) => {
|
||||
this.isLoading = true;
|
||||
this.activeFilters = filters;
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0
|
||||
? $localize`Filter by account or tag...`
|
||||
: '';
|
||||
|
||||
this.initialize();
|
||||
|
||||
return this.fetchPortfolioDetails();
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe((portfolioDetails) => {
|
||||
this.initialize();
|
||||
|
||||
this.portfolioDetails = portfolioDetails;
|
||||
|
||||
this.initializeAllocationsData();
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
const accountFilters: Filter[] = this.user.accounts.map(
|
||||
({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
label: name,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const assetClassFilters: Filter[] = [];
|
||||
for (const assetClass of Object.keys(AssetClass)) {
|
||||
assetClassFilters.push({
|
||||
id: assetClass,
|
||||
label: translate(assetClass),
|
||||
type: 'ASSET_CLASS'
|
||||
});
|
||||
}
|
||||
|
||||
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
label: translate(name),
|
||||
type: 'TAG'
|
||||
};
|
||||
});
|
||||
|
||||
this.allFilters = [
|
||||
...accountFilters,
|
||||
...assetClassFilters,
|
||||
...tagFilters
|
||||
];
|
||||
|
||||
this.worldMapChartFormat =
|
||||
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||
? `{0}%`
|
||||
: `{0} ${this.user?.settings?.baseCurrency}`;
|
||||
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
this.isLoading = true;
|
||||
this.isLoading = true;
|
||||
|
||||
this.initialize();
|
||||
this.initialize();
|
||||
|
||||
this.fetchPortfolioDetails()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((portfolioDetails) => {
|
||||
this.initialize();
|
||||
this.fetchPortfolioDetails()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((portfolioDetails) => {
|
||||
this.initialize();
|
||||
|
||||
this.portfolioDetails = portfolioDetails;
|
||||
this.portfolioDetails = portfolioDetails;
|
||||
|
||||
this.initializeAllocationsData();
|
||||
this.initializeAllocationsData();
|
||||
|
||||
this.isLoading = false;
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
@ -273,10 +204,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
private fetchPortfolioDetails() {
|
||||
return this.dataService.fetchPortfolioDetails({
|
||||
filters:
|
||||
this.activeFilters.length > 0
|
||||
? this.activeFilters
|
||||
: this.userService.getFilters()
|
||||
filters: this.userService.getFilters()
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2,14 +2,6 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Allocations</h1>
|
||||
@if (!user?.settings?.isExperimentalFeatures) {
|
||||
<gf-activities-filter
|
||||
[allFilters]="allFilters"
|
||||
[isLoading]="isLoading"
|
||||
[placeholder]="placeholder"
|
||||
(valueChanged)="filters$.next($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@ -4,7 +4,6 @@ import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
@ -17,7 +16,6 @@ import { AllocationsPageComponent } from './allocations-page.component';
|
||||
imports: [
|
||||
AllocationsPageRoutingModule,
|
||||
CommonModule,
|
||||
GfActivitiesFilterModule,
|
||||
GfPortfolioProportionChartModule,
|
||||
GfPremiumIndicatorModule,
|
||||
GfWorldMapChartModule,
|
||||
|
@ -8,7 +8,6 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
Filter,
|
||||
HistoricalDataItem,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformance,
|
||||
@ -17,14 +16,14 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
|
||||
import { GroupBy, ToggleOption } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetClass, DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { isNumber, sortBy } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-analysis-page',
|
||||
@ -32,8 +31,6 @@ import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './analysis-page.html'
|
||||
})
|
||||
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public activeFilters: Filter[] = [];
|
||||
public allFilters: Filter[];
|
||||
public benchmarkDataItems: HistoricalDataItem[] = [];
|
||||
public benchmarks: Partial<SymbolProfile>[];
|
||||
public bottom3: Position[];
|
||||
@ -42,7 +39,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public deviceType: string;
|
||||
public dividendsByGroup: InvestmentItem[];
|
||||
public dividendTimelineDataLabel = $localize`Dividend`;
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public firstOrderDate: Date;
|
||||
public hasImpersonationId: boolean;
|
||||
public investments: InvestmentItem[];
|
||||
@ -58,7 +54,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public performance: PortfolioPerformance;
|
||||
public performanceDataItems: HistoricalDataItem[];
|
||||
public performanceDataItemsInPercentage: HistoricalDataItem[];
|
||||
public placeholder = '';
|
||||
public portfolioEvolutionDataLabel = $localize`Investment`;
|
||||
public streaks: PortfolioInvestments['streaks'];
|
||||
public top3: Position[];
|
||||
@ -118,61 +113,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
this.hasImpersonationId = !!impersonationId;
|
||||
});
|
||||
|
||||
this.filters$
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((filters) => {
|
||||
this.activeFilters = filters;
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0
|
||||
? $localize`Filter by account or tag...`
|
||||
: '';
|
||||
|
||||
this.update();
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(() => {});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
const accountFilters: Filter[] = this.user.accounts.map(
|
||||
({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
label: name,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const assetClassFilters: Filter[] = [];
|
||||
for (const assetClass of Object.keys(AssetClass)) {
|
||||
assetClassFilters.push({
|
||||
id: assetClass,
|
||||
label: translate(assetClass),
|
||||
type: 'ASSET_CLASS'
|
||||
});
|
||||
}
|
||||
|
||||
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
label: translate(name),
|
||||
type: 'TAG'
|
||||
};
|
||||
});
|
||||
|
||||
this.allFilters = [
|
||||
...accountFilters,
|
||||
...assetClassFilters,
|
||||
...tagFilters
|
||||
];
|
||||
|
||||
this.update();
|
||||
}
|
||||
});
|
||||
@ -196,24 +142,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onChangeDateRange(dateRange: DateRange) {
|
||||
this.dataService
|
||||
.putUserSetting({ dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onChangeGroupBy(aMode: GroupBy) {
|
||||
this.mode = aMode;
|
||||
this.fetchDividendsAndInvestments();
|
||||
@ -227,10 +155,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
private fetchDividendsAndInvestments() {
|
||||
this.dataService
|
||||
.fetchDividends({
|
||||
filters:
|
||||
this.activeFilters.length > 0
|
||||
? this.activeFilters
|
||||
: this.userService.getFilters(),
|
||||
filters: this.userService.getFilters(),
|
||||
groupBy: this.mode,
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
@ -243,10 +168,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.dataService
|
||||
.fetchInvestments({
|
||||
filters:
|
||||
this.activeFilters.length > 0
|
||||
? this.activeFilters
|
||||
: this.userService.getFilters(),
|
||||
filters: this.userService.getFilters(),
|
||||
groupBy: this.mode,
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
@ -321,10 +243,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioPerformance({
|
||||
filters:
|
||||
this.activeFilters.length > 0
|
||||
? this.activeFilters
|
||||
: this.userService.getFilters(),
|
||||
filters: this.userService.getFilters(),
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -370,10 +289,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.dataService
|
||||
.fetchPositions({
|
||||
filters:
|
||||
this.activeFilters.length > 0
|
||||
? this.activeFilters
|
||||
: this.userService.getFilters(),
|
||||
filters: this.userService.getFilters(),
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
|
@ -1,21 +1,5 @@
|
||||
<div class="container">
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1>
|
||||
@if (!user?.settings?.isExperimentalFeatures) {
|
||||
<div class="my-4 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
/>
|
||||
</div>
|
||||
<gf-activities-filter
|
||||
[allFilters]="allFilters"
|
||||
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
|
||||
[placeholder]="placeholder"
|
||||
(valueChanged)="filters$.next($event)"
|
||||
/>
|
||||
}
|
||||
<div class="mb-5 row">
|
||||
<div class="col-lg">
|
||||
<gf-benchmark-comparator
|
||||
|
@ -7,17 +7,15 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
Filter,
|
||||
PortfolioDetails,
|
||||
PortfolioPosition,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetClass, DataSource } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-holdings-page',
|
||||
@ -25,15 +23,11 @@ import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './holdings-page.html'
|
||||
})
|
||||
export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
public activeFilters: Filter[] = [];
|
||||
public allFilters: Filter[];
|
||||
public deviceType: string;
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public holdings: PortfolioPosition[];
|
||||
public isLoading = false;
|
||||
public placeholder = '';
|
||||
public portfolioDetails: PortfolioDetails;
|
||||
public user: User;
|
||||
|
||||
@ -75,31 +69,6 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
this.hasImpersonationId = !!impersonationId;
|
||||
});
|
||||
|
||||
this.filters$
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((filters) => {
|
||||
this.isLoading = true;
|
||||
this.activeFilters = filters;
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0
|
||||
? $localize`Filter by account or tag...`
|
||||
: '';
|
||||
|
||||
return this.fetchPortfolioDetails();
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe((portfolioDetails) => {
|
||||
this.portfolioDetails = portfolioDetails;
|
||||
|
||||
this.initialize();
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
@ -111,52 +80,17 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
const accountFilters: Filter[] = this.user.accounts.map(
|
||||
({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
label: name,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}
|
||||
);
|
||||
this.holdings = undefined;
|
||||
|
||||
const assetClassFilters: Filter[] = [];
|
||||
for (const assetClass of Object.keys(AssetClass)) {
|
||||
assetClassFilters.push({
|
||||
id: assetClass,
|
||||
label: translate(assetClass),
|
||||
type: 'ASSET_CLASS'
|
||||
this.fetchPortfolioDetails()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((portfolioDetails) => {
|
||||
this.portfolioDetails = portfolioDetails;
|
||||
|
||||
this.initialize();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
label: translate(name),
|
||||
type: 'TAG'
|
||||
};
|
||||
});
|
||||
|
||||
this.allFilters = [
|
||||
...accountFilters,
|
||||
...assetClassFilters,
|
||||
...tagFilters
|
||||
];
|
||||
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
this.holdings = undefined;
|
||||
|
||||
this.fetchPortfolioDetails()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((portfolioDetails) => {
|
||||
this.portfolioDetails = portfolioDetails;
|
||||
|
||||
this.initialize();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
@ -170,10 +104,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
private fetchPortfolioDetails() {
|
||||
return this.dataService.fetchPortfolioDetails({
|
||||
filters:
|
||||
this.activeFilters.length > 0
|
||||
? this.activeFilters
|
||||
: this.userService.getFilters()
|
||||
filters: this.userService.getFilters()
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2,14 +2,6 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Holdings</h1>
|
||||
@if (!user?.settings?.isExperimentalFeatures) {
|
||||
<gf-activities-filter
|
||||
[allFilters]="allFilters"
|
||||
[isLoading]="isLoading"
|
||||
[placeholder]="placeholder"
|
||||
(valueChanged)="filters$.next($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module';
|
||||
|
||||
import { HoldingsPageRoutingModule } from './holdings-page-routing.module';
|
||||
@ -11,7 +10,6 @@ import { HoldingsPageComponent } from './holdings-page.component';
|
||||
declarations: [HoldingsPageComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesFilterModule,
|
||||
GfHoldingsTableModule,
|
||||
HoldingsPageRoutingModule,
|
||||
MatButtonModule
|
||||
|
@ -15,6 +15,7 @@ import { DivvyDiaryPageComponent } from './products/divvydiary-page.component';
|
||||
import { EightFiguresPageComponent } from './products/eightfigures-page.component';
|
||||
import { EmpowerPageComponent } from './products/empower-page.component';
|
||||
import { ExirioPageComponent } from './products/exirio-page.component';
|
||||
import { FinaPageComponent } from './products/fina-page.component';
|
||||
import { FinaryPageComponent } from './products/finary-page.component';
|
||||
import { FinWisePageComponent } from './products/finwise-page.component';
|
||||
import { FolisharePageComponent } from './products/folishare-page.component';
|
||||
@ -220,6 +221,18 @@ export const products: Product[] = [
|
||||
pricingPerYear: '$100',
|
||||
slogan: 'All your wealth, in one place.'
|
||||
},
|
||||
{
|
||||
component: FinaPageComponent,
|
||||
founded: 2023,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'fina',
|
||||
languages: ['English'],
|
||||
name: 'Fina',
|
||||
origin: $localize`United States`,
|
||||
pricingPerYear: '$115',
|
||||
slogan: 'Flexible Financial Management'
|
||||
},
|
||||
{
|
||||
component: FinaryPageComponent,
|
||||
founded: 2020,
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { products } from '../products';
|
||||
import { BaseProductPageComponent } from './base-page.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-fina-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class FinaPageComponent extends BaseProductPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'fina';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -279,8 +279,14 @@ export class DataService {
|
||||
return this.http.get<BenchmarkResponse>('/api/v1/benchmark');
|
||||
}
|
||||
|
||||
public fetchExport(activityIds?: string[]) {
|
||||
let params = new HttpParams();
|
||||
public fetchExport({
|
||||
activityIds,
|
||||
filters
|
||||
}: {
|
||||
activityIds?: string[];
|
||||
filters?: Filter[];
|
||||
} = {}) {
|
||||
let params = this.buildFiltersAsQueryParams({ filters });
|
||||
|
||||
if (activityIds) {
|
||||
params = params.append('activityIds', activityIds.join(','));
|
||||
|
@ -50,20 +50,25 @@ export class UserService extends ObservableStore<UserStoreState> {
|
||||
const filters: Filter[] = [];
|
||||
const user = this.getState().user;
|
||||
|
||||
if (user?.settings?.isExperimentalFeatures === true) {
|
||||
if (user.settings['filters.accounts']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.accounts'][0],
|
||||
type: 'ACCOUNT'
|
||||
});
|
||||
}
|
||||
if (user.settings['filters.accounts']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.accounts'][0],
|
||||
type: 'ACCOUNT'
|
||||
});
|
||||
}
|
||||
|
||||
if (user.settings['filters.tags']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.tags'][0],
|
||||
type: 'TAG'
|
||||
});
|
||||
}
|
||||
if (user.settings['filters.assetClasses']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.assetClasses'][0],
|
||||
type: 'ASSET_CLASS'
|
||||
});
|
||||
}
|
||||
|
||||
if (user.settings['filters.tags']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.tags'][0],
|
||||
type: 'TAG'
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
|
@ -1,6 +1,16 @@
|
||||
{
|
||||
"createdAt": "2023-12-30T00:00:00.000Z",
|
||||
"createdAt": "2024-01-29T00:00:00.000Z",
|
||||
"data": [
|
||||
{
|
||||
"name": "Aptabase",
|
||||
"description": "Analytics for Apps, open source, simple and privacy-friendly. SDKs for Swift, React Native, Electron, Flutter and many others.",
|
||||
"href": "https://aptabase.com"
|
||||
},
|
||||
{
|
||||
"name": "Argos",
|
||||
"description": "Argos provides the developer tools to debug tests and detect visual regressions..",
|
||||
"href": "https://argos-ci.com"
|
||||
},
|
||||
{
|
||||
"name": "BoxyHQ",
|
||||
"description": "BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
|
||||
@ -81,6 +91,16 @@
|
||||
"description": "Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
|
||||
"href": "https://infisical.com"
|
||||
},
|
||||
{
|
||||
"name": "Langfuse",
|
||||
"description": "Open source LLM engineering platform. Debug, analyze and iterate together.",
|
||||
"href": "https://langfuse.com"
|
||||
},
|
||||
{
|
||||
"name": "Lost Pixel",
|
||||
"description": "Open source visual regression testing alternative to Percy & Chromatic",
|
||||
"href": "https://lost-pixel.com"
|
||||
},
|
||||
{
|
||||
"name": "Mockoon",
|
||||
"description": "Mockoon is the easiest and quickest way to design and run mock REST APIs.",
|
||||
|
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
File diff suppressed because it is too large
Load Diff
@ -379,6 +379,10 @@ ngx-skeleton-loader {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gf-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.gf-table {
|
||||
@include gf-table;
|
||||
}
|
||||
|
0
git-hooks/pre-commit
Executable file → Normal file
0
git-hooks/pre-commit
Executable file → Normal file
@ -1,4 +1,5 @@
|
||||
export interface DataProviderInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
isPremium: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ export interface HistoricalDataItem {
|
||||
averagePrice?: number;
|
||||
date: string;
|
||||
grossPerformancePercent?: number;
|
||||
investmentValueWithCurrencyEffect?: number;
|
||||
marketPrice?: number;
|
||||
netPerformance?: number;
|
||||
netPerformanceInPercentage?: number;
|
||||
|
@ -19,6 +19,7 @@ import type { FilterGroup } from './filter-group.interface';
|
||||
import type { Filter } from './filter.interface';
|
||||
import type { HistoricalDataItem } from './historical-data-item.interface';
|
||||
import type { InfoItem } from './info-item.interface';
|
||||
import type { InvestmentItem } from './investment-item.interface';
|
||||
import type { LineChartItem } from './line-chart-item.interface';
|
||||
import type { PortfolioChart } from './portfolio-chart.interface';
|
||||
import type { PortfolioDetails } from './portfolio-details.interface';
|
||||
@ -74,6 +75,7 @@ export {
|
||||
HistoricalDataItem,
|
||||
ImportResponse,
|
||||
InfoItem,
|
||||
InvestmentItem,
|
||||
LineChartItem,
|
||||
OAuthResponse,
|
||||
PortfolioChart,
|
||||
|
@ -14,7 +14,10 @@ export interface SymbolMetrics {
|
||||
hasErrors: boolean;
|
||||
initialValue: Big;
|
||||
initialValueWithCurrencyEffect: Big;
|
||||
investmentValues: {
|
||||
investmentValuesAccumulated: {
|
||||
[date: string]: Big;
|
||||
};
|
||||
investmentValuesAccumulatedWithCurrencyEffect: {
|
||||
[date: string]: Big;
|
||||
};
|
||||
investmentValuesWithCurrencyEffect: {
|
||||
|
@ -1,490 +0,0 @@
|
||||
<div *ngIf="hasPermissionToCreateActivity" class="d-flex justify-content-end">
|
||||
<button
|
||||
class="align-items-center d-flex"
|
||||
mat-stroked-button
|
||||
(click)="onImport()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-upload-outline" />
|
||||
<ng-container i18n>Import Activities</ng-container>...
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-stroked-button
|
||||
[matMenuTriggerFor]="activitiesMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical" />
|
||||
</button>
|
||||
<mat-menu #activitiesMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="dataSource?.data.length === 0"
|
||||
(click)="onImportDividends()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="color-wand-outline" />
|
||||
<ng-container i18n>Import Dividends</ng-container>...
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
[disabled]="dataSource?.data.length === 0"
|
||||
(click)="onExport()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="cloud-download-outline" />
|
||||
<span i18n>Export Activities</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
[disabled]="!hasDrafts"
|
||||
(click)="onExportDrafts()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="calendar-clear-outline" />
|
||||
<span i18n>Export Drafts as ICS</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="onDeleteAllActivities()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete all Activities</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
|
||||
<div class="activities">
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
mat-table
|
||||
matSort
|
||||
[dataSource]="dataSource"
|
||||
[matSortActive]="sortColumn"
|
||||
[matSortDirection]="sortDirection"
|
||||
[matSortDisabled]="sortDisabled"
|
||||
>
|
||||
<ng-container matColumnDef="select" sticky>
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
[checked]="
|
||||
areAllRowsSelected() && !hasErrors && selectedRows.hasValue()
|
||||
"
|
||||
[disabled]="hasErrors"
|
||||
[indeterminate]="selectedRows.hasValue() && !areAllRowsSelected()"
|
||||
(change)="$event ? toggleAllRows() : null"
|
||||
></mat-checkbox>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
[checked]="element.error ? false : selectedRows.isSelected(element)"
|
||||
[disabled]="element.error"
|
||||
(change)="$event ? selectedRows.toggle(element) : null"
|
||||
(click)="$event.stopPropagation()"
|
||||
></mat-checkbox>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="importStatus">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n></ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div
|
||||
*ngIf="element.error"
|
||||
class="d-flex"
|
||||
matTooltipPosition="above"
|
||||
[matTooltip]="element.error.message"
|
||||
>
|
||||
<ion-icon class="text-danger" name="alert-circle-outline" />
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="icon">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<gf-symbol-icon
|
||||
[dataSource]="element.SymbolProfile?.dataSource"
|
||||
[symbol]="element.SymbolProfile?.symbol"
|
||||
[tooltip]="element.SymbolProfile?.name"
|
||||
/>
|
||||
<div>{{ element.dataSource }}</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="nameWithSymbol">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
|
||||
<div class="d-flex align-items-center">
|
||||
<div>
|
||||
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
|
||||
<span
|
||||
*ngIf="element.isDraft"
|
||||
class="badge badge-secondary ml-1"
|
||||
i18n
|
||||
>Draft</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!isUUID(element.SymbolProfile?.symbol)">
|
||||
<small class="text-muted">{{
|
||||
element.SymbolProfile?.symbol | gfSymbol
|
||||
}}</small>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Type</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<gf-activity-type [activityType]="element.type" />
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Date</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
{{ element.date | date: defaultDateFormat }}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Quantity</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.quantity"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="unitPrice">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Unit Price</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.unitPrice"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fee">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Fee</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.fee"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
mat-header-cell
|
||||
>
|
||||
<ng-container i18n>Value</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.value"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
||||
<ng-container i18n>Currency</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
{{ element.SymbolProfile?.currency }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="valueInBaseCurrency">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-lg-none d-xl-none justify-content-end px-1"
|
||||
mat-header-cell
|
||||
>
|
||||
<ng-container i18n>Value</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-lg-none d-xl-none px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.valueInBaseCurrency"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<span class="d-none d-lg-block" i18n>Account</span>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
<gf-symbol-icon
|
||||
*ngIf="element.Account?.Platform?.url"
|
||||
class="mr-1"
|
||||
[tooltip]="element.Account?.Platform?.name"
|
||||
[url]="element.Account?.Platform?.url"
|
||||
/>
|
||||
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="comment">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-header-cell
|
||||
></th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<button
|
||||
*ngIf="element.comment"
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
title="Note"
|
||||
(click)="onOpenComment(element.comment); $event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="document-text-outline" />
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||
<button
|
||||
*ngIf="
|
||||
!hasPermissionToCreateActivity && hasPermissionToExportActivities
|
||||
"
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="activitiesMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical" />
|
||||
</button>
|
||||
<mat-menu #activitiesMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
*ngIf="hasPermissionToCreateActivity"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="onImport()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="cloud-upload-outline" />
|
||||
<ng-container i18n>Import Activities</ng-container>...
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToCreateActivity"
|
||||
mat-menu-item
|
||||
[disabled]="dataSource?.data.length === 0"
|
||||
(click)="onImportDividends()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="color-wand-outline" />
|
||||
<ng-container i18n>Import Dividends</ng-container>...
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
[disabled]="dataSource?.data.length === 0"
|
||||
(click)="onExport()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="cloud-download-outline" />
|
||||
<span i18n>Export Activities</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
[disabled]="!hasDrafts"
|
||||
(click)="onExportDrafts()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="calendar-clear-outline" />
|
||||
<span i18n>Export Drafts as ICS</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<button
|
||||
*ngIf="showActions"
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="activityMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #activityMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdateActivity(element)">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onCloneActivity(element)">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="copy-outline" />
|
||||
<span i18n>Clone</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="!element.isDraft"
|
||||
(click)="onExportDraft(element.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="calendar-clear-outline" />
|
||||
<span i18n>Export Draft as ICS</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeleteActivity(element.id)">
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
mat-row
|
||||
[ngClass]="{
|
||||
'cursor-pointer':
|
||||
hasPermissionToOpenDetails &&
|
||||
!row.isDraft &&
|
||||
row.type !== 'FEE' &&
|
||||
row.type !== 'INTEREST' &&
|
||||
row.type !== 'ITEM' &&
|
||||
row.type !== 'LIABILITY'
|
||||
}"
|
||||
(click)="onClickActivity(row)"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="px-4 py-3"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
/>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems"
|
||||
[ngClass]="{
|
||||
'd-none': (isLoading && !totalItems) || totalItems <= pageSize
|
||||
}"
|
||||
[pageIndex]="pageIndex"
|
||||
[pageSize]="pageSize"
|
||||
[showFirstLastButtons]="true"
|
||||
(page)="onChangePage($event)"
|
||||
></mat-paginator>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
dataSource?.data.length === 0 && hasPermissionToCreateActivity && !isLoading
|
||||
"
|
||||
class="p-3 text-center"
|
||||
>
|
||||
<gf-no-transactions-info-indicator [hasBorder]="false" />
|
||||
</div>
|
@ -1,9 +0,0 @@
|
||||
@import 'apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.activities {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
@ -1,241 +0,0 @@
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSort, Sort, SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
import { Subject, Subscription, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-activities-table-lazy',
|
||||
styleUrls: ['./activities-table-lazy.component.scss'],
|
||||
templateUrl: './activities-table-lazy.component.html'
|
||||
})
|
||||
export class ActivitiesTableLazyComponent
|
||||
implements AfterViewInit, OnChanges, OnDestroy, OnInit
|
||||
{
|
||||
@Input() baseCurrency: string;
|
||||
@Input() dataSource: MatTableDataSource<Activity>;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateActivity: boolean;
|
||||
@Input() hasPermissionToExportActivities: boolean;
|
||||
@Input() hasPermissionToOpenDetails = true;
|
||||
@Input() locale: string;
|
||||
@Input() pageIndex: number;
|
||||
@Input() pageSize = DEFAULT_PAGE_SIZE;
|
||||
@Input() showActions = true;
|
||||
@Input() showCheckbox = false;
|
||||
@Input() showFooter = true;
|
||||
@Input() showNameColumn = true;
|
||||
@Input() sortColumn: string;
|
||||
@Input() sortDirection: SortDirection;
|
||||
@Input() sortDisabled = false;
|
||||
@Input() totalItems = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@Output() activityDeleted = new EventEmitter<string>();
|
||||
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
|
||||
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
|
||||
@Output() deleteAllActivities = new EventEmitter<void>();
|
||||
@Output() export = new EventEmitter<string[]>();
|
||||
@Output() exportDrafts = new EventEmitter<string[]>();
|
||||
@Output() import = new EventEmitter<void>();
|
||||
@Output() importDividends = new EventEmitter<UniqueAsset>();
|
||||
@Output() pageChanged = new EventEmitter<PageEvent>();
|
||||
@Output() selectedActivities = new EventEmitter<Activity[]>();
|
||||
@Output() sortChanged = new EventEmitter<Sort>();
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public defaultDateFormat: string;
|
||||
public displayedColumns = [];
|
||||
public endOfToday = endOfToday();
|
||||
public hasDrafts = false;
|
||||
public hasErrors = false;
|
||||
public isAfter = isAfter;
|
||||
public isLoading = true;
|
||||
public isUUID = isUUID;
|
||||
public routeQueryParams: Subscription;
|
||||
public selectedRows = new SelectionModel<Activity>(true, []);
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(private router: Router) {}
|
||||
|
||||
public ngOnInit() {
|
||||
if (this.showCheckbox) {
|
||||
this.toggleAllRows();
|
||||
this.selectedRows.changed
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((selectedRows) => {
|
||||
this.selectedActivities.emit(selectedRows.source.selected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngAfterViewInit() {
|
||||
this.sort.sortChange.subscribe((value: Sort) => {
|
||||
this.sortChanged.emit(value);
|
||||
});
|
||||
}
|
||||
|
||||
public areAllRowsSelected() {
|
||||
const numSelectedRows = this.selectedRows.selected.length;
|
||||
const numTotalRows = this.dataSource.data.length;
|
||||
return numSelectedRows === numTotalRows;
|
||||
}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
|
||||
this.displayedColumns = [
|
||||
'select',
|
||||
'importStatus',
|
||||
'icon',
|
||||
'nameWithSymbol',
|
||||
'type',
|
||||
'date',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'fee',
|
||||
'value',
|
||||
'currency',
|
||||
'valueInBaseCurrency',
|
||||
'account',
|
||||
'comment',
|
||||
'actions'
|
||||
];
|
||||
|
||||
if (!this.showCheckbox) {
|
||||
this.displayedColumns = this.displayedColumns.filter((column) => {
|
||||
return column !== 'importStatus' && column !== 'select';
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.showNameColumn) {
|
||||
this.displayedColumns = this.displayedColumns.filter((column) => {
|
||||
return column !== 'nameWithSymbol';
|
||||
});
|
||||
}
|
||||
|
||||
if (this.dataSource) {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public onChangePage(page: PageEvent) {
|
||||
this.pageChanged.emit(page);
|
||||
}
|
||||
|
||||
public onClickActivity(activity: Activity) {
|
||||
if (this.showCheckbox) {
|
||||
if (!activity.error) {
|
||||
this.selectedRows.toggle(activity);
|
||||
}
|
||||
} else if (
|
||||
this.hasPermissionToOpenDetails &&
|
||||
!activity.isDraft &&
|
||||
activity.type !== 'FEE' &&
|
||||
activity.type !== 'INTEREST' &&
|
||||
activity.type !== 'ITEM' &&
|
||||
activity.type !== 'LIABILITY'
|
||||
) {
|
||||
this.onOpenPositionDialog({
|
||||
dataSource: activity.SymbolProfile.dataSource,
|
||||
symbol: activity.SymbolProfile.symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public onCloneActivity(aActivity: OrderWithAccount) {
|
||||
this.activityToClone.emit(aActivity);
|
||||
}
|
||||
|
||||
public onDeleteActivity(aId: string) {
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete this activity?`
|
||||
);
|
||||
|
||||
if (confirmation) {
|
||||
this.activityDeleted.emit(aId);
|
||||
}
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
this.export.emit();
|
||||
}
|
||||
|
||||
public onExportDraft(aActivityId: string) {
|
||||
this.exportDrafts.emit([aActivityId]);
|
||||
}
|
||||
|
||||
public onExportDrafts() {
|
||||
this.exportDrafts.emit(
|
||||
this.dataSource.filteredData
|
||||
.filter((activity) => {
|
||||
return activity.isDraft;
|
||||
})
|
||||
.map((activity) => {
|
||||
return activity.id;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public onDeleteAllActivities() {
|
||||
this.deleteAllActivities.emit();
|
||||
}
|
||||
|
||||
public onImport() {
|
||||
this.import.emit();
|
||||
}
|
||||
|
||||
public onImportDividends() {
|
||||
this.importDividends.emit();
|
||||
}
|
||||
|
||||
public onOpenComment(aComment: string) {
|
||||
alert(aComment);
|
||||
}
|
||||
|
||||
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
|
||||
this.router.navigate([], {
|
||||
queryParams: { dataSource, symbol, positionDetailDialog: true }
|
||||
});
|
||||
}
|
||||
|
||||
public onUpdateActivity(aActivity: OrderWithAccount) {
|
||||
this.activityToUpdate.emit(aActivity);
|
||||
}
|
||||
|
||||
public toggleAllRows() {
|
||||
this.areAllRowsSelected()
|
||||
? this.selectedRows.clear()
|
||||
: this.dataSource.data.forEach((row) => this.selectedRows.select(row));
|
||||
|
||||
this.selectedActivities.emit(this.selectedRows.selected);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfActivityTypeModule } from '@ghostfolio/ui/activity-type';
|
||||
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { ActivitiesTableLazyComponent } from './activities-table-lazy.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ActivitiesTableLazyComponent],
|
||||
exports: [ActivitiesTableLazyComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivityTypeModule,
|
||||
GfNoTransactionsInfoModule,
|
||||
GfSymbolIconModule,
|
||||
GfSymbolModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
MatTooltipModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfActivitiesTableLazyModule {}
|
@ -1,11 +1,3 @@
|
||||
<gf-activities-filter
|
||||
[allFilters]="allFilters"
|
||||
[isLoading]="isLoading"
|
||||
[ngClass]="{ 'd-none': !hasPermissionToFilter }"
|
||||
[placeholder]="placeholder"
|
||||
(valueChanged)="filters$.next($event)"
|
||||
/>
|
||||
|
||||
<div *ngIf="hasPermissionToCreateActivity" class="d-flex justify-content-end">
|
||||
<button
|
||||
class="align-items-center d-flex"
|
||||
@ -27,7 +19,7 @@
|
||||
<mat-menu #activitiesMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="dataSource.data.length === 0"
|
||||
[disabled]="dataSource?.data.length === 0"
|
||||
(click)="onImportDividends()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
@ -39,7 +31,7 @@
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
[disabled]="dataSource.data.length === 0"
|
||||
[disabled]="dataSource?.data.length === 0"
|
||||
(click)="onExport()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
@ -77,11 +69,12 @@
|
||||
class="gf-table w-100"
|
||||
mat-table
|
||||
matSort
|
||||
matSortActive="date"
|
||||
matSortDirection="desc"
|
||||
[dataSource]="dataSource"
|
||||
[matSortActive]="sortColumn"
|
||||
[matSortDirection]="sortDirection"
|
||||
[matSortDisabled]="sortDisabled"
|
||||
>
|
||||
<ng-container matColumnDef="select">
|
||||
<ng-container matColumnDef="select" sticky>
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
@ -102,7 +95,6 @@
|
||||
(click)="$event.stopPropagation()"
|
||||
></mat-checkbox>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="importStatus">
|
||||
@ -119,67 +111,26 @@
|
||||
<ion-icon class="text-danger" name="alert-circle-outline" />
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="count">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
i18n
|
||||
mat-header-cell
|
||||
></th>
|
||||
<td
|
||||
*matCellDef="let element; let i = index"
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
mat-cell
|
||||
>
|
||||
{{
|
||||
dataSource.data.length > pageSize
|
||||
? dataSource.data.length - pageSize * pageIndex - i
|
||||
: dataSource.data.length - i
|
||||
}}
|
||||
<ng-container matColumnDef="icon">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<gf-symbol-icon
|
||||
[dataSource]="element.SymbolProfile?.dataSource"
|
||||
[symbol]="element.SymbolProfile?.symbol"
|
||||
[tooltip]="element.SymbolProfile?.name"
|
||||
/>
|
||||
<div>{{ element.dataSource }}</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Date</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
{{ element.date | date: defaultDateFormat }}
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Type</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<gf-activity-type [activityType]="element.type" />
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="nameWithSymbol">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header="SymbolProfile.symbol"
|
||||
>
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
|
||||
<div class="d-flex align-items-center">
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="align-items-center d-flex line-height-1">
|
||||
<div>
|
||||
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
|
||||
<span
|
||||
@ -196,27 +147,25 @@
|
||||
}}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header="SymbolProfile.currency"
|
||||
>
|
||||
<ng-container i18n>Currency</ng-container>
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Type</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
{{ element.SymbolProfile?.currency }}
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<gf-activity-type [activityType]="element.type" />
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
{{ baseCurrency }}
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Date</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
{{ element.date | date: defaultDateFormat }}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -242,11 +191,6 @@
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="unitPrice">
|
||||
@ -271,11 +215,6 @@
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fee">
|
||||
@ -300,15 +239,6 @@
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalFees"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
@ -316,7 +246,6 @@
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Value</ng-container>
|
||||
</th>
|
||||
@ -333,16 +262,18 @@
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
*ngIf="totalValue !== null"
|
||||
[isAbsolute]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalValue"
|
||||
/>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
||||
<ng-container i18n>Currency</ng-container>
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
{{ element.SymbolProfile?.currency }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -351,7 +282,6 @@
|
||||
*matHeaderCellDef
|
||||
class="d-lg-none d-xl-none justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Value</ng-container>
|
||||
</th>
|
||||
@ -364,26 +294,10 @@
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-lg-none d-xl-none px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
*ngIf="totalValue !== null"
|
||||
[isAbsolute]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalValue"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header="Account.name"
|
||||
>
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<span class="d-none d-lg-block" i18n>Account</span>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@ -397,7 +311,6 @@
|
||||
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="comment">
|
||||
@ -421,11 +334,6 @@
|
||||
<ion-icon name="document-text-outline" />
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
@ -456,7 +364,7 @@
|
||||
<button
|
||||
*ngIf="hasPermissionToCreateActivity"
|
||||
mat-menu-item
|
||||
[disabled]="dataSource.data.length === 0"
|
||||
[disabled]="dataSource?.data.length === 0"
|
||||
(click)="onImportDividends()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
@ -468,7 +376,7 @@
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
[disabled]="dataSource.data.length === 0"
|
||||
[disabled]="dataSource?.data.length === 0"
|
||||
(click)="onExport()"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
@ -531,7 +439,6 @@
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
@ -549,28 +456,9 @@
|
||||
}"
|
||||
(click)="onClickActivity(row)"
|
||||
></tr>
|
||||
<tr
|
||||
*matFooterRowDef="displayedColumns"
|
||||
mat-footer-row
|
||||
[ngClass]="{
|
||||
'd-none':
|
||||
isLoading || dataSource.data.length === 0 || showFooter === false
|
||||
}"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[ngClass]="{
|
||||
'd-none':
|
||||
(isLoading && dataSource.data.length === 0) ||
|
||||
dataSource.data.length <= pageSize
|
||||
}"
|
||||
[pageSize]="pageSize"
|
||||
[showFirstLastButtons]="true"
|
||||
(page)="onChangePage($event)"
|
||||
></mat-paginator>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
@ -581,9 +469,20 @@
|
||||
}"
|
||||
/>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems"
|
||||
[ngClass]="{
|
||||
'd-none': (isLoading && !totalItems) || totalItems <= pageSize
|
||||
}"
|
||||
[pageIndex]="pageIndex"
|
||||
[pageSize]="pageSize"
|
||||
[showFirstLastButtons]="true"
|
||||
(page)="onChangePage($event)"
|
||||
></mat-paginator>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
dataSource.data.length === 0 && hasPermissionToCreateActivity && !isLoading
|
||||
dataSource?.data.length === 0 && hasPermissionToCreateActivity && !isLoading
|
||||
"
|
||||
class="p-3 text-center"
|
||||
>
|
||||
|
@ -5,15 +5,5 @@
|
||||
|
||||
.activities {
|
||||
overflow-x: auto;
|
||||
|
||||
.mat-mdc-table {
|
||||
th {
|
||||
::ng-deep {
|
||||
.mat-sort-header-container {
|
||||
justify-content: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
@ -11,20 +12,17 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatSort, Sort, SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import Big from 'big.js';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { endOfToday, format, isAfter } from 'date-fns';
|
||||
import { get, isNumber } from 'lodash';
|
||||
import { Subject, Subscription, distinctUntilChanged, takeUntil } from 'rxjs';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
import { Subject, Subscription, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@ -32,63 +30,56 @@ import { Subject, Subscription, distinctUntilChanged, takeUntil } from 'rxjs';
|
||||
styleUrls: ['./activities-table.component.scss'],
|
||||
templateUrl: './activities-table.component.html'
|
||||
})
|
||||
export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Input() activities: Activity[];
|
||||
export class ActivitiesTableComponent
|
||||
implements AfterViewInit, OnChanges, OnDestroy, OnInit
|
||||
{
|
||||
@Input() baseCurrency: string;
|
||||
@Input() dataSource: MatTableDataSource<Activity>;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateActivity: boolean;
|
||||
@Input() hasPermissionToExportActivities: boolean;
|
||||
@Input() hasPermissionToFilter = true;
|
||||
@Input() hasPermissionToOpenDetails = true;
|
||||
@Input() locale: string;
|
||||
@Input() pageIndex: number;
|
||||
@Input() pageSize = DEFAULT_PAGE_SIZE;
|
||||
@Input() showActions = true;
|
||||
@Input() showCheckbox = false;
|
||||
@Input() showFooter = true;
|
||||
@Input() showNameColumn = true;
|
||||
@Input() sortColumn: string;
|
||||
@Input() sortDirection: SortDirection;
|
||||
@Input() sortDisabled = false;
|
||||
@Input() totalItems = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@Output() activityDeleted = new EventEmitter<string>();
|
||||
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
|
||||
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
|
||||
@Output() deleteAllActivities = new EventEmitter<void>();
|
||||
@Output() export = new EventEmitter<string[]>();
|
||||
@Output() export = new EventEmitter<void>();
|
||||
@Output() exportDrafts = new EventEmitter<string[]>();
|
||||
@Output() import = new EventEmitter<void>();
|
||||
@Output() importDividends = new EventEmitter<UniqueAsset>();
|
||||
@Output() pageChanged = new EventEmitter<PageEvent>();
|
||||
@Output() selectedActivities = new EventEmitter<Activity[]>();
|
||||
@Output() sortChanged = new EventEmitter<Sort>();
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public allFilters: Filter[];
|
||||
public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
|
||||
public defaultDateFormat: string;
|
||||
public displayedColumns = [];
|
||||
public endOfToday = endOfToday();
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public hasDrafts = false;
|
||||
public hasErrors = false;
|
||||
public isAfter = isAfter;
|
||||
public isLoading = true;
|
||||
public isUUID = isUUID;
|
||||
public pageIndex = 0;
|
||||
public placeholder = '';
|
||||
public routeQueryParams: Subscription;
|
||||
public searchKeywords: string[] = [];
|
||||
public selectedRows = new SelectionModel<Activity>(true, []);
|
||||
public totalFees: number;
|
||||
public totalValue: number;
|
||||
|
||||
private readonly SEARCH_STRING_SEPARATOR = ',';
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(private router: Router) {
|
||||
this.filters$
|
||||
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((filters) => {
|
||||
this.updateFilters(filters);
|
||||
});
|
||||
}
|
||||
public constructor(private router: Router) {}
|
||||
|
||||
public ngOnInit() {
|
||||
if (this.showCheckbox) {
|
||||
@ -101,6 +92,12 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public ngAfterViewInit() {
|
||||
this.sort.sortChange.subscribe((value: Sort) => {
|
||||
this.sortChanged.emit(value);
|
||||
});
|
||||
}
|
||||
|
||||
public areAllRowsSelected() {
|
||||
const numSelectedRows = this.selectedRows.selected.length;
|
||||
const numTotalRows = this.dataSource.data.length;
|
||||
@ -108,13 +105,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
|
||||
this.displayedColumns = [
|
||||
'select',
|
||||
'importStatus',
|
||||
'count',
|
||||
'date',
|
||||
'type',
|
||||
'icon',
|
||||
'nameWithSymbol',
|
||||
'type',
|
||||
'date',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'fee',
|
||||
@ -126,11 +125,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
'actions'
|
||||
];
|
||||
|
||||
if (this.showCheckbox) {
|
||||
this.displayedColumns = this.displayedColumns.filter((column) => {
|
||||
return column !== 'count';
|
||||
});
|
||||
} else {
|
||||
if (!this.showCheckbox) {
|
||||
this.displayedColumns = this.displayedColumns.filter((column) => {
|
||||
return column !== 'importStatus' && column !== 'select';
|
||||
});
|
||||
@ -142,60 +137,13 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
|
||||
if (this.activities) {
|
||||
this.activities = this.activities.map((activity) => {
|
||||
return {
|
||||
...activity,
|
||||
error: activity.error
|
||||
? {
|
||||
...activity.error,
|
||||
message: translate(
|
||||
`IMPORT_ACTIVITY_ERROR_${activity.error.code}`
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
});
|
||||
|
||||
this.allFilters = this.getSearchableFieldValues(this.activities);
|
||||
|
||||
this.dataSource = new MatTableDataSource(this.activities);
|
||||
this.dataSource.filterPredicate = (data, filter) => {
|
||||
const filterableLabels = this.getFilterableValues(data).map(
|
||||
({ label }) => {
|
||||
return label.toLowerCase();
|
||||
}
|
||||
);
|
||||
|
||||
let includes = true;
|
||||
for (const singleFilter of filter.split(this.SEARCH_STRING_SEPARATOR)) {
|
||||
includes =
|
||||
includes &&
|
||||
filterableLabels.includes(singleFilter.trim().toLowerCase());
|
||||
}
|
||||
return includes;
|
||||
};
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.dataSource.sortingDataAccessor = get;
|
||||
|
||||
this.updateFilters();
|
||||
|
||||
this.hasErrors = this.activities.some(({ error }) => {
|
||||
return !!error;
|
||||
});
|
||||
} else {
|
||||
this.hasErrors = false;
|
||||
if (this.dataSource) {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public onChangePage(page: PageEvent) {
|
||||
this.pageIndex = page.pageIndex;
|
||||
|
||||
this.totalFees = this.getTotalFees();
|
||||
this.totalValue = this.getTotalValue();
|
||||
this.pageChanged.emit(page);
|
||||
}
|
||||
|
||||
public onClickActivity(activity: Activity) {
|
||||
@ -233,15 +181,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
if (this.searchKeywords.length > 0) {
|
||||
this.export.emit(
|
||||
this.dataSource.filteredData.map((activity) => {
|
||||
return activity.id;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.export.emit();
|
||||
}
|
||||
this.export.emit();
|
||||
}
|
||||
|
||||
public onExportDraft(aActivityId: string) {
|
||||
@ -298,145 +238,4 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private getFilterableValues(
|
||||
activity: OrderWithAccount,
|
||||
fieldValueMap: { [id: string]: Filter } = {}
|
||||
): Filter[] {
|
||||
if (activity.Account?.id) {
|
||||
fieldValueMap[activity.Account.id] = {
|
||||
id: activity.Account.id,
|
||||
label: activity.Account.name,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}
|
||||
|
||||
if (activity.SymbolProfile?.currency) {
|
||||
fieldValueMap[activity.SymbolProfile.currency] = {
|
||||
id: activity.SymbolProfile.currency,
|
||||
label: activity.SymbolProfile.currency,
|
||||
type: 'TAG'
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
activity.SymbolProfile?.symbol &&
|
||||
!isUUID(activity.SymbolProfile.symbol)
|
||||
) {
|
||||
fieldValueMap[activity.SymbolProfile.symbol] = {
|
||||
id: activity.SymbolProfile.symbol,
|
||||
label: activity.SymbolProfile.symbol,
|
||||
type: 'SYMBOL'
|
||||
};
|
||||
}
|
||||
|
||||
fieldValueMap[activity.type] = {
|
||||
id: activity.type,
|
||||
label: activity.type,
|
||||
type: 'TAG'
|
||||
};
|
||||
|
||||
fieldValueMap[format(new Date(activity.date), 'yyyy')] = {
|
||||
id: format(new Date(activity.date), 'yyyy'),
|
||||
label: format(new Date(activity.date), 'yyyy'),
|
||||
type: 'TAG'
|
||||
};
|
||||
|
||||
return Object.values(fieldValueMap);
|
||||
}
|
||||
|
||||
private getPaginatedData() {
|
||||
if (this.dataSource.data.length > this.pageSize) {
|
||||
const sortedData = this.dataSource.sortData(
|
||||
this.dataSource.filteredData,
|
||||
this.dataSource.sort
|
||||
);
|
||||
|
||||
return sortedData.slice(
|
||||
this.pageIndex * this.pageSize,
|
||||
(this.pageIndex + 1) * this.pageSize
|
||||
);
|
||||
}
|
||||
return this.dataSource.filteredData;
|
||||
}
|
||||
|
||||
private getSearchableFieldValues(activities: OrderWithAccount[]): Filter[] {
|
||||
const fieldValueMap: { [id: string]: Filter } = {};
|
||||
|
||||
for (const activity of activities) {
|
||||
this.getFilterableValues(activity, fieldValueMap);
|
||||
}
|
||||
|
||||
return Object.values(fieldValueMap);
|
||||
}
|
||||
|
||||
private getTotalFees() {
|
||||
let totalFees = new Big(0);
|
||||
const paginatedData = this.getPaginatedData();
|
||||
for (const activity of paginatedData) {
|
||||
if (isNumber(activity.feeInBaseCurrency)) {
|
||||
totalFees = totalFees.plus(activity.feeInBaseCurrency);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return totalFees.toNumber();
|
||||
}
|
||||
|
||||
private getTotalValue() {
|
||||
const paginatedData = this.getPaginatedData();
|
||||
let totalValue = new Big(0);
|
||||
|
||||
for (const { type, valueInBaseCurrency } of paginatedData) {
|
||||
if (isNumber(valueInBaseCurrency)) {
|
||||
if (type === 'BUY' || type === 'ITEM') {
|
||||
totalValue = totalValue.plus(valueInBaseCurrency);
|
||||
} else if (
|
||||
type === 'DIVIDEND' ||
|
||||
type === 'FEE' ||
|
||||
type === 'INTEREST' ||
|
||||
type === 'LIABILITY' ||
|
||||
type === 'SELL'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return totalValue.toNumber();
|
||||
}
|
||||
|
||||
private updateFilters(filters: Filter[] = []) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.dataSource.filter = filters
|
||||
.map((filter) => {
|
||||
return filter.label;
|
||||
})
|
||||
.join(this.SEARCH_STRING_SEPARATOR);
|
||||
|
||||
const lowercaseSearchKeywords = filters.map((filter) => {
|
||||
return filter.label.trim().toLowerCase();
|
||||
});
|
||||
|
||||
this.placeholder =
|
||||
lowercaseSearchKeywords.length <= 0
|
||||
? $localize`Filter by account, currency, symbol or type...`
|
||||
: '';
|
||||
|
||||
this.searchKeywords = filters.map((filter) => {
|
||||
return filter.label;
|
||||
});
|
||||
|
||||
this.hasDrafts = this.dataSource.filteredData.some((activity) => {
|
||||
return activity.isDraft === true;
|
||||
});
|
||||
this.totalFees = this.getTotalFees();
|
||||
this.totalValue = this.getTotalValue();
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
import { GfActivityTypeModule } from '@ghostfolio/ui/activity-type';
|
||||
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
@ -23,7 +22,6 @@ import { ActivitiesTableComponent } from './activities-table.component';
|
||||
exports: [ActivitiesTableComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesFilterModule,
|
||||
GfActivityTypeModule,
|
||||
GfNoTransactionsInfoModule,
|
||||
GfSymbolIconModule,
|
||||
|
@ -22,7 +22,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Filter, User } from '@ghostfolio/common/interfaces';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
import { Account, AssetClass } from '@prisma/client';
|
||||
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
@ -92,6 +92,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
|
||||
|
||||
public accounts: Account[] = [];
|
||||
public assetClasses: Filter[] = [];
|
||||
public dateRangeFormControl = new FormControl<string>(undefined);
|
||||
public readonly dateRangeOptions = [
|
||||
{ label: $localize`Today`, value: '1d' },
|
||||
@ -107,12 +108,16 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
label: $localize`Year to date` + ' (' + $localize`YTD` + ')',
|
||||
value: 'ytd'
|
||||
},
|
||||
{ label: $localize`1Y`, value: '1y' },
|
||||
{ label: $localize`5Y`, value: '5y' },
|
||||
{ label: '1 ' + $localize`year` + ' (' + $localize`1Y` + ')', value: '1y' },
|
||||
{
|
||||
label: '5 ' + $localize`years` + ' (' + $localize`5Y` + ')',
|
||||
value: '5y'
|
||||
},
|
||||
{ label: $localize`Max`, value: 'max' }
|
||||
];
|
||||
public filterForm = this.formBuilder.group({
|
||||
account: new FormControl<string>(undefined),
|
||||
assetClass: new FormControl<string>(undefined),
|
||||
tag: new FormControl<string>(undefined)
|
||||
});
|
||||
public isLoading = false;
|
||||
@ -123,8 +128,9 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
assetProfiles: [],
|
||||
holdings: []
|
||||
};
|
||||
public tags: Tag[] = [];
|
||||
public tags: Filter[] = [];
|
||||
|
||||
private filterTypes: Filter['type'][] = ['ACCOUNT', 'ASSET_CLASS', 'TAG'];
|
||||
private keyManager: FocusKeyManager<AssistantListItemComponent>;
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -136,32 +142,21 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
const { tags } = this.dataService.fetchInfo();
|
||||
|
||||
this.accounts = this.user?.accounts;
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
this.assetClasses = Object.keys(AssetClass).map((assetClass) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
id: assetClass,
|
||||
label: translate(assetClass),
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
});
|
||||
this.tags = this.user?.tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
label: translate(name),
|
||||
type: 'TAG'
|
||||
};
|
||||
});
|
||||
|
||||
this.filterForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ account, tag }) => {
|
||||
this.filtersChanged.emit([
|
||||
{
|
||||
id: account,
|
||||
type: 'ACCOUNT'
|
||||
},
|
||||
{
|
||||
id: tag,
|
||||
type: 'TAG'
|
||||
}
|
||||
]);
|
||||
|
||||
this.onCloseAssistant();
|
||||
});
|
||||
|
||||
this.searchFormControl.valueChanges
|
||||
.pipe(
|
||||
@ -208,6 +203,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
this.filterForm.setValue(
|
||||
{
|
||||
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
|
||||
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null,
|
||||
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
|
||||
},
|
||||
{
|
||||
@ -245,6 +241,25 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
public onApplyFilters() {
|
||||
this.filtersChanged.emit([
|
||||
{
|
||||
id: this.filterForm.get('account').value,
|
||||
type: 'ACCOUNT'
|
||||
},
|
||||
{
|
||||
id: this.filterForm.get('assetClass').value,
|
||||
type: 'ASSET_CLASS'
|
||||
},
|
||||
{
|
||||
id: this.filterForm.get('tag').value,
|
||||
type: 'TAG'
|
||||
}
|
||||
]);
|
||||
|
||||
this.onCloseAssistant();
|
||||
}
|
||||
|
||||
public onChangeDateRange(dateRangeString: string) {
|
||||
this.dateRangeChanged.emit(dateRangeString as DateRange);
|
||||
}
|
||||
@ -256,16 +271,14 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onResetFilters() {
|
||||
this.filtersChanged.emit([
|
||||
{
|
||||
id: null,
|
||||
type: 'ACCOUNT'
|
||||
},
|
||||
{
|
||||
id: null,
|
||||
type: 'TAG'
|
||||
}
|
||||
]);
|
||||
this.filtersChanged.emit(
|
||||
this.filterTypes.map((type) => {
|
||||
return {
|
||||
type,
|
||||
id: null
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this.onCloseAssistant();
|
||||
}
|
||||
|
@ -1,169 +1,172 @@
|
||||
<div
|
||||
[style.width]="deviceType === 'mobile' ? '85vw' : '30rem'"
|
||||
(click)="$event.stopPropagation();"
|
||||
(keydown.tab)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="align-items-center d-flex search-container">
|
||||
<ion-icon class="ml-2 mr-0" name="search-outline" />
|
||||
<input
|
||||
#search
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class="border-0 p-2 w-100"
|
||||
name="search"
|
||||
type="text"
|
||||
[formControl]="searchFormControl"
|
||||
[placeholder]="placeholder"
|
||||
/>
|
||||
<div
|
||||
*ngIf="deviceType !== 'mobile' && !searchFormControl.value"
|
||||
class="hot-key-hint mx-1 px-1"
|
||||
>
|
||||
/
|
||||
</div>
|
||||
<button
|
||||
*ngIf="searchFormControl.value"
|
||||
class="h-100 no-min-width px-3 rounded-0"
|
||||
mat-button
|
||||
(click)="initialize()"
|
||||
>
|
||||
<ion-icon class="m-0" name="close-circle-outline" />
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!searchFormControl.value"
|
||||
class="h-100 no-min-width px-3 rounded-0"
|
||||
mat-button
|
||||
(click)="onCloseAssistant()"
|
||||
>
|
||||
<ion-icon class="m-0" name="close-outline" />
|
||||
</button>
|
||||
</div>
|
||||
<div (click)="$event.stopPropagation()">
|
||||
<div
|
||||
*ngIf="isLoading || searchFormControl.value"
|
||||
class="overflow-auto py-3 result-container"
|
||||
[style.width]="deviceType === 'mobile' ? '85vw' : '30rem'"
|
||||
(keydown.tab)="$event.stopPropagation()"
|
||||
>
|
||||
<div>
|
||||
<div class="h6 mb-1 px-2" i18n>Holdings</div>
|
||||
<gf-assistant-list-item
|
||||
*ngFor="let searchResultItem of searchResults?.holdings"
|
||||
mode="holding"
|
||||
[item]="searchResultItem"
|
||||
(clicked)="onCloseAssistant()"
|
||||
<div class="align-items-center d-flex search-container">
|
||||
<ion-icon class="ml-2 mr-0" name="search-outline" />
|
||||
<input
|
||||
#search
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
class="border-0 p-2 w-100"
|
||||
name="search"
|
||||
type="text"
|
||||
[formControl]="searchFormControl"
|
||||
[placeholder]="placeholder"
|
||||
/>
|
||||
<ng-container *ngIf="searchResults?.holdings?.length === 0">
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="mx-2"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
/>
|
||||
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionToAccessAdminControl" class="mt-3">
|
||||
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
|
||||
<gf-assistant-list-item
|
||||
*ngFor="let searchResultItem of searchResults?.assetProfiles"
|
||||
mode="assetProfile"
|
||||
[item]="searchResultItem"
|
||||
(clicked)="onCloseAssistant()"
|
||||
/>
|
||||
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="mx-2"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
/>
|
||||
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form [formGroup]="filterForm">
|
||||
<div
|
||||
*ngIf="!(isLoading || searchFormControl.value) && user?.settings?.isExperimentalFeatures"
|
||||
class="filter-container"
|
||||
>
|
||||
<div class="p-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Date Range</mat-label>
|
||||
<mat-select
|
||||
[formControl]="dateRangeFormControl"
|
||||
(selectionChange)="onChangeDateRange($event.value)"
|
||||
>
|
||||
@for (range of dateRangeOptions; track range) {
|
||||
<mat-option [value]="range.value">{{ range.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-tab-group
|
||||
animationDuration="0"
|
||||
mat-align-tabs="start"
|
||||
[mat-stretch-tabs]="false"
|
||||
(click)="$event.stopPropagation();"
|
||||
>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label
|
||||
><ion-icon name="albums-outline" /><span
|
||||
class="d-none d-sm-block ml-2"
|
||||
i18n
|
||||
>Accounts</span
|
||||
></ng-template
|
||||
>
|
||||
<div class="p-3">
|
||||
<mat-radio-group color="primary" formControlName="account">
|
||||
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
|
||||
>No account</mat-radio-button
|
||||
>
|
||||
@for (account of accounts; track account.id) {
|
||||
<mat-radio-button class="d-flex flex-column" [value]="account.id"
|
||||
>{{ account.name }}</mat-radio-button
|
||||
>
|
||||
}
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label
|
||||
><ion-icon name="pricetag-outline" /><span
|
||||
class="d-none d-sm-block ml-2"
|
||||
i18n
|
||||
>Tags</span
|
||||
></ng-template
|
||||
>
|
||||
<div class="p-3">
|
||||
<mat-radio-group color="primary" formControlName="tag">
|
||||
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
|
||||
>No tag</mat-radio-button
|
||||
>
|
||||
@for (tag of tags; track tag.id) {
|
||||
<mat-radio-button class="d-flex flex-column" [value]="tag.id"
|
||||
>{{ tag.name }}</mat-radio-button
|
||||
>
|
||||
}
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
<div class="p-3">
|
||||
<button
|
||||
class="w-100"
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!hasFilter(filterForm.value)"
|
||||
(click)="onResetFilters()"
|
||||
<div
|
||||
*ngIf="deviceType !== 'mobile' && !searchFormControl.value"
|
||||
class="hot-key-hint mx-1 px-1"
|
||||
>
|
||||
Reset Filters
|
||||
/
|
||||
</div>
|
||||
<button
|
||||
*ngIf="searchFormControl.value"
|
||||
class="h-100 no-min-width px-3 rounded-0"
|
||||
mat-button
|
||||
(click)="initialize()"
|
||||
>
|
||||
<ion-icon class="m-0" name="close-circle-outline" />
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!searchFormControl.value"
|
||||
class="h-100 no-min-width px-3 rounded-0"
|
||||
mat-button
|
||||
(click)="onCloseAssistant()"
|
||||
>
|
||||
<ion-icon class="m-0" name="close-outline" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="isLoading || searchFormControl.value"
|
||||
class="overflow-auto py-3 result-container"
|
||||
>
|
||||
<div>
|
||||
<div class="h6 mb-1 px-2" i18n>Holdings</div>
|
||||
<gf-assistant-list-item
|
||||
*ngFor="let searchResultItem of searchResults?.holdings"
|
||||
mode="holding"
|
||||
[item]="searchResultItem"
|
||||
(clicked)="onCloseAssistant()"
|
||||
/>
|
||||
<ng-container *ngIf="searchResults?.holdings?.length === 0">
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="mx-2"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
/>
|
||||
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionToAccessAdminControl" class="mt-3">
|
||||
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
|
||||
<gf-assistant-list-item
|
||||
*ngFor="let searchResultItem of searchResults?.assetProfiles"
|
||||
mode="assetProfile"
|
||||
[item]="searchResultItem"
|
||||
(clicked)="onCloseAssistant()"
|
||||
/>
|
||||
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="mx-2"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
/>
|
||||
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form [formGroup]="filterForm">
|
||||
<ng-container *ngIf="!(isLoading || searchFormControl.value)">
|
||||
<div class="date-range-selector-container p-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Date Range</mat-label>
|
||||
<mat-select
|
||||
[formControl]="dateRangeFormControl"
|
||||
(selectionChange)="onChangeDateRange($event.value)"
|
||||
>
|
||||
@for (range of dateRangeOptions; track range) {
|
||||
<mat-option [value]="range.value">{{ range.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Accounts</mat-label>
|
||||
<mat-select formControlName="account">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
@for (account of accounts; track account.id) {
|
||||
<mat-option [value]="account.id">
|
||||
<div class="d-flex">
|
||||
<gf-symbol-icon
|
||||
*ngIf="account.Platform?.url"
|
||||
class="mr-1"
|
||||
[tooltip]="account.Platform?.name"
|
||||
[url]="account.Platform?.url"
|
||||
/><span>{{ account.name }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-select formControlName="tag">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
@for (tag of tags; track tag.id) {
|
||||
<mat-option [value]="tag.id">{{ tag.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Asset Classes</mat-label>
|
||||
<mat-select formControlName="assetClass">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
@for (assetClass of assetClasses; track assetClass.id) {
|
||||
<mat-option [value]="assetClass.id"
|
||||
>{{ assetClass.label }}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-flex w-100">
|
||||
<button
|
||||
i18n
|
||||
mat-button
|
||||
[disabled]="!hasFilter(filterForm.value)"
|
||||
(click)="onResetFilters()"
|
||||
>
|
||||
Reset Filters
|
||||
</button>
|
||||
<span class="gf-spacer"></span>
|
||||
<button
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!filterForm.dirty"
|
||||
(click)="onApplyFilters()"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -3,10 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfAssistantListItemModule } from './assistant-list-item/assistant-list-item.module';
|
||||
@ -19,11 +18,10 @@ import { AssistantComponent } from './assistant.component';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfAssistantListItemModule,
|
||||
GfSymbolIconModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatRadioModule,
|
||||
MatSelectModule,
|
||||
MatTabsModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
|
@ -1,16 +1,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.filter-container {
|
||||
.mat-mdc-tab-group {
|
||||
max-height: 20vh;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.date-range-selector-container {
|
||||
border-bottom: 1px solid rgba(var(--dark-dividers));
|
||||
}
|
||||
|
||||
.result-container {
|
||||
@ -35,6 +27,10 @@
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.date-range-selector-container {
|
||||
border-color: rgba(var(--light-dividers));
|
||||
}
|
||||
|
||||
.search-container {
|
||||
border-color: rgba(var(--light-dividers));
|
||||
|
||||
|
@ -15,12 +15,15 @@
|
||||
<mat-option
|
||||
*ngFor="let lookupItem of filteredLookupItems"
|
||||
class="line-height-1"
|
||||
[disabled]="lookupItem.dataProviderInfo.isPremium"
|
||||
[value]="lookupItem"
|
||||
>
|
||||
<span
|
||||
><b>{{ lookupItem.name }}</b></span
|
||||
>
|
||||
<br />
|
||||
<span class="align-items-center d-flex line-height-1"
|
||||
><b>{{ lookupItem.name }}</b>
|
||||
@if (lookupItem.dataProviderInfo.isPremium) {
|
||||
<gf-premium-indicator class="ml-1" [enableLink]="false" />
|
||||
}
|
||||
</span>
|
||||
<small class="text-muted"
|
||||
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
|
||||
}}<ng-container *ngIf="lookupItem.assetSubClass">
|
||||
|
@ -7,6 +7,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { SymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.component';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
|
||||
@NgModule({
|
||||
declarations: [SymbolAutocompleteComponent],
|
||||
@ -14,6 +15,7 @@ import { SymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete/
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfPremiumIndicatorModule,
|
||||
GfSymbolModule,
|
||||
MatAutocompleteModule,
|
||||
MatFormFieldModule,
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "2.46.0",
|
||||
"version": "2.49.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/ghostfolio/ghostfolio",
|
||||
@ -133,7 +133,7 @@
|
||||
"svgmap": "2.6.0",
|
||||
"twitter-api-v2": "1.14.2",
|
||||
"uuid": "9.0.1",
|
||||
"yahoo-finance2": "2.9.0",
|
||||
"yahoo-finance2": "2.9.1",
|
||||
"zone.js": "0.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -191,7 +191,7 @@
|
||||
"jest-environment-jsdom": "29.4.3",
|
||||
"jest-preset-angular": "13.1.4",
|
||||
"nx": "17.2.8",
|
||||
"prettier": "3.2.1",
|
||||
"prettier": "3.2.5",
|
||||
"prettier-plugin-organize-attributes": "1.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
|
16
yarn.lock
16
yarn.lock
@ -16945,10 +16945,10 @@ prettier-plugin-organize-attributes@1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/prettier-plugin-organize-attributes/-/prettier-plugin-organize-attributes-1.0.0.tgz#037870ee3111b3c1d6371f677b64888de353cc63"
|
||||
integrity sha512-+NmameaLxbCcylEXsKPmawtzla5EE6ECqvGkpfQz4KM847fXDifB1gFnPQEpoADAq6IXg+cMI8Z0ISJEXa6fhg==
|
||||
|
||||
prettier@3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.1.tgz#babf33580e16c796a9740b9fae551624f7bfeaab"
|
||||
integrity sha512-qSUWshj1IobVbKc226Gw2pync27t0Kf0EdufZa9j7uBSJay1CC+B3K5lAAZoqgX3ASiKuWsk6OmzKRetXNObWg==
|
||||
prettier@3.2.5:
|
||||
version "3.2.5"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
|
||||
integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==
|
||||
|
||||
prettier@^2.8.0:
|
||||
version "2.8.8"
|
||||
@ -20184,10 +20184,10 @@ y18n@^5.0.5:
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
||||
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
|
||||
|
||||
yahoo-finance2@2.9.0:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.9.0.tgz#7842580de36606197f7d64897dd2e5e55b9371d3"
|
||||
integrity sha512-Q1UhB5uA0Uj2bBcSDqsZLt0tCxoHwrWCuvu4NMUgioyN8dlpq8ppbdKhZlzTD9ipIyKSgqG5TT7IlwB1x6eHZA==
|
||||
yahoo-finance2@2.9.1:
|
||||
version "2.9.1"
|
||||
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.9.1.tgz#43e22465403f48c688ff8e762f3894aac8014d70"
|
||||
integrity sha512-s+i5arE6+zUwHRJnze4EsU5aCTmsMFKFeBc9sMzSceDOjH+BSeEZG9twMYtWlSCjKbWLCmUEUCxtH1fvcq+f6Q==
|
||||
dependencies:
|
||||
"@types/tough-cookie" "^4.0.2"
|
||||
ajv "8.10.0"
|
||||
|
Reference in New Issue
Block a user