Compare commits
47 Commits
Author | SHA1 | Date | |
---|---|---|---|
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 |
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
**Bug Description**
|
||||
|
||||
@ -36,9 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- [ ] Cloud
|
||||
- [ ] Self-hosted
|
||||
|
||||
- Cloud or Self-hosted
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 16
|
||||
- 18
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
90
CHANGELOG.md
90
CHANGELOG.md
@ -5,6 +5,93 @@ 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).
|
||||
|
||||
## 1.283.0 - 2023-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added the caching for current market prices
|
||||
- Added a loading indicator to the import dividends dialog
|
||||
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the selected item of the holding selector in the import dividends dialog
|
||||
- Extended the symbol search component by asset sub classes
|
||||
|
||||
## 1.282.0 - 2023-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added an icon to the external links in the footer navigation
|
||||
- Added the ability to add an asset profile in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Harmonized the use of permissions on the about page
|
||||
- Harmonized the use of permissions on the landing page
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
|
||||
|
||||
## 1.281.0 - 2023-06-17
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the feature overview page by liabilities
|
||||
- Set up the language localization for Portuguese (`pt`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Extracted the symbol search to a dedicated component
|
||||
- Improved the column headers in the holdings table for mobile
|
||||
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
|
||||
|
||||
## 1.280.1 - 2023-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for liabilities
|
||||
|
||||
## 1.279.0 - 2023-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a note for accounts
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for French (`fr`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the value nullification related to the investment streaks
|
||||
- Fixed an issue in the public page related to the impersonation service
|
||||
|
||||
## 1.278.0 - 2023-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the clone functionality of a transaction by the quantity
|
||||
- Changed the direction of the ellipsis icon in various tables
|
||||
- Extracted the license to a dedicated tab on the about page
|
||||
- Displayed the link to the markets overview in the footer based on a permission
|
||||
- Improved the spacing in the benchmark comparator
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
|
||||
|
||||
## 1.277.0 - 2023-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added the investment streaks to the analysis page
|
||||
- Added support for a unit in the value component
|
||||
- Added a semantic list structure to the header navigation
|
||||
- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the date format parsing in the activities import
|
||||
|
||||
## 1.276.0 - 2023-06-03
|
||||
|
||||
### Added
|
||||
@ -753,7 +840,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Added support for the dividend timeline grouped by year
|
||||
- Added support for the investment timeline grouped by year
|
||||
- Set up the language localization for Français (`fr`)
|
||||
- Set up the language localization for Português (`pt`)
|
||||
|
||||
### Changed
|
||||
|
||||
@ -1033,7 +1119,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added support to change the appearance (dark mode) in user settings
|
||||
- Added the total amount chart to the investment timeline
|
||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
- Set up the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
|
||||
### Changed
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:16-slim
|
||||
FROM node:18-slim
|
||||
RUN apt update && apt install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
@ -145,7 +145,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,6 +7,7 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
@ -14,6 +16,13 @@ export class CreateAccountDto {
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,6 +7,7 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
@ -14,6 +16,13 @@ export class UpdateAccountDto {
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
@ -26,11 +27,12 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||
import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -328,6 +330,28 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async addProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolProfile | never> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteProfileData(
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
@ -14,8 +15,8 @@ import {
|
||||
Filter,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@ -25,6 +26,7 @@ export class AdminService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
@ -35,6 +37,38 @@ export class AdminService {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public async addAssetProfile({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||
try {
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfiles[symbol]?.currency) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile not found for ${symbol} (${dataSource})`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
|
@ -21,6 +21,7 @@ export class ExportService {
|
||||
select: {
|
||||
accountType: true,
|
||||
balance: true,
|
||||
comment: true,
|
||||
currency: true,
|
||||
id: true,
|
||||
isExcluded: true,
|
||||
|
@ -202,7 +202,7 @@ export class ImportService {
|
||||
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||
activity.dataSource = DataSource.MANUAL;
|
||||
} else {
|
||||
activity.dataSource =
|
||||
|
@ -96,7 +96,7 @@ export class OrderService {
|
||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||
const userId = data.userId;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
@ -129,7 +129,10 @@ export class OrderService {
|
||||
}
|
||||
});
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
const isDraft =
|
||||
data.type === 'LIABILITY'
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
@ -201,7 +204,7 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (order.type === 'ITEM') {
|
||||
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -320,7 +323,11 @@ export class OrderService {
|
||||
})
|
||||
)
|
||||
.filter((order) => {
|
||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||
return (
|
||||
withExcludedAccounts ||
|
||||
!order.Account ||
|
||||
order.Account?.isExcluded === false
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
@ -368,7 +375,7 @@ export class OrderService {
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||
delete data.SymbolProfile.connect;
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
@ -98,7 +98,8 @@ describe('CurrentRateService', () => {
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
propertyService
|
||||
propertyService,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
|
@ -105,6 +105,40 @@ 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') }
|
||||
]);
|
||||
});
|
||||
|
@ -544,7 +544,7 @@ export class PortfolioCalculator {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = [];
|
||||
const investments: { date: string; investment: Big }[] = [];
|
||||
let currentDate: Date;
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
@ -554,13 +554,11 @@ export class PortfolioCalculator {
|
||||
(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(
|
||||
@ -595,7 +593,39 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
return investments;
|
||||
// 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
|
||||
)
|
||||
);
|
||||
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, (investment) => {
|
||||
return investment.date;
|
||||
});
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
|
@ -162,6 +162,7 @@ export class PortfolioController {
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'items',
|
||||
'liabilities',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
@ -258,11 +259,12 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let investments = await this.portfolioService.getInvestments({
|
||||
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
impersonationId,
|
||||
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||
});
|
||||
|
||||
if (
|
||||
@ -278,6 +280,11 @@ export class PortfolioController {
|
||||
date: item.date,
|
||||
investment: item.investment / maxInvestment
|
||||
}));
|
||||
|
||||
streaks = nullifyValuesInObject(streaks, [
|
||||
'currentStreak',
|
||||
'longestStreak'
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
@ -287,9 +294,14 @@ export class PortfolioController {
|
||||
investments = investments.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
|
||||
streaks = nullifyValuesInObject(streaks, [
|
||||
'currentStreak',
|
||||
'longestStreak'
|
||||
]);
|
||||
}
|
||||
|
||||
return { investments };
|
||||
return { investments, streaks };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
Filter,
|
||||
HistoricalDataItem,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
@ -252,13 +253,15 @@ export class PortfolioService {
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
impersonationId,
|
||||
savingsRate
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
filters?: Filter[];
|
||||
groupBy?: GroupBy;
|
||||
impersonationId: string;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
savingsRate: number;
|
||||
}): Promise<PortfolioInvestments> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
@ -276,7 +279,10 @@ export class PortfolioService {
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return [];
|
||||
return {
|
||||
investments: [],
|
||||
streaks: { currentStreak: 0, longestStreak: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
@ -346,9 +352,23 @@ export class PortfolioService {
|
||||
parseDate(investments[0]?.date)
|
||||
);
|
||||
|
||||
return investments.filter(({ date }) => {
|
||||
investments = investments.filter(({ date }) => {
|
||||
return !isBefore(parseDate(date), startDate);
|
||||
});
|
||||
|
||||
let streaks: PortfolioInvestments['streaks'];
|
||||
|
||||
if (savingsRate) {
|
||||
streaks = this.getStreaks({
|
||||
investments,
|
||||
savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
investments,
|
||||
streaks
|
||||
};
|
||||
}
|
||||
|
||||
public async getChart({
|
||||
@ -1282,12 +1302,11 @@ export class PortfolioService {
|
||||
}: {
|
||||
activities: OrderWithAccount[];
|
||||
date?: Date;
|
||||
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date and type dividend
|
||||
// Filter out all activities before given date (drafts) and type dividend
|
||||
return (
|
||||
isBefore(date, new Date(activity.date)) &&
|
||||
activity.type === TypeOfOrder.DIVIDEND
|
||||
@ -1411,7 +1430,7 @@ export class PortfolioService {
|
||||
}) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date
|
||||
// Filter out all activities before given date (drafts)
|
||||
return isBefore(date, new Date(activity.date));
|
||||
})
|
||||
.map(({ fee, SymbolProfile }) => {
|
||||
@ -1458,19 +1477,37 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type item
|
||||
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date (drafts) and type item
|
||||
return (
|
||||
isBefore(date, new Date(order.date)) &&
|
||||
order.type === TypeOfOrder.ITEM
|
||||
isBefore(date, new Date(activity.date)) &&
|
||||
activity.type === TypeOfOrder.ITEM
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.SymbolProfile.currency,
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getLiabilities(activities: OrderWithAccount[]) {
|
||||
return activities
|
||||
.filter(({ type }) => {
|
||||
return type === TypeOfOrder.LIABILITY;
|
||||
})
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
})
|
||||
@ -1510,6 +1547,28 @@ export class PortfolioService {
|
||||
return portfolioStart;
|
||||
}
|
||||
|
||||
private getStreaks({
|
||||
investments,
|
||||
savingsRate
|
||||
}: {
|
||||
investments: InvestmentItem[];
|
||||
savingsRate: number;
|
||||
}) {
|
||||
let currentStreak = 0;
|
||||
let longestStreak = 0;
|
||||
|
||||
for (const { investment } of investments) {
|
||||
if (investment >= savingsRate) {
|
||||
currentStreak++;
|
||||
longestStreak = Math.max(longestStreak, currentStreak);
|
||||
} else {
|
||||
currentStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { currentStreak, longestStreak };
|
||||
}
|
||||
|
||||
private async getSummary({
|
||||
balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
@ -1559,6 +1618,7 @@ export class PortfolioService {
|
||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||
const firstOrderDate = activities[0]?.date;
|
||||
const items = this.getItems(activities).toNumber();
|
||||
const liabilities = this.getLiabilities(activities).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
||||
@ -1591,6 +1651,7 @@ export class PortfolioService {
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.plus(excludedAccountsAndActivities)
|
||||
.minus(liabilities)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
@ -1617,6 +1678,7 @@ export class PortfolioService {
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
liabilities,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
@ -1841,13 +1903,6 @@ export class PortfolioService {
|
||||
return { accounts, platforms };
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||
|
||||
return impersonationUserId || aUserId;
|
||||
}
|
||||
|
||||
private getTotalByType(
|
||||
orders: OrderWithAccount[],
|
||||
currency: string,
|
||||
@ -1874,4 +1929,11 @@ export class PortfolioService {
|
||||
this.baseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||
|
||||
return impersonationUserId || aUserId;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
||||
import { Cache } from 'cache-manager';
|
||||
|
||||
@ -13,6 +14,10 @@ export class RedisCacheService {
|
||||
return await this.cache.get(key);
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
return `quote-${dataSource}-${symbol}`;
|
||||
}
|
||||
|
||||
public async remove(key: string) {
|
||||
await this.cache.del(key);
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ export class SymbolController {
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
||||
@Query('includeHistoricalData') includeHistoricalData = 0
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -16,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
||||
const object = cloneDeep(aObject);
|
||||
|
||||
keys.forEach((key) => {
|
||||
object[key] = null;
|
||||
});
|
||||
if (object) {
|
||||
keys.forEach((key) => {
|
||||
object[key] = null;
|
||||
});
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as bodyParser from 'body-parser';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
@ -10,11 +12,12 @@ async function bootstrap() {
|
||||
const configApp = await NestFactory.create(AppModule);
|
||||
const configService = configApp.get<ConfigService>(ConfigService);
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: environment.production
|
||||
? ['error', 'log', 'warn']
|
||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||
});
|
||||
|
||||
app.enableCors();
|
||||
app.enableVersioning({
|
||||
defaultVersion: '1',
|
||||
@ -32,6 +35,8 @@ async function bootstrap() {
|
||||
// Support 10mb csv/json files for importing activities
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
|
||||
app.use(helmet());
|
||||
|
||||
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
|
||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||
const PORT = configService.get<number>('PORT') || 3333;
|
||||
|
@ -16,6 +16,7 @@ export class ConfigurationService {
|
||||
default: 'USD'
|
||||
}),
|
||||
BETTER_UPTIME_API_KEY: str({ default: '' }),
|
||||
CACHE_QUOTES_TTL: num({ default: 1 }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
@ -27,7 +28,8 @@ export class DataProviderService {
|
||||
private readonly dataProviderInterfaces: DataProviderInterface[],
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {
|
||||
this.initialize();
|
||||
}
|
||||
@ -235,9 +237,43 @@ export class DataProviderService {
|
||||
} = {};
|
||||
const startTimeTotal = performance.now();
|
||||
|
||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||
// Get items from cache
|
||||
const itemsToFetch: IDataGatheringItem[] = [];
|
||||
|
||||
const promises = [];
|
||||
for (const { dataSource, symbol } of items) {
|
||||
const quoteString = await this.redisCacheService.get(
|
||||
this.redisCacheService.getQuoteKey({ dataSource, symbol })
|
||||
);
|
||||
|
||||
if (quoteString) {
|
||||
try {
|
||||
const cachedDataProviderResponse = JSON.parse(quoteString);
|
||||
response[symbol] = cachedDataProviderResponse;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!quoteString) {
|
||||
itemsToFetch.push({ dataSource, symbol });
|
||||
}
|
||||
}
|
||||
|
||||
const numberOfItemsInCache = Object.keys(response)?.length;
|
||||
|
||||
if (numberOfItemsInCache) {
|
||||
Logger.debug(
|
||||
`Fetched ${numberOfItemsInCache} quote${
|
||||
numberOfItemsInCache > 1 ? 's' : ''
|
||||
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
|
||||
3
|
||||
)} seconds`
|
||||
);
|
||||
}
|
||||
|
||||
const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => {
|
||||
return dataSource;
|
||||
});
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||
itemsGroupedByDataSource
|
||||
@ -271,6 +307,15 @@ export class DataProviderService {
|
||||
result
|
||||
)) {
|
||||
response[symbol] = dataProviderResponse;
|
||||
|
||||
this.redisCacheService.set(
|
||||
this.redisCacheService.getQuoteKey({
|
||||
dataSource: DataSource[dataSource],
|
||||
symbol
|
||||
}),
|
||||
JSON.stringify(dataProviderResponse),
|
||||
this.configurationService.get('CACHE_QUOTES_TTL')
|
||||
);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
@ -283,7 +328,7 @@ export class DataProviderService {
|
||||
);
|
||||
|
||||
try {
|
||||
await this.marketDataService.updateMany({
|
||||
this.marketDataService.updateMany({
|
||||
data: Object.keys(response)
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
|
@ -12,22 +12,36 @@ export class ImpersonationService {
|
||||
) {}
|
||||
|
||||
public async validateImpersonationId(aId = '') {
|
||||
const accessObject = await this.prismaService.access.findFirst({
|
||||
where: {
|
||||
GranteeUser: { id: this.request.user.id },
|
||||
id: aId
|
||||
}
|
||||
});
|
||||
if (this.request.user) {
|
||||
const accessObject = await this.prismaService.access.findFirst({
|
||||
where: {
|
||||
GranteeUser: { id: this.request.user.id },
|
||||
id: aId
|
||||
}
|
||||
});
|
||||
|
||||
if (accessObject?.userId) {
|
||||
return accessObject?.userId;
|
||||
} else if (
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.impersonateAllUsers
|
||||
)
|
||||
) {
|
||||
return aId;
|
||||
if (accessObject?.userId) {
|
||||
return accessObject.userId;
|
||||
} else if (
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.impersonateAllUsers
|
||||
)
|
||||
) {
|
||||
return aId;
|
||||
}
|
||||
} else {
|
||||
// Public access
|
||||
const accessObject = await this.prismaService.access.findFirst({
|
||||
where: {
|
||||
GranteeUser: null,
|
||||
User: { id: aId }
|
||||
}
|
||||
});
|
||||
|
||||
if (accessObject?.userId) {
|
||||
return accessObject.userId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -5,6 +5,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
BASE_CURRENCY: string;
|
||||
BETTER_UPTIME_API_KEY: string;
|
||||
CACHE_QUOTES_TTL: number;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCE_EXCHANGE_RATES: string;
|
||||
DATA_SOURCE_IMPORT: string;
|
||||
|
@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
|
||||
export class SymbolProfileService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async add(
|
||||
assetProfile: Prisma.SymbolProfileCreateInput
|
||||
): Promise<SymbolProfile | never> {
|
||||
return this.prismaService.symbolProfile.create({ data: assetProfile });
|
||||
}
|
||||
|
||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.delete({
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
|
@ -18,36 +18,6 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
||||
})),
|
||||
...[
|
||||
'about/changelog',
|
||||
/////
|
||||
'a-propos/changelog',
|
||||
'informazioni-su/changelog',
|
||||
'over/changelog',
|
||||
'sobre/changelog',
|
||||
'ueber-uns/changelog'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/about/changelog/changelog-page.module').then(
|
||||
(m) => m.ChangelogPageModule
|
||||
)
|
||||
})),
|
||||
...[
|
||||
'about/privacy-policy',
|
||||
/////
|
||||
'a-propos/politique-de-confidentialite',
|
||||
'informazioni-su/informativa-sulla-privacy',
|
||||
'over/privacybeleid',
|
||||
'sobre/politica-de-privacidad',
|
||||
'ueber-uns/datenschutzbestimmungen'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
|
||||
(m) => m.PrivacyPolicyPageModule
|
||||
)
|
||||
})),
|
||||
{
|
||||
path: 'account',
|
||||
loadChildren: () =>
|
||||
@ -179,6 +149,7 @@ const routes: Routes = [
|
||||
'domande-piu-frequenti',
|
||||
'foire-aux-questions',
|
||||
'haeufig-gestellte-fragen',
|
||||
'perguntas-mais-frequentes',
|
||||
'preguntas-mas-frecuentes',
|
||||
'vaak-gestelde-vragen'
|
||||
].map((path) => ({
|
||||
@ -243,6 +214,7 @@ const routes: Routes = [
|
||||
'pricing',
|
||||
/////
|
||||
'precios',
|
||||
'precos',
|
||||
'preise',
|
||||
'prezzi',
|
||||
'prijzen',
|
||||
@ -259,6 +231,7 @@ const routes: Routes = [
|
||||
/////
|
||||
'enregistrement',
|
||||
'iscrizione',
|
||||
'registo',
|
||||
'registratie',
|
||||
'registrierung',
|
||||
'registro'
|
||||
|
@ -66,7 +66,9 @@
|
||||
<div class="col-sm">
|
||||
<div class="h6 mt-2" i18n>Personal Finance</div>
|
||||
<ul class="list-unstyled">
|
||||
<li><a i18n [routerLink]="['/markets']">Markets</a></li>
|
||||
<li *ngIf="hasPermissionToAccessFearAndGreedIndex">
|
||||
<a i18n [routerLink]="['/markets']">Markets</a>
|
||||
</li>
|
||||
<li><a i18n [routerLink]="['/resources']">Resources</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -78,15 +80,16 @@
|
||||
<a i18n [routerLink]="['/blog']">Blog</a>
|
||||
</li>
|
||||
<li>
|
||||
<a i18n [routerLink]="['/about', 'changelog']"
|
||||
>Changelog & License</a
|
||||
>
|
||||
<a i18n [routerLink]="['/about', 'changelog']">Changelog</a>
|
||||
</li>
|
||||
<li><a i18n [routerLink]="['/features']">Features</a></li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
<li>
|
||||
<a i18n [routerLink]="['/about', 'license']">License</a>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForStatistics">
|
||||
<a [routerLink]="['/open']">Open Startup</a>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
@ -98,9 +101,13 @@
|
||||
>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription">
|
||||
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
|
||||
>Status</a
|
||||
>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://status.ghostfol.io"
|
||||
target="_blank"
|
||||
title="Ghostfolio Status"
|
||||
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -109,24 +116,30 @@
|
||||
<ul class="list-unstyled">
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
target="_blank"
|
||||
title="Find Ghostfolio on GitHub"
|
||||
>GitHub</a
|
||||
>
|
||||
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack channel"
|
||||
>Slack</a
|
||||
>
|
||||
target="_blank"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
target="_blank"
|
||||
title="Follow Ghostfolio on Twitter"
|
||||
>Twitter</a
|
||||
>
|
||||
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li> </li>
|
||||
<li>
|
||||
@ -147,6 +160,9 @@
|
||||
<li>
|
||||
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -33,7 +33,9 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public currentYear = new Date().getFullYear();
|
||||
public deviceType: string;
|
||||
public hasPermissionForBlog: boolean;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public info: InfoItem;
|
||||
public pageTitle: string;
|
||||
public user: User;
|
||||
@ -69,6 +71,16 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.hasPermissionForStatistics = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableFearAndGreedIndex
|
||||
);
|
||||
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
|
@ -47,7 +47,7 @@
|
||||
[matMenuTriggerFor]="transactionMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||
@ -57,6 +57,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||
</table>
|
||||
|
@ -12,8 +12,9 @@
|
||||
<div class="col-12 d-flex justify-content-center mb-3">
|
||||
<gf-value
|
||||
size="large"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="valueInBaseCurrency"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -24,8 +25,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="currency"
|
||||
[value]="balance"
|
||||
>Cash Balance</gf-value
|
||||
>
|
||||
@ -34,8 +36,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="currency"
|
||||
[value]="equity"
|
||||
>Equity</gf-value
|
||||
>
|
||||
|
@ -207,6 +207,30 @@
|
||||
</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"></ion-icon>
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
@ -216,7 +240,7 @@
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||
|
@ -58,7 +58,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
'balance',
|
||||
'value',
|
||||
'currency',
|
||||
'valueInBaseCurrency'
|
||||
'valueInBaseCurrency',
|
||||
'comment'
|
||||
];
|
||||
|
||||
if (this.showActions) {
|
||||
@ -92,6 +93,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onOpenComment(aComment: string) {
|
||||
alert(aComment);
|
||||
}
|
||||
|
||||
public onUpdateAccount(aAccount: AccountModel) {
|
||||
this.accountToUpdate.emit(aAccount);
|
||||
}
|
||||
|
@ -108,7 +108,7 @@
|
||||
[matMenuTriggerFor]="jobActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onViewData(job.data)">
|
||||
|
@ -24,6 +24,8 @@ import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
|
||||
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@ -99,6 +101,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
dataSource: params['dataSource'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
} else if (params['createAssetProfileDialog']) {
|
||||
this.openCreateAssetProfileDialog();
|
||||
}
|
||||
});
|
||||
|
||||
@ -241,4 +245,52 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private openCreateAssetProfileDialog() {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
|
||||
autoFocus: false,
|
||||
data: <CreateAssetProfileDialogParams>{
|
||||
deviceType: this.deviceType,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ dataSource, symbol }) => {
|
||||
if (dataSource && symbol) {
|
||||
this.adminService
|
||||
.addAssetProfile({ dataSource, symbol })
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
this.isLoading = true;
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
||||
return this.dataService.fetchAdminMarketData({
|
||||
filters: this.activeFilters
|
||||
});
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ marketData }) => {
|
||||
this.dataSource = new MatTableDataSource(marketData);
|
||||
this.dataSource.sort = this.sort;
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -140,7 +140,7 @@
|
||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
@ -164,4 +164,16 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fab-container">
|
||||
<a
|
||||
class="align-items-center d-flex justify-content-center"
|
||||
color="primary"
|
||||
mat-fab
|
||||
[queryParams]="{ createAssetProfileDialog: true }"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,10 +4,12 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
|
||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
||||
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AdminMarketDataComponent],
|
||||
@ -15,10 +17,12 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
|
||||
CommonModule,
|
||||
GfActivitiesFilterModule,
|
||||
GfAssetProfileDialogModule,
|
||||
GfCreateAssetProfileDialogModule,
|
||||
MatButtonModule,
|
||||
MatMenuModule,
|
||||
MatSortModule,
|
||||
MatTableModule
|
||||
MatTableModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -2,4 +2,11 @@
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.fab-container {
|
||||
bottom: 2rem;
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
z-index: 999;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,53 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'h-100' },
|
||||
selector: 'gf-create-asset-profile-dialog',
|
||||
templateUrl: 'create-asset-profile-dialog.html'
|
||||
})
|
||||
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||
public createAssetProfileForm: FormGroup;
|
||||
|
||||
public constructor(
|
||||
public readonly adminService: AdminService,
|
||||
public readonly changeDetectorRef: ChangeDetectorRef,
|
||||
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
||||
public readonly formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.createAssetProfileForm = this.formBuilder.group({
|
||||
searchSymbol: new FormControl(null, [Validators.required])
|
||||
});
|
||||
}
|
||||
|
||||
public onCancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
this.dialogRef.close({
|
||||
dataSource:
|
||||
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
|
||||
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
<form
|
||||
class="d-flex flex-column h-100"
|
||||
[formGroup]="createAssetProfileForm"
|
||||
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
<h1 i18n mat-dialog-title>Create Asset Profile</h1>
|
||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||
<gf-symbol-autocomplete formControlName="searchSymbol" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||
<button
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="!createAssetProfileForm.valid"
|
||||
>
|
||||
<ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
@ -0,0 +1,24 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
||||
|
||||
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [CreateAssetProfileDialog],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfSymbolAutocompleteModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfCreateAssetProfileDialogModule {}
|
@ -0,0 +1,4 @@
|
||||
export interface CreateAssetProfileDialogParams {
|
||||
deviceType: string;
|
||||
locale: string;
|
||||
}
|
@ -82,7 +82,7 @@
|
||||
[matMenuTriggerFor]="platformMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #platformMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdatePlatform(element)">
|
||||
|
@ -109,7 +109,7 @@
|
||||
[matMenuTriggerFor]="userMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #userMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
|
@ -1,6 +1,8 @@
|
||||
<div class="mb-2 row">
|
||||
<div class="col-md-6 col-xs-12 d-flex">
|
||||
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
|
||||
<div
|
||||
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
|
||||
>
|
||||
<span i18n>Performance</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
|
@ -8,216 +8,238 @@
|
||||
<gf-logo [label]="pageTitle"></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
class="d-none d-sm-block"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'portfolio',
|
||||
'text-decoration-underline': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'accounts',
|
||||
'text-decoration-underline': currentRoute === 'accounts'
|
||||
}"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'admin',
|
||||
'text-decoration-underline': currentRoute === 'admin'
|
||||
}"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources',
|
||||
'text-decoration-underline': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
<a
|
||||
*ngIf="
|
||||
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||
"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
<button
|
||||
class="no-min-width px-1"
|
||||
mat-flat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(menuOpened)="onMenuOpened()"
|
||||
>
|
||||
<ion-icon
|
||||
class="d-none d-sm-block"
|
||||
name="person-circle-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
class="d-block d-sm-none"
|
||||
size="large"
|
||||
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
||||
></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<ng-container *ngIf="user?.access?.length > 0">
|
||||
<button mat-menu-item (click)="impersonateAccount(null)">
|
||||
<ion-icon
|
||||
*ngIf="user?.access?.length > 0"
|
||||
class="mr-2"
|
||||
[name]="
|
||||
impersonationId
|
||||
? 'radio-button-off-outline'
|
||||
: 'radio-button-on-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
<span i18n>Me</span>
|
||||
</button>
|
||||
<button
|
||||
*ngFor="let accessItem of user?.access"
|
||||
mat-menu-item
|
||||
(click)="impersonateAccount(accessItem.id)"
|
||||
<ul class="alig-items-center d-flex list-inline m-0">
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'home' || currentRoute === 'zen',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-2"
|
||||
name="square-outline"
|
||||
[name]="
|
||||
accessItem.id === impersonationId
|
||||
? 'radio-button-on-outline'
|
||||
: 'radio-button-off-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
|
||||
<span *ngIf="!accessItem.alias" i18n>User</span>
|
||||
</button>
|
||||
<hr class="m-0" />
|
||||
</ng-container>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||
[routerLink]="['/account']"
|
||||
>My Ghostfolio</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
<hr class="m-0" />
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
<a
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'portfolio',
|
||||
'text-decoration-underline': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'accounts',
|
||||
'text-decoration-underline': currentRoute === 'accounts'
|
||||
}"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'admin',
|
||||
'text-decoration-underline': currentRoute === 'admin'
|
||||
}"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources',
|
||||
'text-decoration-underline': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
*ngIf="
|
||||
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||
"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
class="list-inline-item"
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||
[routerLink]="['/about']"
|
||||
>About Ghostfolio</a
|
||||
>
|
||||
<hr class="d-flex d-sm-none m-0" />
|
||||
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
||||
</mat-menu>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<button
|
||||
class="no-min-width px-1"
|
||||
mat-flat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(menuOpened)="onMenuOpened()"
|
||||
>
|
||||
<ion-icon
|
||||
class="d-none d-sm-block"
|
||||
name="person-circle-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
class="d-block d-sm-none"
|
||||
size="large"
|
||||
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
||||
></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<ng-container *ngIf="user?.access?.length > 0">
|
||||
<button mat-menu-item (click)="impersonateAccount(null)">
|
||||
<ion-icon
|
||||
*ngIf="user?.access?.length > 0"
|
||||
class="mr-2"
|
||||
[name]="
|
||||
impersonationId
|
||||
? 'radio-button-off-outline'
|
||||
: 'radio-button-on-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
<span i18n>Me</span>
|
||||
</button>
|
||||
<button
|
||||
*ngFor="let accessItem of user?.access"
|
||||
mat-menu-item
|
||||
(click)="impersonateAccount(accessItem.id)"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-2"
|
||||
name="square-outline"
|
||||
[name]="
|
||||
accessItem.id === impersonationId
|
||||
? 'radio-button-on-outline'
|
||||
: 'radio-button-off-outline'
|
||||
"
|
||||
></ion-icon>
|
||||
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
|
||||
<span *ngIf="!accessItem.alias" i18n>User</span>
|
||||
</button>
|
||||
<hr class="m-0" />
|
||||
</ng-container>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||
[routerLink]="['/account']"
|
||||
>My Ghostfolio</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
<hr class="m-0" />
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
<a
|
||||
*ngIf="
|
||||
hasPermissionForSubscription &&
|
||||
user?.subscription?.type === 'Basic'
|
||||
"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||
[routerLink]="['/about']"
|
||||
>About Ghostfolio</a
|
||||
>
|
||||
<hr class="d-flex d-sm-none m-0" />
|
||||
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
||||
</mat-menu>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="user === null">
|
||||
<a
|
||||
@ -231,67 +253,86 @@
|
||||
></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'features',
|
||||
'text-decoration-underline': currentRoute === 'features'
|
||||
}"
|
||||
[routerLink]="['/features']"
|
||||
>Features</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessFearAndGreedIndex"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'markets',
|
||||
'text-decoration-underline': currentRoute === 'markets'
|
||||
}"
|
||||
[routerLink]="['/markets']"
|
||||
>Markets</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block no-min-width"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
mat-icon-button
|
||||
><ion-icon name="logo-github"></ion-icon
|
||||
></a>
|
||||
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||
<ng-container i18n>Sign in</ng-container>
|
||||
</button>
|
||||
<a
|
||||
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
||||
class="d-none d-sm-block"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/register']"
|
||||
><ng-container i18n>Get started</ng-container>
|
||||
</a>
|
||||
<ul class="alig-items-center d-flex list-inline m-0">
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'features',
|
||||
'text-decoration-underline': currentRoute === 'features'
|
||||
}"
|
||||
[routerLink]="['/features']"
|
||||
>Features</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
</li>
|
||||
<li *ngIf="hasPermissionForSubscription" class="list-inline-item">
|
||||
<a
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
*ngIf="hasPermissionToAccessFearAndGreedIndex"
|
||||
class="list-inline-item"
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'markets',
|
||||
'text-decoration-underline': currentRoute === 'markets'
|
||||
}"
|
||||
[routerLink]="['/markets']"
|
||||
>Markets</a
|
||||
>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block no-min-width p-1"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
mat-flat-button
|
||||
><ion-icon name="logo-github"></ion-icon
|
||||
></a>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||
<ng-container i18n>Sign in</ng-container>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
||||
class="list-inline-item"
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/register']"
|
||||
><ng-container i18n>Get started</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
</mat-toolbar>
|
||||
|
@ -13,8 +13,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.totalBuy"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -24,8 +25,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.totalSell"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -38,8 +40,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.committedFunds"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -49,8 +52,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.currentGrossPerformance"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -79,8 +83,9 @@
|
||||
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.fees"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -93,8 +98,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.currentNetPerformance"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -121,8 +127,9 @@
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.currentValue"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -132,8 +139,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.items"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -152,8 +160,9 @@
|
||||
></ion-icon>
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.emergencyFund"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -163,8 +172,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.cash"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -174,8 +184,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.excludedAccountsAndActivities"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -183,13 +194,34 @@
|
||||
<div class="row">
|
||||
<div class="col"><hr /></div>
|
||||
</div>
|
||||
<div class="flex-nowrap px-3 py-1 row">
|
||||
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<span
|
||||
*ngIf="summary?.liabilities || summary?.liabilities === 0"
|
||||
class="mr-1"
|
||||
>-</span
|
||||
>
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.liabilities"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><hr /></div>
|
||||
</div>
|
||||
<div class="flex-nowrap px-3 py-1 row">
|
||||
<div class="flex-grow-1 font-weight-bold text-truncate" i18n>Net Worth</div>
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.netWorth"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -217,8 +249,9 @@
|
||||
<div class="justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.dividend"
|
||||
></gf-value>
|
||||
</div>
|
||||
|
@ -12,8 +12,9 @@
|
||||
<div class="col-12 d-flex justify-content-center mb-3">
|
||||
<gf-value
|
||||
size="large"
|
||||
[currency]="data.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="value"
|
||||
></gf-value>
|
||||
</div>
|
||||
@ -40,8 +41,9 @@
|
||||
i18n
|
||||
size="medium"
|
||||
[colorizeSign]="true"
|
||||
[currency]="data.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="netPerformance"
|
||||
>Change</gf-value
|
||||
>
|
||||
@ -61,8 +63,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[unit]="SymbolProfile?.currency"
|
||||
[value]="averagePrice"
|
||||
>Average Unit Price</gf-value
|
||||
>
|
||||
@ -71,8 +74,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[unit]="SymbolProfile?.currency"
|
||||
[value]="marketPrice"
|
||||
>Market Price</gf-value
|
||||
>
|
||||
@ -81,9 +85,10 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
||||
[unit]="SymbolProfile?.currency"
|
||||
[value]="minPrice"
|
||||
>Minimum Price</gf-value
|
||||
>
|
||||
@ -92,9 +97,10 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
||||
[unit]="SymbolProfile?.currency"
|
||||
[value]="maxPrice"
|
||||
>Maximum Price</gf-value
|
||||
>
|
||||
@ -113,8 +119,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="data.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="investment"
|
||||
>Investment</gf-value
|
||||
>
|
||||
@ -123,8 +130,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="data.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="dividendInBaseCurrency"
|
||||
>Dividend</gf-value
|
||||
>
|
||||
@ -133,8 +141,9 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[currency]="data.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="feeInBaseCurrency"
|
||||
>Fees</gf-value
|
||||
>
|
||||
|
@ -45,8 +45,9 @@
|
||||
<gf-value
|
||||
class="mr-3"
|
||||
[colorizeSign]="true"
|
||||
[currency]="baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="position?.netPerformance"
|
||||
></gf-value>
|
||||
<gf-value
|
||||
|
@ -22,13 +22,38 @@ const routes: Routes = [
|
||||
(m) => m.ChangelogPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'privacy-policy',
|
||||
...[
|
||||
'license',
|
||||
/////
|
||||
'licenca',
|
||||
'licence',
|
||||
'licencia',
|
||||
'licentie',
|
||||
'lizenz',
|
||||
'licenza'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./license/license-page.module').then(
|
||||
(m) => m.LicensePageModule
|
||||
)
|
||||
})),
|
||||
...[
|
||||
'privacy-policy',
|
||||
/////
|
||||
'datenschutzbestimmungen',
|
||||
'informativa-sulla-privacy',
|
||||
'politique-de-confidentialite',
|
||||
'politica-de-privacidad',
|
||||
'politica-de-privacidade',
|
||||
'privacybeleid'
|
||||
].map((path) => ({
|
||||
path,
|
||||
loadChildren: () =>
|
||||
import('./privacy-policy/privacy-policy-page.module').then(
|
||||
(m) => m.PrivacyPolicyPageModule
|
||||
)
|
||||
}
|
||||
}))
|
||||
],
|
||||
component: AboutPageComponent,
|
||||
path: '',
|
||||
|
@ -53,9 +53,14 @@ export class AboutPageComponent implements OnDestroy, OnInit {
|
||||
},
|
||||
{
|
||||
iconName: 'sparkles-outline',
|
||||
label: $localize`Changelog & License`,
|
||||
label: $localize`Changelog`,
|
||||
path: ['/about', 'changelog']
|
||||
},
|
||||
{
|
||||
iconName: 'ribbon-outline',
|
||||
label: $localize`License`,
|
||||
path: ['/about', 'license']
|
||||
},
|
||||
{
|
||||
iconName: 'shield-checkmark-outline',
|
||||
label: $localize`Privacy Policy`,
|
||||
|
@ -9,7 +9,7 @@ const routes: Routes = [
|
||||
canActivate: [AuthGuard],
|
||||
component: ChangelogPageComponent,
|
||||
path: '',
|
||||
title: $localize`Changelog & License`
|
||||
title: $localize`Changelog`
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -1,23 +1,10 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
||||
<mat-card appearance="outlined" class="changelog">
|
||||
<mat-card-content>
|
||||
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
||||
<mat-card appearance="outlined">
|
||||
<mat-card-content>
|
||||
<markdown [src]="'../assets/LICENSE'"></markdown>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Changelog</h1>
|
||||
<div class="changelog">
|
||||
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
|
||||
import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
|
||||
@ -11,8 +10,7 @@ import { ChangelogPageComponent } from './changelog-page.component';
|
||||
imports: [
|
||||
ChangelogPageRoutingModule,
|
||||
CommonModule,
|
||||
MarkdownModule.forChild(),
|
||||
MatCardModule
|
||||
MarkdownModule.forChild()
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -2,35 +2,33 @@
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
.mat-mdc-card {
|
||||
&.changelog {
|
||||
::ng-deep {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
.changelog {
|
||||
::ng-deep {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
|
||||
markdown {
|
||||
h1,
|
||||
p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
markdown {
|
||||
h1,
|
||||
p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { LicensePageComponent } from './license-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: LicensePageComponent,
|
||||
path: '',
|
||||
title: $localize`License`
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class LicensePageRoutingModule {}
|
@ -0,0 +1,19 @@
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-license-page',
|
||||
styleUrls: ['./license-page.scss'],
|
||||
templateUrl: './license-page.html'
|
||||
})
|
||||
export class LicensePageComponent implements OnDestroy {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
10
apps/client/src/app/pages/about/license/license-page.html
Normal file
10
apps/client/src/app/pages/about/license/license-page.html
Normal file
@ -0,0 +1,10 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>License</h1>
|
||||
<div>
|
||||
<markdown [src]="'../assets/LICENSE'"></markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,13 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
|
||||
import { LicensePageRoutingModule } from './license-page-routing.module';
|
||||
import { LicensePageComponent } from './license-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [LicensePageComponent],
|
||||
imports: [LicensePageRoutingModule, CommonModule, MarkdownModule.forChild()],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class LicensePageModule {}
|
@ -0,0 +1,8 @@
|
||||
:host {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
color: rgb(var(--light-primary-text));
|
||||
}
|
@ -2,7 +2,6 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { environment } from '@ghostfolio/client/../environments/environment';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Subject } from 'rxjs';
|
||||
@ -15,8 +14,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './about-overview-page.html'
|
||||
})
|
||||
export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
||||
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
|
||||
public hasPermissionForBlog: boolean;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public isLoggedIn: boolean;
|
||||
public user: User;
|
||||
@ -36,6 +35,11 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
||||
permissions.enableBlog
|
||||
);
|
||||
|
||||
this.hasPermissionForStatistics = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableSubscription
|
||||
|
@ -19,13 +19,11 @@
|
||||
title="GNU Affero General Public License"
|
||||
>AGPL-3.0 license</a
|
||||
>
|
||||
and we share aggregated
|
||||
<a
|
||||
href="https://ghostfol.io/{{ defaultLanguageCode }}/open"
|
||||
title="Open Startup"
|
||||
>key metrics</a
|
||||
>
|
||||
of the platform’s performance. The project has been initiated by
|
||||
<ng-container *ngIf="hasPermissionForStatistics">
|
||||
and we share aggregated
|
||||
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
|
||||
of the platform’s performance</ng-container
|
||||
>. The project has been initiated by
|
||||
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
|
||||
>Thomas Kaul</a
|
||||
>
|
||||
@ -53,8 +51,9 @@
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack community</a
|
||||
>, tweet to
|
||||
>Slack</a
|
||||
>
|
||||
community, tweet to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Tweet to Ghostfolio on Twitter"
|
||||
@ -94,7 +93,7 @@
|
||||
class="mx-2"
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
mat-icon-button
|
||||
title="Join the Ghostfolio Slack channel"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>
|
||||
<ion-icon name="logo-slack"></ion-icon>
|
||||
</a>
|
||||
|
@ -1,13 +1,19 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { AboutOverviewPageRoutingModule } from './about-overview-page-routing.module';
|
||||
import { AboutOverviewPageComponent } from './about-overview-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AboutOverviewPageComponent],
|
||||
imports: [AboutOverviewPageRoutingModule, CommonModule, MatButtonModule],
|
||||
imports: [
|
||||
AboutOverviewPageRoutingModule,
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class AboutOverviewPageModule {}
|
||||
|
@ -156,10 +156,10 @@
|
||||
>Nederlands (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<!--<mat-option value="pt"
|
||||
<mat-option value="pt"
|
||||
>Português (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>-->
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
@ -153,6 +153,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
public openUpdateAccountDialog({
|
||||
accountType,
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
@ -164,6 +165,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
account: {
|
||||
accountType,
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
@ -232,6 +234,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
account: {
|
||||
accountType: AccountType.SECURITIES,
|
||||
balance: 0,
|
||||
comment: null,
|
||||
currency: this.user?.settings?.baseCurrency,
|
||||
isExcluded: false,
|
||||
name: null,
|
||||
|
@ -50,6 +50,19 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Note</mat-label>
|
||||
<textarea
|
||||
cdkAutosizeMinRows="2"
|
||||
cdkTextareaAutosize
|
||||
matInput
|
||||
name="comment"
|
||||
[(ngModel)]="data.account.comment"
|
||||
(keyup.enter)="$event.stopPropagation()"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3 px-2">
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
|
@ -82,9 +82,9 @@
|
||||
If you have further questions or ideas, please join our growing
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>Slack community</a
|
||||
>Slack</a
|
||||
>
|
||||
or get in touch on Twitter
|
||||
community or get in touch on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
</p>
|
||||
|
@ -121,7 +121,7 @@
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>Slack</a
|
||||
>
|
||||
channel or connect with
|
||||
community or connect with
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> on
|
||||
Twitter. We are happy to discuss ideas and get you involved.
|
||||
</p>
|
||||
|
@ -185,8 +185,9 @@
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack community</a
|
||||
>,
|
||||
>Slack</a
|
||||
>
|
||||
community,
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Tweet to Ghostfolio on Twitter"
|
||||
@ -214,8 +215,8 @@
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack community</a
|
||||
>, tweet to
|
||||
>Slack </a
|
||||
>community, tweet to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Tweet to Ghostfolio on Twitter"
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-none d-sm-block mb-3 text-center">Features</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Features</h3>
|
||||
<div class="mb-4">
|
||||
<p>
|
||||
Check out the numerous features of <strong>Ghostfolio</strong> to
|
||||
@ -13,7 +13,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Stocks</h4>
|
||||
<h4 i18n>Stocks</h4>
|
||||
<p class="m-0">Keep track of your stock purchases and sales.</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -23,7 +23,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>ETFs</h4>
|
||||
<h4 i18n>ETFs</h4>
|
||||
<p class="m-0">
|
||||
Are you into ETFs (Exchange Traded Funds)? Track your ETF
|
||||
investments.
|
||||
@ -36,7 +36,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Bonds</h4>
|
||||
<h4 i18n>Bonds</h4>
|
||||
<p class="m-0">
|
||||
Manage your investment in bonds and other assets with fixed
|
||||
income.
|
||||
@ -49,7 +49,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Cryptocurrencies</h4>
|
||||
<h4 i18n>Cryptocurrencies</h4>
|
||||
<p class="m-0">
|
||||
Keep track of your Bitcoin and Altcoin holdings.
|
||||
</p>
|
||||
@ -61,7 +61,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Dividend</h4>
|
||||
<h4 i18n>Dividend</h4>
|
||||
<p class="m-0">
|
||||
Are you building a dividend portfolio? Track your dividend in
|
||||
Ghostfolio.
|
||||
@ -74,7 +74,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Wealth Items</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Wealth Items</h4>
|
||||
<p class="m-0">
|
||||
Track all your treasuries, be it your luxury watch or rare
|
||||
trading cards.
|
||||
@ -87,7 +87,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Emergency Fund</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Emergency Fund</h4>
|
||||
<p class="m-0">
|
||||
Define your emergency fund you are comfortable with for
|
||||
difficult times.
|
||||
@ -100,7 +100,22 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Import and Export</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Liabilities</h4>
|
||||
<p class="m-0">
|
||||
Manage your financial liabilities, such as your student loan,
|
||||
to stay ahead of your financial obligations.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex" i18n>
|
||||
Import and Export
|
||||
</h4>
|
||||
<p class="m-0">Import and export your investment activities.</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -110,7 +125,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Multi-Accounts</h4>
|
||||
<h4 i18n>Multi-Accounts</h4>
|
||||
<p class="m-0">
|
||||
Keep an eye on all your accounts across multiple platforms
|
||||
(multi-banking).
|
||||
@ -124,7 +139,7 @@
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>Portfolio Calculations</span>
|
||||
<span i18n>Portfolio Calculations</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1"
|
||||
@ -144,7 +159,7 @@
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>Portfolio Allocations</span>
|
||||
<span i18n>Portfolio Allocations</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1"
|
||||
@ -162,7 +177,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Dark Mode</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Dark Mode</h4>
|
||||
<p class="m-0">
|
||||
Ghostfolio automatically switches to a dark color theme based
|
||||
on your operating system's preferences.
|
||||
@ -175,7 +190,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">Zen Mode</h4>
|
||||
<h4 class="align-items-center d-flex" i18n>Zen Mode</h4>
|
||||
<p class="m-0">
|
||||
Keep calm and activate Zen Mode if the markets are going
|
||||
crazy.
|
||||
@ -192,7 +207,7 @@
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>Market Mood</span>
|
||||
<span i18n>Market Mood</span>
|
||||
<gf-premium-indicator class="ml-1"></gf-premium-indicator>
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
@ -210,7 +225,7 @@
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span>Static Analysis</span>
|
||||
<span i18n>Static Analysis</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1"
|
||||
@ -228,13 +243,11 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Multi-Language</h4>
|
||||
<h4 i18n>Multi-Language</h4>
|
||||
<p class="m-0">
|
||||
Use Ghostfolio in multiple languages: English, Dutch, French,
|
||||
German, Italian<ng-container *ngIf="false"
|
||||
>, Portuguese</ng-container
|
||||
>
|
||||
and Spanish are currently supported.
|
||||
German, Italian, Portuguese and Spanish are currently
|
||||
supported.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -244,16 +257,16 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Community</h4>
|
||||
<h4 i18n>Community</h4>
|
||||
<p class="m-0">
|
||||
Join the Ghostfolio
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack channel</a
|
||||
>Slack</a
|
||||
>
|
||||
full of enthusiastic investors and discuss the latest market
|
||||
trends.
|
||||
community full of enthusiastic investors and discuss the
|
||||
latest market trends.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -263,7 +276,7 @@
|
||||
<mat-card appearance="outlined" class="d-flex flex-column h-100">
|
||||
<mat-card-content>
|
||||
<div class="flex-grow-1">
|
||||
<h4>Open Source Software</h4>
|
||||
<h4 i18n>Open Source Software</h4>
|
||||
<p class="m-0">
|
||||
The source code is fully available as
|
||||
<a
|
||||
@ -282,9 +295,9 @@
|
||||
</div>
|
||||
<div *ngIf="!user" class="row">
|
||||
<div class="col mt-3 text-center">
|
||||
<a color="primary" mat-flat-button [routerLink]="['/register']">
|
||||
Get Started
|
||||
</a>
|
||||
<a color="primary" i18n mat-flat-button [routerLink]="['/register']"
|
||||
>Get Started</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -44,14 +44,13 @@
|
||||
|
||||
<div *ngIf="hasPermissionForStatistics" class="row mb-5">
|
||||
<div
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Monthly Active Users (MAU)"
|
||||
[routerLink]="['/about']"
|
||||
[routerLink]="['/open']"
|
||||
>
|
||||
<gf-value
|
||||
icon="people-outline"
|
||||
@ -61,24 +60,6 @@
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!hasPermissionForSubscription"
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Contributors on GitHub"
|
||||
[routerLink]="['/about']"
|
||||
>
|
||||
<gf-value
|
||||
icon="people-outline"
|
||||
size="large"
|
||||
[value]="statistics?.gitHubContributors ?? '-'"
|
||||
>Contributors on GitHub</gf-value
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
@ -86,7 +67,7 @@
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Stars on GitHub"
|
||||
[routerLink]="['/about']"
|
||||
[routerLink]="['/open']"
|
||||
>
|
||||
<gf-value
|
||||
icon="star-outline"
|
||||
@ -103,7 +84,7 @@
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Pulls on Docker Hub"
|
||||
[routerLink]="['/about']"
|
||||
[routerLink]="['/open']"
|
||||
>
|
||||
<gf-value
|
||||
icon="cloud-download-outline"
|
||||
|
@ -291,7 +291,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
date: new Date(),
|
||||
id: null,
|
||||
fee: 0,
|
||||
quantity: null,
|
||||
type: aActivity?.type ?? 'BUY',
|
||||
unitPrice: null
|
||||
},
|
||||
|
@ -55,8 +55,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public currencies: string[] = [];
|
||||
public currentMarketPrice = null;
|
||||
public defaultDateFormat: string;
|
||||
public filteredLookupItems: LookupItem[] = [];
|
||||
public filteredLookupItemsObservable: Observable<LookupItem[]> = of([]);
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public isLoading = false;
|
||||
public platforms: { id: string; name: string }[];
|
||||
@ -120,10 +118,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
|
||||
quantity: [this.data.activity?.quantity, Validators.required],
|
||||
searchSymbol: [
|
||||
{
|
||||
dataSource: this.data.activity?.SymbolProfile?.dataSource,
|
||||
symbol: this.data.activity?.SymbolProfile?.symbol
|
||||
},
|
||||
!!this.data.activity?.SymbolProfile
|
||||
? {
|
||||
dataSource: this.data.activity?.SymbolProfile?.dataSource,
|
||||
symbol: this.data.activity?.SymbolProfile?.symbol
|
||||
}
|
||||
: null,
|
||||
Validators.required
|
||||
],
|
||||
tags: [
|
||||
@ -238,28 +238,19 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.filteredLookupItemsObservable = this.activityForm.controls[
|
||||
'searchSymbol'
|
||||
].valueChanges.pipe(
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
switchMap((query: string) => {
|
||||
if (isString(query) && query.length > 1) {
|
||||
const filteredLookupItemsObservable =
|
||||
this.dataService.fetchSymbols(query);
|
||||
this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => {
|
||||
if (this.activityForm.controls['searchSymbol'].invalid) {
|
||||
this.data.activity.SymbolProfile = null;
|
||||
} else {
|
||||
this.activityForm.controls['dataSource'].setValue(
|
||||
this.activityForm.controls['searchSymbol'].value.dataSource
|
||||
);
|
||||
|
||||
filteredLookupItemsObservable
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((filteredLookupItems) => {
|
||||
this.filteredLookupItems = filteredLookupItems;
|
||||
});
|
||||
this.updateSymbol();
|
||||
}
|
||||
|
||||
return filteredLookupItemsObservable;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
);
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.filteredTagsObservable = this.activityForm.controls[
|
||||
'tags'
|
||||
@ -300,6 +291,33 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
|
||||
this.activityForm.controls['updateAccountBalance'].disable();
|
||||
this.activityForm.controls['updateAccountBalance'].setValue(false);
|
||||
} else if (type === 'LIABILITY') {
|
||||
this.activityForm.controls['accountId'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['accountId'].updateValueAndValidity();
|
||||
this.activityForm.controls['currency'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['currencyOfFee'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['currencyOfUnitPrice'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['dataSource'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['dataSource'].updateValueAndValidity();
|
||||
this.activityForm.controls['name'].setValidators(Validators.required);
|
||||
this.activityForm.controls['name'].updateValueAndValidity();
|
||||
this.activityForm.controls['quantity'].setValue(1);
|
||||
this.activityForm.controls['searchSymbol'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
|
||||
this.activityForm.controls['updateAccountBalance'].disable();
|
||||
this.activityForm.controls['updateAccountBalance'].setValue(false);
|
||||
} else {
|
||||
this.activityForm.controls['accountId'].setValidators(
|
||||
Validators.required
|
||||
@ -366,25 +384,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
public onBlurSymbol() {
|
||||
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
|
||||
return (
|
||||
lookupItem.symbol ===
|
||||
this.activityForm.controls['searchSymbol'].value.symbol
|
||||
);
|
||||
});
|
||||
|
||||
if (currentLookupItem) {
|
||||
this.updateSymbol(currentLookupItem.symbol);
|
||||
} else {
|
||||
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
|
||||
|
||||
this.data.activity.SymbolProfile = null;
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
public onCancel() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
@ -428,13 +427,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.dialogRef.close({ activity });
|
||||
}
|
||||
|
||||
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.controls['dataSource'].setValue(
|
||||
event.option.value.dataSource
|
||||
);
|
||||
this.updateSymbol(event.option.value.symbol);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
@ -450,12 +442,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private updateSymbol(symbol: string) {
|
||||
private updateSymbol() {
|
||||
this.isLoading = true;
|
||||
|
||||
this.activityForm.controls['searchSymbol'].setErrors(null);
|
||||
this.activityForm.controls['searchSymbol'].setValue({ symbol });
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
||||
this.dataService
|
||||
|
@ -14,6 +14,7 @@
|
||||
<mat-option i18n value="BUY">Buy</mat-option>
|
||||
<mat-option i18n value="DIVIDEND">Dividend</mat-option>
|
||||
<mat-option i18n value="ITEM">Item</mat-option>
|
||||
<mat-option i18n value="LIABILITY">Liability</mat-option>
|
||||
<mat-option i18n value="SELL">Sell</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@ -47,34 +48,10 @@
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||
<input
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
<gf-symbol-autocomplete
|
||||
formControlName="searchSymbol"
|
||||
matInput
|
||||
[matAutocomplete]="symbolAutocomplete"
|
||||
(blur)="onBlurSymbol()"
|
||||
[isLoading]="isLoading"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#symbolAutocomplete="matAutocomplete"
|
||||
[displayWith]="displayFn"
|
||||
(optionSelected)="onUpdateSymbol($event)"
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
|
||||
class="line-height-1"
|
||||
[value]="lookupItem"
|
||||
>
|
||||
<span><b>{{ lookupItem.name }}</b></span>
|
||||
<br />
|
||||
<small class="text-muted"
|
||||
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
|
||||
}}</small
|
||||
>
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div
|
||||
@ -116,7 +93,10 @@
|
||||
<mat-datepicker #date disabled="false"></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div
|
||||
class="mb-3"
|
||||
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }"
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Quantity</mat-label>
|
||||
<input formControlName="quantity" matInput type="number" />
|
||||
@ -130,6 +110,7 @@
|
||||
>Dividend</ng-container
|
||||
>
|
||||
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
|
||||
<ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container>
|
||||
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
|
||||
</ng-container>
|
||||
</mat-label>
|
||||
@ -177,6 +158,7 @@
|
||||
>Dividend</ng-container
|
||||
>
|
||||
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
|
||||
<ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container>
|
||||
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
|
||||
</ng-container>
|
||||
</mat-label>
|
||||
@ -186,7 +168,10 @@
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div
|
||||
class="mb-3"
|
||||
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }"
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Fee</mat-label>
|
||||
<input formControlName="feeInCustomCurrency" matInput type="number" />
|
||||
@ -302,8 +287,9 @@
|
||||
<div class="d-flex" mat-dialog-actions>
|
||||
<gf-value
|
||||
class="flex-grow-1"
|
||||
[currency]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.user?.settings?.locale"
|
||||
[unit]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
|
||||
[value]="total"
|
||||
></gf-value>
|
||||
<div>
|
||||
|
@ -9,9 +9,8 @@ import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog.component';
|
||||
@ -21,7 +20,7 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfSymbolModule,
|
||||
GfSymbolAutocompleteModule,
|
||||
GfValueModule,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
@ -31,7 +30,6 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
|
@ -41,6 +41,7 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
public errorMessages: string[] = [];
|
||||
public holdings: Position[] = [];
|
||||
public importStep: ImportStep = ImportStep.UPLOAD_FILE;
|
||||
public isLoading = false;
|
||||
public maxSafeInteger = Number.MAX_SAFE_INTEGER;
|
||||
public mode: 'DIVIDEND';
|
||||
public selectedActivities: Activity[] = [];
|
||||
@ -73,6 +74,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
this.data?.activityTypes?.length === 1 &&
|
||||
this.data?.activityTypes?.[0] === 'DIVIDEND'
|
||||
) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.dialogTitle = $localize`Import Dividends`;
|
||||
this.mode = 'DIVIDEND';
|
||||
this.uniqueAssetForm.controls['uniqueAsset'].disable();
|
||||
@ -94,6 +97,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
});
|
||||
this.uniqueAssetForm.controls['uniqueAsset'].enable();
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
@ -32,10 +32,14 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Holding</mat-label>
|
||||
<mat-select formControlName="uniqueAsset">
|
||||
<mat-select-trigger
|
||||
>{{ uniqueAssetForm.controls['uniqueAsset']?.value?.name
|
||||
}}</mat-select-trigger
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let holding of holdings"
|
||||
class="line-height-1"
|
||||
[value]="{dataSource: holding.dataSource, symbol: holding.symbol}"
|
||||
[value]="{ dataSource: holding.dataSource, name: holding.name, symbol: holding.symbol }"
|
||||
>
|
||||
<span><b>{{ holding.name }}</b></span>
|
||||
<br />
|
||||
@ -45,6 +49,11 @@
|
||||
>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-spinner
|
||||
*ngIf="isLoading"
|
||||
class="position-absolute"
|
||||
[diameter]="20"
|
||||
></mat-spinner>
|
||||
</mat-form-field>
|
||||
<div class="d-flex flex-column justify-content-center">
|
||||
<button
|
||||
|
@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatStepperModule } from '@angular/material/stepper';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
@ -27,6 +28,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
|
||||
MatDialogModule,
|
||||
MatExpansionModule,
|
||||
MatFormFieldModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatStepperModule,
|
||||
ReactiveFormsModule
|
||||
|
@ -27,4 +27,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-progress-spinner {
|
||||
right: 1.5rem;
|
||||
top: calc(50% - 10px);
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
Filter,
|
||||
HistoricalDataItem,
|
||||
PortfolioInvestments,
|
||||
Position,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -58,7 +59,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public performanceDataItemsInPercentage: HistoricalDataItem[];
|
||||
public placeholder = '';
|
||||
public portfolioEvolutionDataLabel = $localize`Deposit`;
|
||||
public streaks: PortfolioInvestments['streaks'];
|
||||
public top3: Position[];
|
||||
public unitCurrentStreak: string;
|
||||
public unitLongestStreak: string;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -242,8 +246,25 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ investments }) => {
|
||||
.subscribe(({ investments, streaks }) => {
|
||||
this.investmentsByGroup = investments;
|
||||
this.streaks = streaks;
|
||||
this.unitCurrentStreak =
|
||||
this.mode === 'year'
|
||||
? this.streaks?.currentStreak === 1
|
||||
? translate('YEAR')
|
||||
: translate('YEARS')
|
||||
: this.streaks?.currentStreak === 1
|
||||
? translate('MONTH')
|
||||
: translate('MONTHS');
|
||||
this.unitLongestStreak =
|
||||
this.mode === 'year'
|
||||
? this.streaks?.longestStreak === 1
|
||||
? translate('YEAR')
|
||||
: translate('YEARS')
|
||||
: this.streaks?.longestStreak === 1
|
||||
? translate('MONTH')
|
||||
: translate('MONTHS');
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
@ -177,6 +177,26 @@
|
||||
(change)="onChangeGroupBy($event.value)"
|
||||
></gf-toggle>
|
||||
</div>
|
||||
<div *ngIf="streaks" class="row">
|
||||
<div class="col-md-6 col-xs-12 my-2">
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
[unit]="unitCurrentStreak"
|
||||
[value]="streaks?.currentStreak"
|
||||
>Current Streak</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-6 col-xs-12 my-2">
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
[unit]="unitLongestStreak"
|
||||
[value]="streaks?.longestStreak"
|
||||
>Longest Streak</gf-value
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
|
@ -59,8 +59,9 @@
|
||||
<span class="font-weight-bold"
|
||||
><gf-value
|
||||
class="d-inline-block"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="withdrawalRatePerYear?.toNumber()"
|
||||
></gf-value>
|
||||
per year</span
|
||||
@ -69,8 +70,9 @@
|
||||
<span class="font-weight-bold"
|
||||
><gf-value
|
||||
class="d-inline-block"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="withdrawalRatePerMonth?.toNumber()"
|
||||
></gf-value>
|
||||
per month</span
|
||||
@ -78,8 +80,9 @@
|
||||
<span class="font-weight-bold"
|
||||
><gf-value
|
||||
class="d-inline-block"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="fireWealth?.toNumber()"
|
||||
></gf-value
|
||||
></span>
|
||||
|
@ -23,6 +23,13 @@ import { Observable, map } from 'rxjs';
|
||||
export class AdminService {
|
||||
public constructor(private http: HttpClient) {}
|
||||
|
||||
public addAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.http.post<void>(
|
||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public deleteJob(aId: string) {
|
||||
return this.http.delete<void>(`/api/v1/admin/queue/job/${aId}`);
|
||||
}
|
||||
|
@ -223,16 +223,16 @@ export class ImportActivitiesService {
|
||||
|
||||
for (const key of ImportActivitiesService.DATE_KEYS) {
|
||||
if (item[key]) {
|
||||
if (isMatch(item[key], 'dd-MM-yyyy') && item[key].length === '10') {
|
||||
if (isMatch(item[key], 'dd-MM-yyyy') && item[key].length === 10) {
|
||||
// Check length to only match yyyy (and not yy)
|
||||
date = parse(item[key], 'dd-MM-yyyy', new Date()).toISOString();
|
||||
} else if (
|
||||
isMatch(item[key], 'dd/MM/yyyy') &&
|
||||
item[key].length === '10'
|
||||
item[key].length === 10
|
||||
) {
|
||||
// Check length to only match yyyy (and not yy)
|
||||
date = parse(item[key], 'dd/MM/yyyy', new Date()).toISOString();
|
||||
} else if (isMatch(item[key], 'yyyyMMdd') && item[key].length === '8') {
|
||||
} else if (isMatch(item[key], 'yyyyMMdd') && item[key].length === 8) {
|
||||
// Check length to only match yyyy (and not yy)
|
||||
date = parse(item[key], 'yyyyMMdd', new Date()).toISOString();
|
||||
} else {
|
||||
@ -342,6 +342,8 @@ export class ImportActivitiesService {
|
||||
return Type.DIVIDEND;
|
||||
case 'item':
|
||||
return Type.ITEM;
|
||||
case 'liability':
|
||||
return Type.LIABILITY;
|
||||
case 'sell':
|
||||
return Type.SELL;
|
||||
default:
|
||||
|
@ -77,4 +77,4 @@ You are advised to review this Privacy Policy periodically for any changes. Chan
|
||||
|
||||
## Contact Us
|
||||
|
||||
If you have any questions about this Privacy Policy, You can contact us [here](https://ghostfol.io/about).
|
||||
If you have any questions about this Privacy Policy, You can contact us [here](https://ghostfol.io/en/about).
|
||||
|
@ -58,6 +58,14 @@
|
||||
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
@ -70,6 +78,10 @@
|
||||
<loc>https://ghostfol.io/en/about/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/about/license</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
@ -190,6 +202,10 @@
|
||||
<loc>https://ghostfol.io/es/sobre/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/licencia</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
@ -206,6 +222,10 @@
|
||||
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
@ -260,6 +280,10 @@
|
||||
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
@ -316,6 +340,10 @@
|
||||
<loc>https://ghostfol.io/nl/over/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/licentie</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
@ -332,4 +360,52 @@
|
||||
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/blog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/funcionalidades</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/mercados</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/open</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/precos</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/recursos</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/registo</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
|
||||
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
|
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
@ -2,4 +2,5 @@ import { InvestmentItem } from './investment-item.interface';
|
||||
|
||||
export interface PortfolioInvestments {
|
||||
investments: InvestmentItem[];
|
||||
streaks: { currentStreak: number; longestStreak: number };
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
|
||||
fees: number;
|
||||
firstOrderDate: Date;
|
||||
items: number;
|
||||
liabilities: number;
|
||||
netWorth: number;
|
||||
ordersCount: number;
|
||||
totalBuy: number;
|
||||
|
@ -162,6 +162,7 @@
|
||||
buy: element.type === 'BUY',
|
||||
dividend: element.type === 'DIVIDEND',
|
||||
item: element.type === 'ITEM',
|
||||
liability: element.type === 'LIABILITY',
|
||||
sell: element.type === 'SELL'
|
||||
}"
|
||||
>
|
||||
@ -173,6 +174,10 @@
|
||||
*ngIf="element.type === 'ITEM'"
|
||||
name="cube-outline"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="element.type === 'LIABILITY'"
|
||||
name="flame-outline"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="element.type === 'SELL'"
|
||||
name="arrow-down-circle-outline"
|
||||
@ -504,7 +509,7 @@
|
||||
[matMenuTriggerFor]="activityMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #activityMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onUpdateActivity(element)">
|
||||
@ -538,7 +543,10 @@
|
||||
mat-row
|
||||
[ngClass]="{
|
||||
'cursor-pointer':
|
||||
hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM'
|
||||
hasPermissionToOpenDetails &&
|
||||
!row.isDraft &&
|
||||
row.type !== 'ITEM' &&
|
||||
row.type !== 'LIABILITY'
|
||||
}"
|
||||
(click)="onClickActivity(row)"
|
||||
></tr>
|
||||
|
@ -37,6 +37,10 @@
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
&.liability {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
&.sell {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user