Compare commits

...

41 Commits

Author SHA1 Message Date
5f3d445f1d Release 1.282.0 (#2085) 2023-06-19 20:58:30 +02:00
fce6caebc2 Fix arm64 prisma binary target (#2082)
* Fix arm64 prisma binary target

* Update changelog
2023-06-19 20:56:28 +02:00
d0a4f5c000 Feature/added ability to add asset profile in admin control panel (#2075)
* Added ability to add asset profile in admin control panel

* Update changelog
2023-06-19 20:50:11 +02:00
b5e2a3aa91 Feature/harmonize use of permissions on about and landing page (#2084)
* Harmonize use of permissions

* About page
* Landing page

* About changelog
2023-06-19 20:29:36 +02:00
f47883fb0b Feature/add icon to external links in footer (#2083)
* Add icon for external links

* Update changelog
2023-06-19 20:29:12 +02:00
2932744a68 Feature/improve language localization for german 20230618 (#2081)
* Update translations

* Update changelog
2023-06-19 19:40:10 +02:00
73c0f02e06 Add the final translations for Portuguese (#2079) 2023-06-18 08:59:16 +02:00
382fe24f29 Release 1.281.0 (#2080) 2023-06-17 17:38:30 +02:00
908876ca6e Feature/setup language localization for portuguese (#2076)
* Set up Portuguese

* Update changelog
2023-06-17 17:26:40 +02:00
99cf9f8802 Feature/translation pt 2 (#2074)
* Add more Portuguese translations
2023-06-16 20:47:06 +02:00
7444ff97fc Feature/translation pt (#2073)
* Complete Portuguese translations for home screen and various other Portuguese translations
2023-06-15 08:34:47 +02:00
834a48466e Feature/add liabilities to feature page (#2072)
* Add section for liabilities

* Update changelog
2023-06-14 20:08:04 +02:00
a9526430c2 Improve column headers in holdings table for mobile (#2071)
* Improve column headers in holdings table for mobile

* Update changelog
2023-06-13 21:00:56 +02:00
fce3b2084e Feature/extract symbol search to component (#2003) (#2056)
* Extract symbol search to component (#2003)

* Update changelog
2023-06-13 20:36:16 +02:00
f5a50a95de Feature/upgrade prisma to version 4.15.0 (#2070)
* Upgrade prisma to version 4.15.0

* Update changelog
2023-06-12 15:16:43 +02:00
06dfb91f82 Release 1.280.1 (#2069) 2023-06-10 21:41:39 +02:00
be36050d76 Release 1.280.0 (#2068) 2023-06-10 17:18:28 +02:00
7931e6950d Feature/add support for liabilities (#1789)
* Add support for liabilities

* Update changelog
2023-06-10 16:17:11 +02:00
04eb452e04 Add missing guards (#2067) 2023-06-10 16:16:27 +02:00
6f7e370fca Release 1.279.0 (#2066) 2023-06-10 12:21:11 +02:00
b4a126280f Bugfix/fix public page (#2065)
* Check for user in request because of public page

* Update changelog
2023-06-10 12:19:34 +02:00
2d009aacc4 Bugfix/handle value nullifcation for undefined object (#2064)
* Handle undefined object

* Update changelog
2023-06-10 12:01:26 +02:00
9116443305 Feature/support note in accounts (#2063)
* Add support for a note in accounts

* Update changelog
2023-06-10 12:01:13 +02:00
0adaf12a01 Add new French translations (#2057)
* Add new French translations

* Update changelog

Signed-off-by: Martin Vandenbussche <vandenbusschemartin@gmail.com>
2023-06-10 11:19:33 +02:00
b6562b6e2c Release 1.278.0 (#2062) 2023-06-09 21:14:38 +02:00
b0a4b09ef5 Feature/extract license to dedicated tab (#2061)
* Extract license to tab on about page

* Update changelog
2023-06-09 21:13:05 +02:00
ad8b9ad333 Bugfix/improve spacing in bechmark comparator (#2060)
* Improve spacing

* Update changelog
2023-06-09 20:52:51 +02:00
809956f210 Feature/display markets overview link in footer based on permission (#2059)
* Add check for permission

* Update changelog
2023-06-09 20:01:24 +02:00
6077bfa754 Feature/change direction of ellipsis icon to horizontal in tables (#2055)
* Change direction of ellipsis icon to horizontal

* Update changelog
2023-06-09 19:39:14 +02:00
09498bd804 Feature/refresh cryptocurrencies list 20230606 (#2049)
* Update cryptocurrencies.json

* Update changelog
2023-06-09 18:55:24 +02:00
fd84f4ec14 Change to routerLink (#2058) 2023-06-09 17:37:52 +02:00
c711a11d6e Feature/upgrade node.js from version 16 to 18 (#2053)
* Upgrade to Node.js 18

* Update changelog
2023-06-09 17:28:05 +02:00
8232b05f62 Feature/extend activity cloning by quantity (#2054)
* Allow to clone quantity

* Update changelog
2023-06-09 16:14:40 +02:00
0ea66aebcb Release 1.277.0 (#2052) 2023-06-07 17:35:19 +02:00
64087de3fc Bugfix/fix date format parsing in activities import (#2051)
* Fix date format parsing

* Update changelog
2023-06-07 17:33:35 +02:00
7082ff12f8 Feature/add semantic list structure to header navigation (#2044)
* Add semantic list structure (ul and li elements)

* Update changelog
2023-06-07 17:22:09 +02:00
1c7d92e15e Harmonize Slack community links (#2047) 2023-06-06 09:23:32 +02:00
a53461d257 Feature/migrate currency to unit in value component (#2043)
* Migrate currency to unit

* Update locales
2023-06-04 21:46:49 +02:00
d630fb900d Feature/add investment streaks (#2042)
* Add investment streaks

* Current streak
* Longest streak

* Add unit to value component

* Update changelog
2023-06-04 09:35:58 +02:00
51e8555fa5 Update link (#2040) 2023-06-04 09:30:35 +02:00
9db675b955 Feature/improve get symbol data endpoint (#2041)
* Add default value to query parameter

* Update changelog
2023-06-03 19:32:48 +02:00
106 changed files with 5842 additions and 2846 deletions

View File

@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
node_version:
- 16
- 18
steps:
- name: Checkout code
uses: actions/checkout@v3

2
.nvmrc
View File

@ -1 +1 @@
v16
v18

View File

@ -5,6 +5,80 @@ 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.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 +827,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,6 +21,7 @@ export class ExportService {
select: {
accountType: true,
balance: true,
comment: true,
currency: true,
id: true,
isExcluded: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>&nbsp;</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>

View File

@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]
})

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export interface CreateAssetProfileDialogParams {
deviceType: string;
locale: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '',

View File

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

View File

@ -9,7 +9,7 @@ const routes: Routes = [
canActivate: [AuthGuard],
component: ChangelogPageComponent,
path: '',
title: $localize`Changelog & License`
title: $localize`Changelog`
}
];

View File

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

View File

@ -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]
})

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,8 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,4 +2,5 @@ import { InvestmentItem } from './investment-item.interface';
export interface PortfolioInvestments {
investments: InvestmentItem[];
streaks: { currentStreak: number; longestStreak: number };
}

View File

@ -10,6 +10,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
fees: number;
firstOrderDate: Date;
items: number;
liabilities: number;
netWorth: number;
ordersCount: number;
totalBuy: number;

View File

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

View File

@ -37,6 +37,10 @@
color: var(--purple);
}
&.liability {
color: var(--red);
}
&.sell {
color: var(--orange);
}

View File

@ -206,7 +206,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
} else if (
this.hasPermissionToOpenDetails &&
!activity.isDraft &&
activity.type !== 'ITEM'
activity.type !== 'ITEM' &&
activity.type !== 'LIABILITY'
) {
this.onOpenPositionDialog({
dataSource: activity.SymbolProfile.dataSource,

View File

@ -92,7 +92,8 @@
mat-header-cell
mat-sort-header
>
<ng-container i18n>Allocation</ng-container>
<span class="d-none d-sm-block" i18n>Allocation</span>
<span class="d-block d-sm-none" title="Allocation">%</span>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
@ -108,17 +109,14 @@
<ng-container matColumnDef="performance">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 justify-content-end"
class="justify-content-end px-1"
mat-header-cell
mat-sort-header="netPerformancePercent"
>
<ng-container i18n>Performance</ng-container>
<span class="d-none d-sm-block" i18n>Performance</span>
<span class="d-block d-sm-none" title="Performance">±</span>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[colorizeSign]="true"

View File

@ -13,12 +13,16 @@ const locales = {
HIGHER_RISK: $localize`Higher Risk`,
IMPORT_ACTIVITY_ERROR_IS_DUPLICATE: $localize`This activity already exists.`,
LOWER_RISK: $localize`Lower Risk`,
MONTH: $localize`Month`,
MONTHS: $localize`Months`,
OTHER: $localize`Other`,
RETIREMENT_PROVISION: $localize`Retirement Provision`,
SATELLITE: $localize`Satellite`,
SECURITIES: $localize`Securities`,
SYMBOL: $localize`Symbol`,
TAG: $localize`Tag`,
YEAR: $localize`Year`,
YEARS: $localize`Years`,
// enum AssetClass
CASH: $localize`Cash`,

View File

@ -100,7 +100,7 @@ export class PortfolioProportionChartComponent
};
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.keys[0]].toUpperCase()) {
if (this.positions[symbol][this.keys[0]]?.toUpperCase()) {
if (chartData[this.positions[symbol][this.keys[0]].toUpperCase()]) {
chartData[this.positions[symbol][this.keys[0]].toUpperCase()].value =
chartData[

View File

@ -0,0 +1,178 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
Component,
DoCheck,
ElementRef,
HostBinding,
HostListener,
Input,
OnDestroy
} from '@angular/core';
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { MatFormFieldControl } from '@angular/material/form-field';
import { Subject } from 'rxjs';
@Component({
template: ''
})
export abstract class AbstractMatFormField<T>
implements ControlValueAccessor, DoCheck, MatFormFieldControl<T>, OnDestroy
{
@HostBinding()
public id = `${this.controlType}-${AbstractMatFormField.nextId++}`;
@HostBinding('attr.aria-describedBy') public describedBy = '';
public readonly autofilled: boolean;
public errorState: boolean;
public focused = false;
public readonly stateChanges = new Subject<void>();
public readonly userAriaDescribedBy: string;
protected onChange?: (value: T) => void;
protected onTouched?: () => void;
private static nextId: number = 0;
protected constructor(
protected _elementRef: ElementRef,
protected _focusMonitor: FocusMonitor,
public readonly ngControl: NgControl
) {
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
_focusMonitor
.monitor(this._elementRef.nativeElement, true)
.subscribe((origin) => {
this.focused = !!origin;
this.stateChanges.next();
});
}
private _controlType: string;
public get controlType(): string {
return this._controlType;
}
protected set controlType(value: string) {
this._controlType = value;
this.id = `${this._controlType}-${AbstractMatFormField.nextId++}`;
}
private _value: T;
public get value(): T {
return this._value;
}
public set value(value: T) {
this._value = value;
if (this.onChange) {
this.onChange(value);
}
}
public get empty(): boolean {
return !this._value;
}
public _placeholder: string = '';
public get placeholder() {
return this._placeholder;
}
@Input()
public set placeholder(placeholder: string) {
this._placeholder = placeholder;
this.stateChanges.next();
}
public _required: boolean = false;
public get required() {
return this._required;
}
@Input()
public set required(required: any) {
this._required = coerceBooleanProperty(required);
this.stateChanges.next();
}
public _disabled: boolean = false;
public get disabled() {
if (this.ngControl && this.ngControl.disabled !== null) {
return this.ngControl.disabled;
}
return this._disabled;
}
@Input()
public set disabled(disabled: any) {
this._disabled = coerceBooleanProperty(disabled);
if (this.focused) {
this.focused = false;
this.stateChanges.next();
}
}
public abstract focus(): void;
public get shouldLabelFloat(): boolean {
return this.focused || !this.empty;
}
public ngDoCheck(): void {
if (this.ngControl) {
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public ngOnDestroy(): void {
this.stateChanges.complete();
this._focusMonitor.stopMonitoring(this._elementRef.nativeElement);
}
public registerOnChange(fn: (_: T) => void): void {
this.onChange = fn;
}
public registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
public setDescribedByIds(ids: string[]): void {
this.describedBy = ids.join(' ');
}
public writeValue(value: T): void {
this.value = value;
}
@HostListener('focusout')
public onBlur() {
this.focused = false;
if (this.onTouched) {
this.onTouched();
}
this.stateChanges.next();
}
public onContainerClick(): void {
if (!this.focused) {
this.focus();
}
}
}

View File

@ -0,0 +1 @@
export * from './symbol-autocomplete.module';

View File

@ -0,0 +1,34 @@
<input
autocapitalize="off"
autocomplete="off"
matInput
[formControl]="control"
[matAutocomplete]="symbolAutocomplete"
/>
<mat-autocomplete
#symbolAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)"
>
<ng-container *ngIf="!isLoading">
<mat-option
*ngFor="let lookupItem of filteredLookupItems"
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>
</ng-container>
</mat-autocomplete>
<mat-spinner
*ngIf="isLoading"
class="position-absolute"
[diameter]="20"
></mat-spinner>

View File

@ -0,0 +1,8 @@
:host {
display: block;
.mat-mdc-progress-spinner {
right: 0;
top: calc(50% - 10px);
}
}

View File

@ -0,0 +1,169 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { FormControl, NgControl, Validators } from '@angular/forms';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatFormFieldControl } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { isString } from 'lodash';
import { Observable, Subject, of, tap } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter,
switchMap
} from 'rxjs/operators';
import { AbstractMatFormField } from './abstract-mat-form-field';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.aria-describedBy]': 'describedBy',
'[id]': 'id'
},
selector: 'gf-symbol-autocomplete',
styleUrls: ['./symbol-autocomplete.component.scss'],
templateUrl: 'symbol-autocomplete.component.html',
providers: [
{
provide: MatFormFieldControl,
useExisting: SymbolAutocompleteComponent
}
]
})
export class SymbolAutocompleteComponent
extends AbstractMatFormField<LookupItem>
implements OnInit, OnDestroy
{
@Input() public isLoading = false;
@ViewChild(MatInput, { static: false }) private input: MatInput;
@ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete;
public control = new FormControl();
public filteredLookupItems: LookupItem[] = [];
public filteredLookupItemsObservable: Observable<LookupItem[]> = of([]);
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly _elementRef: ElementRef,
public readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dataService: DataService,
public readonly ngControl: NgControl
) {
super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'symbol-autocomplete';
}
public ngOnInit() {
super.required = this.ngControl.control?.hasValidator(Validators.required);
if (this.disabled) {
this.control.disable();
}
this.control.valueChanges
.pipe(
debounceTime(400),
distinctUntilChanged(),
filter((query) => {
return isString(query) && query.length > 1;
}),
tap(() => {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
}),
switchMap((query: string) => {
return this.dataService.fetchSymbols(query);
})
)
.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
public displayFn(aLookupItem: LookupItem) {
return aLookupItem?.symbol ?? '';
}
public get empty() {
return this.input?.empty;
}
public focus() {
this.input.focus();
}
public isValueInOptions(value: string) {
return this.filteredLookupItems.some((item) => {
return item.symbol === value;
});
}
public ngDoCheck() {
if (this.ngControl) {
this.validateRequired();
this.validateSelection();
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
super.value = {
dataSource: event.option.value.dataSource,
symbol: event.option.value.symbol
} as LookupItem;
}
public set value(value: LookupItem) {
this.control.setValue(value);
super.value = value;
}
public ngOnDestroy() {
super.ngOnDestroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private validateRequired() {
const requiredCheck = super.required
? !super.value?.dataSource || !super.value?.symbol
: false;
if (requiredCheck) {
this.ngControl.control.setErrors({ invalidData: true });
}
}
private validateSelection() {
const error =
!this.isValueInOptions(this.input?.value) ||
this.input?.value !== super.value?.symbol;
if (error) {
this.ngControl.control.setErrors({ invalidData: true });
}
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { SymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.component';
@NgModule({
declarations: [SymbolAutocompleteComponent],
exports: [SymbolAutocompleteComponent],
imports: [
CommonModule,
FormsModule,
GfSymbolModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfSymbolAutocompleteModule {}

View File

@ -32,11 +32,11 @@
{{ formattedValue }}
</ng-container>
</div>
<small *ngIf="currency && size === 'medium'" class="ml-1">
{{ currency }}
<small *ngIf="unit && size === 'medium'" class="ml-1">
{{ unit }}
</small>
<div *ngIf="currency && size !== 'medium'" class="ml-1">
{{ currency }}
<div *ngIf="unit && size !== 'medium'" class="ml-1">
{{ unit }}
</div>
</ng-container>
<ng-container *ngIf="isString">

View File

@ -24,8 +24,9 @@ Loading.args = {
export const Currency = Template.bind({});
Currency.args = {
currency: 'USD',
isCurrency: true,
locale: 'en-US',
unit: 'USD',
value: 7
};

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