Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
893ca83d3a | |||
23da1bd293 | |||
fa66cd5bce | |||
9344dcd26e | |||
90ad22cccf | |||
dcc7ef89fe | |||
e355847f40 | |||
76f70598e2 | |||
7af5cd244a | |||
86943a5f5b | |||
6eb4eae4a9 | |||
6ac693dd39 | |||
e29f7f8976 | |||
82069da4e2 | |||
07656c6a95 | |||
16f0743353 | |||
9b5ec0c56d | |||
8d2fcc6b42 | |||
e625e55784 | |||
bed3e5aae2 | |||
65bfe52db4 | |||
48b524de5a | |||
67d40333f6 |
71
CHANGELOG.md
71
CHANGELOG.md
@ -5,6 +5,77 @@ 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.115.0 - 13.02.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a feature overview page
|
||||
- Added the asset and asset sub class to the position detail dialog
|
||||
- Added the countries and sectors to the position detail dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `angular` from version `13.1.2` to `13.2.3`
|
||||
- Upgraded `Nx` from version `13.4.1` to `13.8.1`
|
||||
- Upgraded `storybook` from version `6.4.9` to `6.4.18`
|
||||
|
||||
## 1.114.1 - 10.02.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the creation of (wealth) items
|
||||
|
||||
## 1.114.0 - 10.02.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for (wealth) items
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.113.0 - 09.02.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the position of the currency column in the accounts table
|
||||
- Improved the position of the currency column in the activities table
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the performance calculation in connection with fees in the new calculation engine
|
||||
|
||||
## 1.112.1 - 06.02.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the creation of the user account (missing access token)
|
||||
|
||||
## 1.112.0 - 06.02.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the export functionality to the position detail dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the export functionality for activities (respect filtering)
|
||||
- Removed the _Admin_ user from the database seeding
|
||||
- Assigned the role `ADMIN` on sign up (only if there is no admin yet)
|
||||
- Upgraded `prisma` from version `3.8.1` to `3.9.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the performance calculation in connection with a sell activity in the new calculation engine
|
||||
- Fixed the horizontal overflow in the accounts table
|
||||
- Fixed the horizontal overflow in the activities table
|
||||
- Fixed the total value of the activities table in the position detail dialog (absolute value)
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.111.0 - 03.02.2022
|
||||
|
||||
### Added
|
||||
|
21
README.md
21
README.md
@ -41,21 +41,13 @@ If you prefer to run Ghostfolio on your own infrastructure (self-hosting), pleas
|
||||
Ghostfolio is for you if you are...
|
||||
|
||||
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
|
||||
|
||||
- 🏦 pursuing a buy & hold strategy
|
||||
|
||||
- 🎯 interested in getting insights of your portfolio composition
|
||||
|
||||
- 👻 valuing privacy and data ownership
|
||||
|
||||
- 🧘 into minimalism
|
||||
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
|
||||
- 🆓 interested in financial independence
|
||||
|
||||
- 🙅 saying no to spreadsheets in 2021
|
||||
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
@ -65,6 +57,7 @@ Ghostfolio is for you if you are...
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
- ✅ Dark Mode
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
@ -124,16 +117,10 @@ docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:
|
||||
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
|
||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Finalization
|
||||
|
||||
1. Create a new user via _Get Started_
|
||||
1. Assign the role `ADMIN` to this user (directly in the database)
|
||||
1. Delete the original _Admin_ (directly in the database)
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||
@ -155,8 +142,8 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
|
||||
1. Run `yarn install`
|
||||
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Start server and client (see [_Development_](#Development))
|
||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
|
@ -264,7 +264,8 @@
|
||||
"port": 4400,
|
||||
"config": {
|
||||
"configFolder": "libs/ui/.storybook"
|
||||
}
|
||||
},
|
||||
"projectBuildConfig": "ui:build-storybook"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
@ -280,7 +281,8 @@
|
||||
"outputPath": "dist/storybook/ui",
|
||||
"config": {
|
||||
"configFolder": "libs/ui/.storybook"
|
||||
}
|
||||
},
|
||||
"projectBuildConfig": "ui:build-storybook"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@ -15,8 +22,11 @@ export class ExportController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async export(): Promise<Export> {
|
||||
return await this.exportService.export({
|
||||
public async export(
|
||||
@Query('activityIds') activityIds?: string[]
|
||||
): Promise<Export> {
|
||||
return this.exportService.export({
|
||||
activityIds,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
@ -7,8 +7,14 @@ import { Injectable } from '@nestjs/common';
|
||||
export class ExportService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async export({ userId }: { userId: string }): Promise<Export> {
|
||||
const orders = await this.prismaService.order.findMany({
|
||||
public async export({
|
||||
activityIds,
|
||||
userId
|
||||
}: {
|
||||
activityIds?: string[];
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
let orders = await this.prismaService.order.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
@ -16,6 +22,7 @@ export class ExportService {
|
||||
dataSource: true,
|
||||
date: true,
|
||||
fee: true,
|
||||
id: true,
|
||||
quantity: true,
|
||||
SymbolProfile: true,
|
||||
type: true,
|
||||
@ -24,6 +31,12 @@ export class ExportService {
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
orders = orders.filter((order) => {
|
||||
return activityIds.includes(order.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
orders: orders.map(
|
||||
@ -46,7 +59,7 @@ export class ExportService {
|
||||
type,
|
||||
unitPrice,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||
};
|
||||
}
|
||||
)
|
||||
|
@ -21,8 +21,13 @@ export class ImportService {
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const order of orders) {
|
||||
order.dataSource =
|
||||
order.dataSource ?? this.dataProviderService.getPrimaryDataSource();
|
||||
if (!order.dataSource) {
|
||||
if (order.type === 'ITEM') {
|
||||
order.dataSource = 'MANUAL';
|
||||
} else {
|
||||
order.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.validateOrders({ orders, userId });
|
||||
@ -111,20 +116,22 @@ export class ImportService {
|
||||
throw new Error(`orders.${index} is a duplicate transaction`);
|
||||
}
|
||||
|
||||
const result = await this.dataProviderService.get([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const result = await this.dataProviderService.get([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (result[symbol] === undefined) {
|
||||
throw new Error(
|
||||
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
if (result[symbol] === undefined) {
|
||||
throw new Error(
|
||||
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (result[symbol].currency !== currency) {
|
||||
throw new Error(
|
||||
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
|
||||
);
|
||||
if (result[symbol].currency !== currency) {
|
||||
throw new Error(
|
||||
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OrderController } from './order.controller';
|
||||
@ -22,6 +23,7 @@ import { OrderService } from './order.service';
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [OrderController],
|
||||
|
@ -3,11 +3,13 @@ import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Activity } from './interfaces/activities.interface';
|
||||
|
||||
@ -18,7 +20,8 @@ export class OrderService {
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async order(
|
||||
@ -58,7 +61,7 @@ export class OrderService {
|
||||
return account.isDefault === true;
|
||||
});
|
||||
|
||||
const Account = {
|
||||
let Account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
@ -67,24 +70,47 @@ export class OrderService {
|
||||
}
|
||||
};
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
if (data.type === 'ITEM') {
|
||||
const currency = data.currency;
|
||||
const dataSource: DataSource = 'MANUAL';
|
||||
const id = uuidv4();
|
||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||
|
||||
// Convert the symbol to uppercase to avoid case-sensitive duplicates
|
||||
const symbol = data.symbol.toUpperCase();
|
||||
Account = undefined;
|
||||
data.dataSource = dataSource;
|
||||
data.id = id;
|
||||
data.symbol = null;
|
||||
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
|
||||
dataSource,
|
||||
symbol: id
|
||||
};
|
||||
} else {
|
||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||
}
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
symbol,
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date
|
||||
date: <Date>data.date,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([
|
||||
{ symbol, dataSource: data.dataSource }
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
}
|
||||
]);
|
||||
|
||||
await this.cacheService.flush();
|
||||
@ -98,8 +124,7 @@ export class OrderService {
|
||||
data: {
|
||||
...orderData,
|
||||
Account,
|
||||
isDraft,
|
||||
symbol
|
||||
isDraft
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -107,9 +132,15 @@ export class OrderService {
|
||||
public async deleteOrder(
|
||||
where: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order> {
|
||||
return this.prismaService.order.delete({
|
||||
const order = await this.prismaService.order.delete({
|
||||
where
|
||||
});
|
||||
|
||||
if (order.type === 'ITEM') {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public async getOrders({
|
||||
@ -180,6 +211,17 @@ export class OrderService {
|
||||
}): Promise<Order> {
|
||||
const { data, where } = params;
|
||||
|
||||
if (data.Account.connect.id_userId.id === null) {
|
||||
delete data.Account;
|
||||
}
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
const name = data.symbol;
|
||||
|
||||
data.symbol = null;
|
||||
data.SymbolProfile = { update: { name } };
|
||||
}
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateOrderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountId: string;
|
||||
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
averagePrice: number;
|
||||
currency: string;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
@ -14,12 +11,11 @@ export interface PortfolioPositionDetail {
|
||||
marketPrice: number;
|
||||
maxPrice: number;
|
||||
minPrice: number;
|
||||
name: string;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
orders: OrderWithAccount[];
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
SymbolProfile: EnhancedSymbolProfile;
|
||||
transactionCount: number;
|
||||
value: number;
|
||||
}
|
||||
|
@ -82,7 +82,7 @@ export class PortfolioCalculatorNew {
|
||||
: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.add(oldAccumulatedSymbol.investment),
|
||||
.plus(oldAccumulatedSymbol.investment),
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
@ -337,13 +337,19 @@ export class PortfolioCalculatorNew {
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastValueOfInvestment = new Big(0);
|
||||
let lastNetValueOfInvestment = new Big(0);
|
||||
let lastTransactionInvestment = new Big(0);
|
||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
|
||||
const holdingPeriodPerformances: {
|
||||
grossReturn: Big;
|
||||
netReturn: Big;
|
||||
valueOfInvestment: Big;
|
||||
}[] = [];
|
||||
|
||||
// Add a synthetic order at the start and the end date
|
||||
orders.push({
|
||||
symbol,
|
||||
@ -394,7 +400,13 @@ export class PortfolioCalculatorNew {
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
const transactionInvestment = order.quantity.mul(order.unitPrice);
|
||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
||||
order.unitPrice
|
||||
);
|
||||
|
||||
const transactionInvestment = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
|
||||
if (
|
||||
!initialValue &&
|
||||
@ -411,7 +423,6 @@ export class PortfolioCalculatorNew {
|
||||
);
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
||||
const netValueOfInvestment = totalUnits.mul(order.unitPrice).sub(fees);
|
||||
|
||||
const grossPerformanceFromSell =
|
||||
order.type === TypeOfOrder.SELL
|
||||
@ -423,7 +434,7 @@ export class PortfolioCalculatorNew {
|
||||
);
|
||||
|
||||
totalInvestment = totalInvestment
|
||||
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||
.plus(transactionInvestment)
|
||||
.plus(grossPerformanceFromSell);
|
||||
|
||||
lastAveragePrice = totalUnits.eq(0)
|
||||
@ -436,48 +447,60 @@ export class PortfolioCalculatorNew {
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestment
|
||||
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||
!lastValueOfInvestmentBeforeTransaction
|
||||
.plus(lastTransactionInvestment)
|
||||
.eq(0)
|
||||
) {
|
||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(
|
||||
valueOfInvestment
|
||||
.minus(
|
||||
lastValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
new Big(1).plus(grossHoldingPeriodReturn)
|
||||
);
|
||||
|
||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(fees.minus(feesAtStartDate))
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(
|
||||
netValueOfInvestment
|
||||
.minus(
|
||||
lastNetValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastNetValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
)
|
||||
new Big(1).plus(netHoldingPeriodReturn)
|
||||
);
|
||||
|
||||
holdingPeriodPerformances.push({
|
||||
grossReturn: grossHoldingPeriodReturn,
|
||||
netReturn: netHoldingPeriodReturn,
|
||||
valueOfInvestment: lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
lastNetValueOfInvestment = netValueOfInvestment;
|
||||
lastValueOfInvestment = valueOfInvestment;
|
||||
|
||||
lastTransactionInvestment = transactionInvestment;
|
||||
|
||||
lastValueOfInvestmentBeforeTransaction =
|
||||
valueOfInvestmentBeforeTransaction;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
@ -486,10 +509,10 @@ export class PortfolioCalculatorNew {
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.sub(1);
|
||||
timeWeightedGrossPerformancePercentage.minus(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.sub(1);
|
||||
timeWeightedNetPerformancePercentage.minus(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
@ -499,13 +522,39 @@ export class PortfolioCalculatorNew {
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
let valueOfInvestmentSum = new Big(0);
|
||||
|
||||
for (const holdingPeriodPerformance of holdingPeriodPerformances) {
|
||||
valueOfInvestmentSum = valueOfInvestmentSum.plus(
|
||||
holdingPeriodPerformance.valueOfInvestment
|
||||
);
|
||||
}
|
||||
|
||||
let totalWeightedGrossPerformance = new Big(0);
|
||||
let totalWeightedNetPerformance = new Big(0);
|
||||
|
||||
// Weight the holding period returns according to their value of investment
|
||||
for (const holdingPeriodPerformance of holdingPeriodPerformances) {
|
||||
totalWeightedGrossPerformance = totalWeightedGrossPerformance.plus(
|
||||
holdingPeriodPerformance.grossReturn
|
||||
.mul(holdingPeriodPerformance.valueOfInvestment)
|
||||
.div(valueOfInvestmentSum)
|
||||
);
|
||||
|
||||
totalWeightedNetPerformance = totalWeightedNetPerformance.plus(
|
||||
holdingPeriodPerformance.netReturn
|
||||
.mul(holdingPeriodPerformance.valueOfInvestment)
|
||||
.div(valueOfInvestmentSum)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
initialValue,
|
||||
hasErrors: !initialValue || !unitPriceAtEndDate,
|
||||
netPerformance: totalNetPerformance,
|
||||
netPerformancePercentage: timeWeightedNetPerformancePercentage,
|
||||
netPerformancePercentage: totalWeightedNetPerformance,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
grossPerformancePercentage: timeWeightedGrossPerformancePercentage
|
||||
grossPerformancePercentage: totalWeightedGrossPerformance
|
||||
};
|
||||
}
|
||||
|
||||
@ -519,7 +568,7 @@ export class PortfolioCalculatorNew {
|
||||
date: transactionPoint.date,
|
||||
investment: transactionPoint.items.reduce(
|
||||
(investment, transactionPointSymbol) =>
|
||||
investment.add(transactionPointSymbol.investment),
|
||||
investment.plus(transactionPointSymbol.investment),
|
||||
new Big(0)
|
||||
)
|
||||
};
|
||||
@ -636,13 +685,13 @@ export class PortfolioCalculatorNew {
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.add(
|
||||
currentValue = currentValue.plus(
|
||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
totalInvestment = totalInvestment.add(currentPosition.investment);
|
||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
@ -711,8 +760,8 @@ export class PortfolioCalculatorNew {
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.add(item.investment);
|
||||
fees = fees.add(item.fee);
|
||||
investment = investment.plus(item.investment);
|
||||
fees = fees.plus(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
@ -768,7 +817,7 @@ export class PortfolioCalculatorNew {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
value = value.add(
|
||||
value = value.plus(
|
||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||
);
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ export class PortfolioCalculator {
|
||||
: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.add(oldAccumulatedSymbol.investment),
|
||||
.plus(oldAccumulatedSymbol.investment),
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
@ -354,7 +354,7 @@ export class PortfolioCalculator {
|
||||
date: transactionPoint.date,
|
||||
investment: transactionPoint.items.reduce(
|
||||
(investment, transactionPointSymbol) =>
|
||||
investment.add(transactionPointSymbol.investment),
|
||||
investment.plus(transactionPointSymbol.investment),
|
||||
new Big(0)
|
||||
)
|
||||
};
|
||||
@ -475,13 +475,13 @@ export class PortfolioCalculator {
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.add(
|
||||
currentValue = currentValue.plus(
|
||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
totalInvestment = totalInvestment.add(currentPosition.investment);
|
||||
totalInvestment = totalInvestment.plus(currentPosition.investment);
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
@ -562,8 +562,8 @@ export class PortfolioCalculator {
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.add(item.investment);
|
||||
fees = fees.add(item.fee);
|
||||
investment = investment.plus(item.investment);
|
||||
fees = fees.plus(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
@ -619,7 +619,7 @@ export class PortfolioCalculator {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
value = value.add(
|
||||
value = value.plus(
|
||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||
);
|
||||
}
|
||||
|
@ -332,6 +332,7 @@ export class PortfolioController {
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'fees',
|
||||
'items',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
@ -343,6 +344,7 @@ export class PortfolioController {
|
||||
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
|
@ -417,7 +417,6 @@ export class PortfolioServiceNew {
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
averagePrice: undefined,
|
||||
currency: undefined,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
@ -426,21 +425,20 @@ export class PortfolioServiceNew {
|
||||
marketPrice: undefined,
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
name: undefined,
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
orders: [],
|
||||
quantity: undefined,
|
||||
symbol: aSymbol,
|
||||
SymbolProfile: undefined,
|
||||
transactionCount: undefined,
|
||||
value: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const assetClass = orders[0].SymbolProfile?.assetClass;
|
||||
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
|
||||
const positionCurrency = orders[0].currency;
|
||||
const name = orders[0].SymbolProfile?.name ?? '';
|
||||
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
aSymbol
|
||||
]);
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders
|
||||
.filter((order) => {
|
||||
@ -557,18 +555,15 @@ export class PortfolioServiceNew {
|
||||
}
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
firstBuyDate,
|
||||
grossPerformance,
|
||||
investment,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
netPerformance,
|
||||
orders,
|
||||
SymbolProfile,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformancePercent:
|
||||
@ -576,7 +571,6 @@ export class PortfolioServiceNew {
|
||||
historicalData: historicalDataArray,
|
||||
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
||||
quantity: quantity.toNumber(),
|
||||
symbol: aSymbol,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
quantity.mul(marketPrice).toNumber(),
|
||||
currency,
|
||||
@ -621,15 +615,12 @@ export class PortfolioServiceNew {
|
||||
}
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
orders,
|
||||
SymbolProfile,
|
||||
averagePrice: 0,
|
||||
currency: currentData[aSymbol]?.currency,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
@ -638,7 +629,6 @@ export class PortfolioServiceNew {
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
quantity: 0,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined,
|
||||
value: 0
|
||||
};
|
||||
@ -891,14 +881,16 @@ export class PortfolioServiceNew {
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
const items = this.getItems(orders).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||
|
||||
const netWorth = new Big(balance)
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
@ -922,6 +914,7 @@ export class PortfolioServiceNew {
|
||||
dividend,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
@ -1043,6 +1036,28 @@ export class PortfolioServiceNew {
|
||||
);
|
||||
}
|
||||
|
||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type item
|
||||
return (
|
||||
isBefore(date, new Date(order.date)) &&
|
||||
order.type === TypeOfOrder.ITEM
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
|
@ -405,7 +405,6 @@ export class PortfolioService {
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
averagePrice: undefined,
|
||||
currency: undefined,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
@ -414,21 +413,20 @@ export class PortfolioService {
|
||||
marketPrice: undefined,
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
name: undefined,
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
orders: [],
|
||||
quantity: undefined,
|
||||
symbol: aSymbol,
|
||||
SymbolProfile: undefined,
|
||||
transactionCount: undefined,
|
||||
value: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const assetClass = orders[0].SymbolProfile?.assetClass;
|
||||
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
|
||||
const positionCurrency = orders[0].currency;
|
||||
const name = orders[0].SymbolProfile?.name ?? '';
|
||||
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
aSymbol
|
||||
]);
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders
|
||||
.filter((order) => {
|
||||
@ -543,25 +541,21 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
firstBuyDate,
|
||||
grossPerformance,
|
||||
investment,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
netPerformance,
|
||||
orders,
|
||||
SymbolProfile,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||
historicalData: historicalDataArray,
|
||||
netPerformancePercent: position.netPerformancePercentage.toNumber(),
|
||||
quantity: quantity.toNumber(),
|
||||
symbol: aSymbol,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
quantity.mul(marketPrice).toNumber(),
|
||||
currency,
|
||||
@ -606,15 +600,12 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
orders,
|
||||
SymbolProfile,
|
||||
averagePrice: 0,
|
||||
currency: currentData[aSymbol]?.currency,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
@ -623,7 +614,6 @@ export class PortfolioService {
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
quantity: 0,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined,
|
||||
value: 0
|
||||
};
|
||||
@ -869,14 +859,16 @@ export class PortfolioService {
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
const items = this.getItems(orders).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||
|
||||
const netWorth = new Big(balance)
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
@ -884,6 +876,7 @@ export class PortfolioService {
|
||||
dividend,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
@ -1007,6 +1000,28 @@ 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
|
||||
return (
|
||||
isBefore(date, new Date(order.date)) &&
|
||||
order.type === TypeOfOrder.ITEM
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export interface UserItem {
|
||||
accessToken?: string;
|
||||
authToken: string;
|
||||
role: Role;
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { Provider, Role } from '@prisma/client';
|
||||
import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -83,12 +83,15 @@ export class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
const { accessToken, id } = await this.userService.createUser({
|
||||
provider: Provider.ANONYMOUS
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
|
||||
const { accessToken, id, role } = await this.userService.createUser({
|
||||
role: hasAdmin ? 'USER' : 'ADMIN'
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
role,
|
||||
authToken: this.jwtService.sign({
|
||||
id
|
||||
})
|
||||
|
@ -70,6 +70,18 @@ export class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
public async hasAdmin() {
|
||||
const usersWithAdminRole = await this.users({
|
||||
where: {
|
||||
role: {
|
||||
equals: 'ADMIN'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return usersWithAdminRole.length > 0;
|
||||
}
|
||||
|
||||
public isRestrictedView(aUser: UserWithSettings) {
|
||||
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
||||
}
|
||||
@ -168,7 +180,11 @@ export class UserService {
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
|
||||
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
||||
if (!data?.provider) {
|
||||
data.provider = 'ANONYMOUS';
|
||||
}
|
||||
|
||||
let user = await this.prismaService.user.create({
|
||||
data: {
|
||||
...data,
|
||||
@ -187,7 +203,7 @@ export class UserService {
|
||||
}
|
||||
});
|
||||
|
||||
if (data.provider === Provider.ANONYMOUS) {
|
||||
if (data.provider === 'ANONYMOUS') {
|
||||
const accessToken = this.createAccessToken(
|
||||
user.id,
|
||||
this.getRandomString(10)
|
||||
|
@ -58,12 +58,25 @@ export class TransformDataSourceInResponseInterceptor<T>
|
||||
});
|
||||
}
|
||||
|
||||
if (data.orders) {
|
||||
data.orders.map((order) => {
|
||||
order.dataSource = encodeDataSource(order.dataSource);
|
||||
return order;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.positions) {
|
||||
data.positions.map((position) => {
|
||||
position.dataSource = encodeDataSource(position.dataSource);
|
||||
return position;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.SymbolProfile) {
|
||||
data.SymbolProfile.dataSource = encodeDataSource(
|
||||
data.SymbolProfile.dataSource
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
|
@ -445,6 +445,11 @@ export class DataGatheringService {
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
dataSource: {
|
||||
not: 'MANUAL'
|
||||
}
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
@ -479,6 +484,11 @@ export class DataGatheringService {
|
||||
dataSource: true,
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
dataSource: {
|
||||
not: 'MANUAL'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -537,6 +547,7 @@ export class DataGatheringService {
|
||||
return distinctOrders.filter((distinctOrder) => {
|
||||
return (
|
||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
distinctOrder.dataSource !== DataSource.MANUAL &&
|
||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
});
|
||||
|
@ -2,6 +2,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
@ -23,6 +24,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
AlphaVantageService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
],
|
||||
@ -38,12 +41,14 @@ import { DataProviderService } from './data-provider.service';
|
||||
alphaVantageService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rakutenRapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rakutenRapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
|
@ -194,6 +194,7 @@ export class DataProviderService {
|
||||
return dataProviderInterface;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No data provider has been found.');
|
||||
}
|
||||
}
|
||||
|
43
apps/api/src/services/data-provider/manual/manual.service.ts
Normal file
43
apps/api/src/services/data-provider/manual/manual.service.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class ManualService implements DataProviderInterface {
|
||||
public constructor() {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbols: string[],
|
||||
aGranularity: Granularity = 'day',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.MANUAL;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
}
|
||||
}
|
@ -25,6 +25,12 @@ export class SymbolProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteById(id: string) {
|
||||
return this.prismaService.symbolProfile.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
public async getSymbolProfiles(
|
||||
symbols: string[]
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
|
@ -66,6 +66,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||
},
|
||||
{
|
||||
path: 'features',
|
||||
loadChildren: () =>
|
||||
import('./pages/features/features-page.module').then(
|
||||
(m) => m.FeaturesPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
|
@ -46,11 +46,11 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
public ngOnChanges() {
|
||||
this.displayedColumns = [
|
||||
'account',
|
||||
'currency',
|
||||
'platform',
|
||||
'transactions',
|
||||
'balance',
|
||||
'value'
|
||||
'value',
|
||||
'currency'
|
||||
];
|
||||
|
||||
if (this.showActions) {
|
||||
|
@ -238,6 +238,17 @@
|
||||
></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
|
||||
|
@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import {
|
||||
RANGE,
|
||||
SettingsStorageService
|
||||
@ -26,6 +27,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public dateRange: DateRange;
|
||||
public dateRangeOptions = defaultDateRangeOptions;
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public positions: Position[];
|
||||
public user: User;
|
||||
@ -40,6 +42,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
@ -82,6 +85,13 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((aId) => {
|
||||
this.hasImpersonationId = !!aId;
|
||||
});
|
||||
|
||||
this.dateRange =
|
||||
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
|
||||
|
||||
@ -119,6 +129,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
|
@ -27,7 +27,7 @@
|
||||
i18n
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities...</a
|
||||
>Manage Activities</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -142,6 +142,17 @@
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row px-3 py-1">
|
||||
<div class="d-flex flex-grow-1" i18n>Items</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : summary?.items"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><hr /></div>
|
||||
</div>
|
||||
|
@ -4,6 +4,7 @@ export interface PositionDetailDialogParams {
|
||||
baseCurrency: string;
|
||||
dataSource: DataSource;
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -8,10 +8,10 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { AssetSubClass } from '@prisma/client';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -26,10 +26,11 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./position-detail-dialog.component.scss']
|
||||
})
|
||||
export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public assetSubClass: AssetSubClass;
|
||||
public averagePrice: number;
|
||||
public benchmarkDataItems: LineChartItem[];
|
||||
public currency: string;
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public firstBuyDate: string;
|
||||
public grossPerformance: number;
|
||||
public grossPerformancePercent: number;
|
||||
@ -38,13 +39,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public marketPrice: number;
|
||||
public maxPrice: number;
|
||||
public minPrice: number;
|
||||
public name: string;
|
||||
public netPerformance: number;
|
||||
public netPerformancePercent: number;
|
||||
public orders: OrderWithAccount[];
|
||||
public quantity: number;
|
||||
public quantityPrecision = 2;
|
||||
public symbol: string;
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public SymbolProfile: SymbolProfile;
|
||||
public transactionCount: number;
|
||||
public value: number;
|
||||
|
||||
@ -66,9 +69,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(
|
||||
({
|
||||
assetSubClass,
|
||||
averagePrice,
|
||||
currency,
|
||||
firstBuyDate,
|
||||
grossPerformance,
|
||||
grossPerformancePercent,
|
||||
@ -77,19 +78,17 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
netPerformance,
|
||||
netPerformancePercent,
|
||||
orders,
|
||||
quantity,
|
||||
symbol,
|
||||
SymbolProfile,
|
||||
transactionCount,
|
||||
value
|
||||
}) => {
|
||||
this.assetSubClass = assetSubClass;
|
||||
this.averagePrice = averagePrice;
|
||||
this.benchmarkDataItems = [];
|
||||
this.currency = currency;
|
||||
this.countries = {};
|
||||
this.firstBuyDate = firstBuyDate;
|
||||
this.grossPerformance = grossPerformance;
|
||||
this.grossPerformancePercent = grossPerformancePercent;
|
||||
@ -110,15 +109,33 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
this.marketPrice = marketPrice;
|
||||
this.maxPrice = maxPrice;
|
||||
this.minPrice = minPrice;
|
||||
this.name = name;
|
||||
this.netPerformance = netPerformance;
|
||||
this.netPerformancePercent = netPerformancePercent;
|
||||
this.orders = orders;
|
||||
this.quantity = quantity;
|
||||
this.symbol = symbol;
|
||||
this.sectors = {};
|
||||
this.SymbolProfile = SymbolProfile;
|
||||
this.transactionCount = transactionCount;
|
||||
this.value = value;
|
||||
|
||||
if (SymbolProfile?.countries?.length > 0) {
|
||||
for (const country of SymbolProfile.countries) {
|
||||
this.countries[country.code] = {
|
||||
name: country.name,
|
||||
value: country.weight
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (SymbolProfile?.sectors?.length > 0) {
|
||||
for (const sector of SymbolProfile.sectors) {
|
||||
this.sectors[sector.name] = {
|
||||
name: sector.name,
|
||||
value: sector.weight
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isToday(parseISO(this.firstBuyDate))) {
|
||||
// Add average price
|
||||
this.historicalDataItems.push({
|
||||
@ -166,7 +183,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
|
||||
if (Number.isInteger(this.quantity)) {
|
||||
this.quantityPrecision = 0;
|
||||
} else if (assetSubClass === 'CRYPTOCURRENCY') {
|
||||
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
||||
if (this.quantity < 1) {
|
||||
this.quantityPrecision = 7;
|
||||
} else if (this.quantity < 1000) {
|
||||
@ -185,6 +202,26 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
this.dataService
|
||||
.fetchExport(
|
||||
this.orders.map((order) => {
|
||||
return order.id;
|
||||
})
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data) => {
|
||||
downloadAsFile(
|
||||
data,
|
||||
`ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
|
||||
parseISO(data.meta.date),
|
||||
'yyyyMMddHHmm'
|
||||
)}.json`,
|
||||
'text/plain'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -2,7 +2,7 @@
|
||||
mat-dialog-title
|
||||
position="center"
|
||||
[deviceType]="data.deviceType"
|
||||
[title]="name ?? symbol"
|
||||
[title]="SymbolProfile?.name ?? SymbolProfile?.symbol"
|
||||
(closeButtonClicked)="onClose()"
|
||||
></gf-dialog-header>
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
<gf-value
|
||||
label="Ø Buy Price"
|
||||
size="medium"
|
||||
[currency]="currency"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[locale]="data.locale"
|
||||
[value]="averagePrice"
|
||||
></gf-value>
|
||||
@ -64,7 +64,7 @@
|
||||
<gf-value
|
||||
label="Market Price"
|
||||
size="medium"
|
||||
[currency]="currency"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[locale]="data.locale"
|
||||
[value]="marketPrice"
|
||||
></gf-value>
|
||||
@ -73,7 +73,7 @@
|
||||
<gf-value
|
||||
label="Minimum Price"
|
||||
size="medium"
|
||||
[currency]="currency"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[locale]="data.locale"
|
||||
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
||||
[value]="minPrice"
|
||||
@ -83,7 +83,7 @@
|
||||
<gf-value
|
||||
label="Maximum Price"
|
||||
size="medium"
|
||||
[currency]="currency"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[locale]="data.locale"
|
||||
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }"
|
||||
[value]="maxPrice"
|
||||
@ -122,6 +122,72 @@
|
||||
[value]="transactionCount"
|
||||
></gf-value>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
label="Asset Class"
|
||||
size="medium"
|
||||
[value]="SymbolProfile?.assetClass"
|
||||
></gf-value>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
size="medium"
|
||||
label="Asset Sub Class"
|
||||
[locale]="data.locale"
|
||||
[value]="SymbolProfile?.assetSubClass"
|
||||
></gf-value>
|
||||
</div>
|
||||
<ng-container
|
||||
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0"
|
||||
>
|
||||
<ng-container
|
||||
*ngIf="SymbolProfile?.countries?.length === 1 && SymbolProfile?.sectors?.length === 1; else charts"
|
||||
>
|
||||
<div
|
||||
*ngIf="SymbolProfile?.countries?.length === 1"
|
||||
class="col-6 mb-3"
|
||||
>
|
||||
<gf-value
|
||||
label="Country"
|
||||
size="medium"
|
||||
[locale]="data.locale"
|
||||
[value]="SymbolProfile.countries[0].name"
|
||||
></gf-value>
|
||||
</div>
|
||||
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
|
||||
<gf-value
|
||||
label="Sector"
|
||||
size="medium"
|
||||
[locale]="data.locale"
|
||||
[value]="SymbolProfile.sectors[0].name"
|
||||
></gf-value>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #charts>
|
||||
<div class="col-6 mb-3">
|
||||
<div class="h4 mb-0" i18n>Countries</div>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[maxItems]="10"
|
||||
[positions]="countries"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<div class="h4 mb-0" i18n>Sectors</div>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[maxItems]="10"
|
||||
[positions]="sectors"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -131,12 +197,14 @@
|
||||
[baseCurrency]="data.baseCurrency"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToImportActivities]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
[showActions]="false"
|
||||
[showSymbolColumn]="false"
|
||||
(export)="onExport()"
|
||||
></gf-activities-table>
|
||||
</div>
|
||||
|
||||
|
@ -6,6 +6,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@ -20,6 +21,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfLineChartModule,
|
||||
GfPortfolioProportionChartModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
|
@ -139,7 +139,7 @@
|
||||
class="my-3 text-center"
|
||||
>
|
||||
<button i18n mat-stroked-button (click)="onShowAllPositions()">
|
||||
Show all...
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
@ -20,6 +20,7 @@ export class AuthGuard implements CanActivate {
|
||||
'/blog',
|
||||
'/de/blog',
|
||||
'/en/blog',
|
||||
'/features',
|
||||
'/p',
|
||||
'/pricing',
|
||||
'/register',
|
||||
|
@ -11,7 +11,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-about-page',
|
||||
styleUrls: ['./about-page.scss'],
|
||||
templateUrl: './about-page.html'
|
||||
|
@ -32,7 +32,8 @@
|
||||
</p>
|
||||
<p>
|
||||
If you encounter a bug or would like to suggest an improvement or a
|
||||
new feature, please join the Ghostfolio
|
||||
new <a [routerLink]="['/features']">feature</a>, please join the
|
||||
Ghostfolio
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack community"
|
||||
|
@ -2,7 +2,7 @@ import { Component, OnDestroy } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-changelog-page',
|
||||
styleUrls: ['./changelog-page.scss'],
|
||||
templateUrl: './changelog-page.html'
|
||||
|
@ -31,7 +31,7 @@ import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-account-page',
|
||||
styleUrls: ['./account-page.scss'],
|
||||
templateUrl: './account-page.html'
|
||||
|
@ -16,7 +16,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-accounts-page',
|
||||
styleUrls: ['./accounts-page.scss'],
|
||||
templateUrl: './accounts-page.html'
|
||||
|
@ -1,19 +1,21 @@
|
||||
<div class="container">
|
||||
<div class="row mb-3">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Accounts</h3>
|
||||
<gf-accounts-table
|
||||
[accounts]="accounts"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
|
||||
[totalBalance]="totalBalance"
|
||||
[totalValue]="totalValue"
|
||||
[transactionCount]="transactionCount"
|
||||
(accountDeleted)="onDeleteAccount($event)"
|
||||
(accountToUpdate)="onUpdateAccount($event)"
|
||||
></gf-accounts-table>
|
||||
<div class="accounts">
|
||||
<gf-accounts-table
|
||||
[accounts]="accounts"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
|
||||
[totalBalance]="totalBalance"
|
||||
[totalValue]="totalValue"
|
||||
[transactionCount]="transactionCount"
|
||||
(accountDeleted)="onDeleteAccount($event)"
|
||||
(accountToUpdate)="onUpdateAccount($event)"
|
||||
></gf-accounts-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,6 +1,10 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.accounts {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-hallo-ghostfolio-page',
|
||||
styleUrls: ['./hallo-ghostfolio-page.scss'],
|
||||
templateUrl: './hallo-ghostfolio-page.html'
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-hello-ghostfolio-page',
|
||||
styleUrls: ['./hello-ghostfolio-page.scss'],
|
||||
templateUrl: './hello-ghostfolio-page.html'
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-first-months-in-open-source-page',
|
||||
styleUrls: ['./first-months-in-open-source-page.scss'],
|
||||
templateUrl: './first-months-in-open-source-page.html'
|
||||
|
@ -2,7 +2,7 @@ import { Component, OnDestroy } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-blog-page',
|
||||
styleUrls: ['./blog-page.scss'],
|
||||
templateUrl: './blog-page.html'
|
||||
|
@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { FeaturesPageComponent } from './features-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: FeaturesPageComponent, canActivate: [AuthGuard] }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class FeaturesPageRoutingModule {}
|
@ -0,0 +1,44 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-features-page',
|
||||
styleUrls: ['./features-page.scss'],
|
||||
templateUrl: './features-page.html'
|
||||
})
|
||||
export class FeaturesPageComponent implements OnDestroy {
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
220
apps/client/src/app/pages/features/features-page.html
Normal file
220
apps/client/src/app/pages/features/features-page.html
Normal file
@ -0,0 +1,220 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
||||
Features
|
||||
</h3>
|
||||
<mat-card class="mb-4">
|
||||
<mat-card-content>
|
||||
<p>
|
||||
Check out the numerous features of <strong>Ghostfolio</strong> to
|
||||
manage your wealth.
|
||||
</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 i18n>Stocks</h4>
|
||||
<p class="m-0">Keep track of your stock purchases and sales.</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 i18n>ETFs</h4>
|
||||
<p class="m-0">
|
||||
Are you into ETFs (Exchange Traded Funds)? Track your ETF
|
||||
investments.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 i18n>Cryptocurrencies</h4>
|
||||
<p class="m-0">
|
||||
Keep track of your Bitcoin and Altcoin holdings.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 i18n>Dividend</h4>
|
||||
<p class="m-0">
|
||||
Are you building a dividend portfolio? Track your dividend in
|
||||
Ghostfolio.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 i18n>Multi-Accounts</h4>
|
||||
<p class="m-0">
|
||||
Keep an eye on all your accounts across multiple platforms
|
||||
(multi-banking).
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>Portfolio Calculations</span>
|
||||
<ion-icon
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
Check the rate of return of your portfolio for
|
||||
<code>Today</code>, <code>YTD</code>, <code>1Y</code>,
|
||||
<code>5Y</code>, and <code>Max</code>.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>Portfolio Allocations</span>
|
||||
<ion-icon
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
Check the allocations of your portfolio by account, asset class,
|
||||
currency, region, and sector.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>Market Mood</span>
|
||||
<ion-icon
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
Check the current market mood (<a [routerLink]="['/resources']"
|
||||
>Fear & Greed Index</a
|
||||
>) within the app.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>Static Analysis</span>
|
||||
<ion-icon
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
Identify potential risks in your portfolio with Ghostfolio
|
||||
X-ray, the static portfolio analysis.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<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
|
||||
>
|
||||
full of enthusiastic investors and discuss the latest market
|
||||
trends.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4 i18n>Open Source Software</h4>
|
||||
<p class="m-0">
|
||||
The source code is fully available as
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
title="Find Ghostfolio on GitHub"
|
||||
>open source software</a
|
||||
>
|
||||
(OSS) and licensed under the <i>AGPLv3 License</i>.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!user" class="row">
|
||||
<div class="col mt-3 text-center">
|
||||
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
19
apps/client/src/app/pages/features/features-page.module.ts
Normal file
19
apps/client/src/app/pages/features/features-page.module.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
|
||||
import { FeaturesPageRoutingModule } from './features-page-routing.module';
|
||||
import { FeaturesPageComponent } from './features-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [FeaturesPageComponent],
|
||||
imports: [
|
||||
FeaturesPageRoutingModule,
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatCardModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class FeaturesPageModule {}
|
17
apps/client/src/app/pages/features/features-page.scss
Normal file
17
apps/client/src/app/pages/features/features-page.scss
Normal file
@ -0,0 +1,17 @@
|
||||
:host {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
color: rgb(var(--light-primary-text));
|
||||
}
|
@ -6,7 +6,7 @@ import { format } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-landing-page',
|
||||
styleUrls: ['./landing-page.scss'],
|
||||
templateUrl: './landing-page.html'
|
||||
|
@ -20,7 +20,7 @@ import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-allocations-page',
|
||||
styleUrls: ['./allocations-page.scss'],
|
||||
templateUrl: './allocations-page.html'
|
||||
@ -316,6 +316,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
|
@ -11,7 +11,7 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-analysis-page',
|
||||
styleUrls: ['./analysis-page.scss'],
|
||||
templateUrl: './analysis-page.html'
|
||||
|
@ -7,7 +7,7 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-portfolio-page',
|
||||
styleUrls: ['./portfolio-page.scss'],
|
||||
templateUrl: './portfolio-page.html'
|
||||
|
@ -7,7 +7,7 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-report-page',
|
||||
styleUrls: ['./report-page.scss'],
|
||||
templateUrl: './report-page.html'
|
||||
|
@ -6,11 +6,15 @@ import {
|
||||
OnDestroy,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormControl, Validators } from '@angular/forms';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Type } from '@prisma/client';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
import { EMPTY, Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
@ -34,19 +38,15 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
|
||||
export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
@ViewChild('autocomplete') autocomplete;
|
||||
|
||||
public activityForm: FormGroup;
|
||||
|
||||
public currencies: string[] = [];
|
||||
public currentMarketPrice = null;
|
||||
public filteredLookupItems: LookupItem[];
|
||||
public filteredLookupItemsObservable: Observable<LookupItem[]>;
|
||||
public isLoading = false;
|
||||
public platforms: { id: string; name: string }[];
|
||||
public searchSymbolCtrl = new FormControl(
|
||||
{
|
||||
dataSource: this.data.transaction.dataSource,
|
||||
symbol: this.data.transaction.symbol
|
||||
},
|
||||
Validators.required
|
||||
);
|
||||
public Validators = Validators;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -54,6 +54,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
|
||||
private formBuilder: FormBuilder,
|
||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
|
||||
) {}
|
||||
|
||||
@ -63,36 +64,105 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
this.currencies = currencies;
|
||||
this.platforms = platforms;
|
||||
|
||||
this.filteredLookupItemsObservable =
|
||||
this.searchSymbolCtrl.valueChanges.pipe(
|
||||
startWith(''),
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
switchMap((query: string) => {
|
||||
if (isString(query)) {
|
||||
const filteredLookupItemsObservable =
|
||||
this.dataService.fetchSymbols(query);
|
||||
this.activityForm = this.formBuilder.group({
|
||||
accountId: [this.data.activity?.accountId, Validators.required],
|
||||
currency: [
|
||||
this.data.activity?.SymbolProfile?.currency,
|
||||
Validators.required
|
||||
],
|
||||
dataSource: [
|
||||
this.data.activity?.SymbolProfile?.dataSource,
|
||||
Validators.required
|
||||
],
|
||||
date: [this.data.activity?.date, Validators.required],
|
||||
fee: [this.data.activity?.fee, Validators.required],
|
||||
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
|
||||
},
|
||||
Validators.required
|
||||
],
|
||||
type: [undefined, Validators.required], // Set after value changes subscription
|
||||
unitPrice: [this.data.activity?.unitPrice, Validators.required]
|
||||
});
|
||||
|
||||
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
|
||||
this.filteredLookupItems = filteredLookupItems;
|
||||
});
|
||||
this.filteredLookupItemsObservable = this.activityForm.controls[
|
||||
'searchSymbol'
|
||||
].valueChanges.pipe(
|
||||
startWith(''),
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
switchMap((query: string) => {
|
||||
if (isString(query)) {
|
||||
const filteredLookupItemsObservable =
|
||||
this.dataService.fetchSymbols(query);
|
||||
|
||||
return filteredLookupItemsObservable;
|
||||
}
|
||||
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
|
||||
this.filteredLookupItems = filteredLookupItems;
|
||||
});
|
||||
|
||||
return [];
|
||||
})
|
||||
);
|
||||
return filteredLookupItemsObservable;
|
||||
}
|
||||
|
||||
if (this.data.transaction.id) {
|
||||
this.searchSymbolCtrl.disable();
|
||||
return [];
|
||||
})
|
||||
);
|
||||
|
||||
this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => {
|
||||
if (type === 'ITEM') {
|
||||
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['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();
|
||||
} else {
|
||||
this.activityForm.controls['accountId'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['accountId'].updateValueAndValidity();
|
||||
this.activityForm.controls['dataSource'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['dataSource'].updateValueAndValidity();
|
||||
this.activityForm.controls['name'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['name'].updateValueAndValidity();
|
||||
this.activityForm.controls['searchSymbol'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
|
||||
this.activityForm.controls['type'].setValue(this.data.activity?.type);
|
||||
|
||||
if (this.data.activity?.id) {
|
||||
this.activityForm.controls['searchSymbol'].disable();
|
||||
this.activityForm.controls['type'].disable();
|
||||
}
|
||||
|
||||
if (this.data.transaction.symbol) {
|
||||
if (this.data.activity?.symbol) {
|
||||
this.dataService
|
||||
.fetchSymbolItem({
|
||||
dataSource: this.data.transaction.dataSource,
|
||||
symbol: this.data.transaction.symbol
|
||||
dataSource: this.data.activity?.dataSource,
|
||||
symbol: this.data.activity?.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketPrice }) => {
|
||||
@ -104,7 +174,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
public applyCurrentMarketPrice() {
|
||||
this.data.transaction.unitPrice = this.currentMarketPrice;
|
||||
this.activityForm.patchValue({
|
||||
unitPrice: this.currentMarketPrice
|
||||
});
|
||||
}
|
||||
|
||||
public displayFn(aLookupItem: LookupItem) {
|
||||
@ -113,17 +185,20 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
|
||||
public onBlurSymbol() {
|
||||
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
|
||||
return lookupItem.symbol === this.data.transaction.symbol;
|
||||
return (
|
||||
lookupItem.symbol ===
|
||||
this.activityForm.controls['searchSymbol'].value.symbol
|
||||
);
|
||||
});
|
||||
|
||||
if (currentLookupItem) {
|
||||
this.updateSymbol(currentLookupItem.symbol);
|
||||
} else {
|
||||
this.searchSymbolCtrl.setErrors({ incorrect: true });
|
||||
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
|
||||
|
||||
this.data.transaction.currency = null;
|
||||
this.data.transaction.dataSource = null;
|
||||
this.data.transaction.symbol = null;
|
||||
this.data.activity.currency = null;
|
||||
this.data.activity.dataSource = null;
|
||||
this.data.activity.symbol = null;
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
@ -133,8 +208,34 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
const activity: CreateOrderDto | UpdateOrderDto = {
|
||||
accountId: this.activityForm.controls['accountId'].value,
|
||||
currency: this.activityForm.controls['currency'].value,
|
||||
date: this.activityForm.controls['date'].value,
|
||||
dataSource: this.activityForm.controls['dataSource'].value,
|
||||
fee: this.activityForm.controls['fee'].value,
|
||||
quantity: this.activityForm.controls['quantity'].value,
|
||||
symbol:
|
||||
this.activityForm.controls['searchSymbol'].value.symbol === undefined ||
|
||||
isUUID(this.activityForm.controls['searchSymbol'].value.symbol)
|
||||
? this.activityForm.controls['name'].value
|
||||
: this.activityForm.controls['searchSymbol'].value.symbol,
|
||||
type: this.activityForm.controls['type'].value,
|
||||
unitPrice: this.activityForm.controls['unitPrice'].value
|
||||
};
|
||||
|
||||
if (this.data.activity.id) {
|
||||
(activity as UpdateOrderDto).id = this.data.activity.id;
|
||||
}
|
||||
|
||||
this.dialogRef.close({ activity });
|
||||
}
|
||||
|
||||
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
|
||||
this.data.transaction.dataSource = event.option.value.dataSource;
|
||||
this.activityForm.controls['dataSource'].setValue(
|
||||
event.option.value.dataSource
|
||||
);
|
||||
this.updateSymbol(event.option.value.symbol);
|
||||
}
|
||||
|
||||
@ -146,20 +247,21 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
private updateSymbol(symbol: string) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.searchSymbolCtrl.setErrors(null);
|
||||
this.activityForm.controls['searchSymbol'].setErrors(null);
|
||||
this.activityForm.controls['searchSymbol'].setValue({ symbol });
|
||||
|
||||
this.data.transaction.symbol = symbol;
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
||||
this.dataService
|
||||
.fetchSymbolItem({
|
||||
dataSource: this.data.transaction.dataSource,
|
||||
symbol: this.data.transaction.symbol
|
||||
dataSource: this.activityForm.controls['dataSource'].value,
|
||||
symbol: this.activityForm.controls['searchSymbol'].value.symbol
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.data.transaction.currency = null;
|
||||
this.data.transaction.dataSource = null;
|
||||
this.data.transaction.unitPrice = null;
|
||||
this.data.activity.currency = null;
|
||||
this.data.activity.dataSource = null;
|
||||
this.data.activity.unitPrice = null;
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
@ -170,8 +272,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||
this.data.transaction.currency = currency;
|
||||
this.data.transaction.dataSource = dataSource;
|
||||
this.activityForm.controls['currency'].setValue(currency);
|
||||
this.activityForm.controls['dataSource'].setValue(dataSource);
|
||||
|
||||
this.currentMarketPrice = marketPrice;
|
||||
|
||||
this.isLoading = false;
|
||||
|
@ -1,31 +1,45 @@
|
||||
<form #addTransactionForm="ngForm" class="d-flex flex-column h-100">
|
||||
<h1 *ngIf="data.transaction.id" mat-dialog-title i18n>Update activity</h1>
|
||||
<h1 *ngIf="!data.transaction.id" mat-dialog-title i18n>Add activity</h1>
|
||||
<form
|
||||
class="d-flex flex-column h-100"
|
||||
[formGroup]="activityForm"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>
|
||||
<h1 *ngIf="!data.activity.id" mat-dialog-title i18n>Add activity</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
<mat-select formControlName="type">
|
||||
<mat-option value="BUY" i18n>BUY</mat-option>
|
||||
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
|
||||
<mat-option value="ITEM" i18n>ITEM</mat-option>
|
||||
<mat-option value="SELL" i18n>SELL</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div
|
||||
[ngClass]="{ 'd-none': !activityForm.controls['accountId'].hasValidator(Validators.required) }"
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Account</mat-label>
|
||||
<mat-select
|
||||
name="accountId"
|
||||
required
|
||||
[(value)]="data.transaction.accountId"
|
||||
>
|
||||
<mat-select formControlName="accountId">
|
||||
<mat-option *ngFor="let account of data.accounts" [value]="account.id"
|
||||
>{{ account.name }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
[ngClass]="{ 'd-none': !activityForm.controls['searchSymbol'].hasValidator(Validators.required) }"
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Symbol or ISIN</mat-label>
|
||||
<input
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
formControlName="searchSymbol"
|
||||
matInput
|
||||
required
|
||||
[formControl]="searchSymbolCtrl"
|
||||
[matAutocomplete]="autocomplete"
|
||||
(blur)="onBlurSymbol()"
|
||||
/>
|
||||
@ -48,26 +62,18 @@
|
||||
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
[ngClass]="{ 'd-none': !activityForm.controls['name'].hasValidator(Validators.required) }"
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
<mat-select name="type" required [(value)]="data.transaction.type">
|
||||
<mat-option value="BUY" i18n>BUY</mat-option>
|
||||
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
|
||||
<mat-option value="SELL" i18n>SELL</mat-option>
|
||||
</mat-select>
|
||||
<mat-label i18n>Name</mat-label>
|
||||
<input formControlName="name" matInput />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Currency</mat-label>
|
||||
<mat-select
|
||||
class="no-arrow"
|
||||
disabled
|
||||
name="currency"
|
||||
required
|
||||
[(value)]="data.transaction.currency"
|
||||
>
|
||||
<mat-select class="no-arrow" formControlName="currency">
|
||||
<mat-option *ngFor="let currency of currencies" [value]="currency"
|
||||
>{{ currency }}</mat-option
|
||||
>
|
||||
@ -77,26 +83,13 @@
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Data Source</mat-label>
|
||||
<input
|
||||
disabled
|
||||
matInput
|
||||
name="dataSource"
|
||||
required
|
||||
[(ngModel)]="data.transaction.dataSource"
|
||||
/>
|
||||
<input formControlName="dataSource" matInput />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Date</mat-label>
|
||||
<input
|
||||
disabled
|
||||
matInput
|
||||
name="date"
|
||||
required
|
||||
[matDatepicker]="date"
|
||||
[(ngModel)]="data.transaction.date"
|
||||
/>
|
||||
<input formControlName="date" matInput [matDatepicker]="date" />
|
||||
<mat-datepicker-toggle matSuffix [for]="date">
|
||||
<ion-icon
|
||||
class="text-muted"
|
||||
@ -110,31 +103,22 @@
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Quantity</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="quantity"
|
||||
required
|
||||
type="number"
|
||||
[(ngModel)]="data.transaction.quantity"
|
||||
/>
|
||||
<input formControlName="quantity" matInput type="number" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Unit Price</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="unitPrice"
|
||||
required
|
||||
type="number"
|
||||
[(ngModel)]="data.transaction.unitPrice"
|
||||
/>
|
||||
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
|
||||
<input formControlName="unitPrice" matInput type="number" />
|
||||
<span class="ml-2" matSuffix
|
||||
>{{ activityForm.controls['currency'].value }}</span
|
||||
>
|
||||
<button
|
||||
*ngIf="currentMarketPrice && (data.transaction.type === 'BUY' || data.transaction.type === 'SELL')"
|
||||
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
title="Apply current market price"
|
||||
type="button"
|
||||
(click)="applyCurrentMarketPrice()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
@ -144,32 +128,28 @@
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Fee</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="fee"
|
||||
required
|
||||
type="number"
|
||||
[(ngModel)]="data.transaction.fee"
|
||||
/>
|
||||
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
|
||||
<input formControlName="fee" matInput type="number" />
|
||||
<span class="ml-2" matSuffix
|
||||
>{{ activityForm.controls['currency'].value }}</span
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex" mat-dialog-actions>
|
||||
<gf-value
|
||||
class="flex-grow-1"
|
||||
[currency]="data.transaction.currency"
|
||||
[currency]="activityForm.controls['currency'].value"
|
||||
[locale]="data.user?.settings?.locale"
|
||||
[value]="data.transaction.fee + (data.transaction.quantity * data.transaction.unitPrice)"
|
||||
[value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0"
|
||||
></gf-value>
|
||||
<div>
|
||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||
<button
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!(addTransactionForm.form.valid && data.transaction.currency && data.transaction.symbol)"
|
||||
[mat-dialog-close]="data"
|
||||
type="submit"
|
||||
[disabled]="!activityForm.valid"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { Account, Order } from '@prisma/client';
|
||||
import { Account } from '@prisma/client';
|
||||
|
||||
export interface CreateOrUpdateTransactionDialogParams {
|
||||
accountId: string;
|
||||
accounts: Account[];
|
||||
transaction: Order;
|
||||
activity: Activity;
|
||||
user: User;
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||
@ -23,7 +24,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
|
||||
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-transactions-page',
|
||||
styleUrls: ['./transactions-page.scss'],
|
||||
templateUrl: './transactions-page.html'
|
||||
@ -90,11 +91,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
public ngOnInit() {
|
||||
const { globalPermissions } = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionToImportOrders = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableImport
|
||||
);
|
||||
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.impersonationStorageService
|
||||
@ -102,6 +98,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((aId) => {
|
||||
this.hasImpersonationId = !!aId;
|
||||
|
||||
this.hasPermissionToImportOrders =
|
||||
hasPermission(globalPermissions, permissions.enableImport) &&
|
||||
!this.hasImpersonationId;
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
@ -132,8 +132,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onCloneTransaction(aTransaction: OrderModel) {
|
||||
this.openCreateTransactionDialog(aTransaction);
|
||||
public onCloneTransaction(aActivity: Activity) {
|
||||
this.openCreateTransactionDialog(aActivity);
|
||||
}
|
||||
|
||||
public onDeleteTransaction(aId: string) {
|
||||
@ -147,12 +147,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
public onExport(activityIds?: string[]) {
|
||||
this.dataService
|
||||
.fetchExport()
|
||||
.fetchExport(activityIds)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data) => {
|
||||
this.downloadAsFile(
|
||||
downloadAsFile(
|
||||
data,
|
||||
`ghostfolio-export-${format(
|
||||
parseISO(data.meta.date),
|
||||
@ -242,35 +242,13 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public openUpdateTransactionDialog({
|
||||
accountId,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
}: OrderModel): void {
|
||||
public openUpdateTransactionDialog(activity: Activity): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
||||
data: {
|
||||
activity,
|
||||
accounts: this.user?.accounts?.filter((account) => {
|
||||
return account.accountType === 'SECURITIES';
|
||||
}),
|
||||
transaction: {
|
||||
accountId,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
},
|
||||
user: this.user
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
@ -281,7 +259,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data: any) => {
|
||||
const transaction: UpdateOrderDto = data?.transaction;
|
||||
const transaction: UpdateOrderDto = data?.activity;
|
||||
|
||||
if (transaction) {
|
||||
this.dataService
|
||||
@ -303,20 +281,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private downloadAsFile(
|
||||
aContent: unknown,
|
||||
aFileName: string,
|
||||
aContentType: string
|
||||
) {
|
||||
const a = document.createElement('a');
|
||||
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
|
||||
type: aContentType
|
||||
});
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = aFileName;
|
||||
a.click();
|
||||
}
|
||||
|
||||
private handleImportError({ error, orders }: { error: any; orders: any[] }) {
|
||||
this.snackBar.dismiss();
|
||||
|
||||
@ -338,7 +302,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
|
||||
private openCreateTransactionDialog(aActivity?: Activity): void {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -350,15 +314,14 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
accounts: this.user?.accounts?.filter((account) => {
|
||||
return account.accountType === 'SECURITIES';
|
||||
}),
|
||||
transaction: {
|
||||
accountId: aTransaction?.accountId ?? this.defaultAccountId,
|
||||
currency: aTransaction?.currency ?? null,
|
||||
dataSource: aTransaction?.dataSource ?? null,
|
||||
activity: {
|
||||
...aActivity,
|
||||
accountId: aActivity?.accountId ?? this.defaultAccountId,
|
||||
date: new Date(),
|
||||
id: null,
|
||||
fee: 0,
|
||||
quantity: null,
|
||||
symbol: aTransaction?.symbol ?? null,
|
||||
type: aTransaction?.type ?? 'BUY',
|
||||
type: aActivity?.type ?? 'BUY',
|
||||
unitPrice: null
|
||||
},
|
||||
user: this.user
|
||||
@ -371,7 +334,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data: any) => {
|
||||
const transaction: CreateOrderDto = data?.transaction;
|
||||
const transaction: CreateOrderDto = data?.activity;
|
||||
|
||||
if (transaction) {
|
||||
this.dataService.postOrder(transaction).subscribe({
|
||||
@ -406,6 +369,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
|
@ -7,13 +7,14 @@
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||
[hasPermissionToImportActivities]="hasPermissionToImportOrders"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder && !user.settings.isRestrictedView"
|
||||
(activityDeleted)="onDeleteTransaction($event)"
|
||||
(activityToClone)="onCloneTransaction($event)"
|
||||
(activityToUpdate)="onUpdateTransaction($event)"
|
||||
(export)="onExport()"
|
||||
(export)="onExport($event)"
|
||||
(import)="onImport()"
|
||||
></gf-activities-table>
|
||||
</div>
|
||||
|
@ -7,7 +7,7 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-pricing-page',
|
||||
styleUrls: ['./pricing-page.scss'],
|
||||
templateUrl: './pricing-page.html'
|
||||
|
@ -13,7 +13,7 @@ import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-public-page',
|
||||
styleUrls: ['./public-page.scss'],
|
||||
templateUrl: './public-page.html'
|
||||
|
@ -6,6 +6,7 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { Role } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
@ -14,7 +15,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-register-page',
|
||||
styleUrls: ['./register-page.scss'],
|
||||
templateUrl: './register-page.html'
|
||||
@ -62,19 +63,21 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||
this.dataService
|
||||
.postUser()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ accessToken, authToken }) => {
|
||||
this.openShowAccessTokenDialog(accessToken, authToken);
|
||||
.subscribe(({ accessToken, authToken, role }) => {
|
||||
this.openShowAccessTokenDialog(accessToken, authToken, role);
|
||||
});
|
||||
}
|
||||
|
||||
public openShowAccessTokenDialog(
|
||||
accessToken: string,
|
||||
authToken: string
|
||||
authToken: string,
|
||||
role: Role
|
||||
): void {
|
||||
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
|
||||
data: {
|
||||
accessToken,
|
||||
authToken
|
||||
authToken,
|
||||
role
|
||||
},
|
||||
disableClose: true,
|
||||
width: '30rem'
|
||||
|
@ -1,4 +1,9 @@
|
||||
<h1 mat-dialog-title i18n>Create Account</h1>
|
||||
<h1 mat-dialog-title>
|
||||
<span i18n>Create Account</span
|
||||
><span *ngIf="data.role === 'ADMIN'" class="badge badge-light ml-2"
|
||||
>{{ data.role }}</span
|
||||
>
|
||||
</h1>
|
||||
<div mat-dialog-content>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
|
@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-resources-page',
|
||||
styleUrls: ['./resources-page.scss'],
|
||||
templateUrl: './resources-page.html'
|
||||
|
@ -6,7 +6,7 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-webauthn-page',
|
||||
styleUrls: ['./webauthn-page.scss'],
|
||||
templateUrl: './webauthn-page.html'
|
||||
|
@ -6,7 +6,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
@ -94,8 +94,16 @@ export class DataService {
|
||||
});
|
||||
}
|
||||
|
||||
public fetchExport() {
|
||||
return this.http.get<Export>('/api/export');
|
||||
public fetchExport(activityIds?: string[]) {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (activityIds) {
|
||||
params = params.append('activityIds', activityIds.join(','));
|
||||
}
|
||||
|
||||
return this.http.get<Export>('/api/export', {
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
public fetchInfo(): InfoItem {
|
||||
|
@ -245,6 +245,8 @@ export class ImportTransactionsService {
|
||||
return Type.BUY;
|
||||
case 'dividend':
|
||||
return Type.DIVIDEND;
|
||||
case 'item':
|
||||
return Type.ITEM;
|
||||
case 'sell':
|
||||
return Type.SELL;
|
||||
default:
|
||||
|
@ -6,42 +6,46 @@
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://ghostfol.io</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/about</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/about/changelog</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/blog</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
|
||||
<lastmod>2022-01-05T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/features</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pricing</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/register</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/resources</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
|
@ -164,6 +164,10 @@ ngx-skeleton-loader {
|
||||
min-width: unset !important;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.svgMap-tooltip {
|
||||
border-bottom: none;
|
||||
|
||||
|
@ -12,6 +12,20 @@ export function decodeDataSource(encodedDataSource: string) {
|
||||
return Buffer.from(encodedDataSource, 'hex').toString();
|
||||
}
|
||||
|
||||
export function downloadAsFile(
|
||||
aContent: unknown,
|
||||
aFileName: string,
|
||||
aContentType: string
|
||||
) {
|
||||
const a = document.createElement('a');
|
||||
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
|
||||
type: aContentType
|
||||
});
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = aFileName;
|
||||
a.click();
|
||||
}
|
||||
|
||||
export function encodeDataSource(aDataSource: DataSource) {
|
||||
return Buffer.from(aDataSource, 'utf-8').toString('hex');
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
|
||||
committedFunds: number;
|
||||
fees: number;
|
||||
firstOrderDate: Date;
|
||||
items: number;
|
||||
netWorth: number;
|
||||
ordersCount: number;
|
||||
totalBuy: number;
|
||||
|
@ -36,309 +36,348 @@
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
matSort
|
||||
matSortActive="date"
|
||||
matSortDirection="desc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="count">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
i18n
|
||||
mat-header-cell
|
||||
></th>
|
||||
<td
|
||||
*matCellDef="let element; let i = index"
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
mat-cell
|
||||
>
|
||||
{{ dataSource.data.length - i }}
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Date
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
{{ element.date | date: defaultDateFormat }}
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Type
|
||||
</th>
|
||||
<td *matCellDef="let element" mat-cell class="px-1">
|
||||
<div
|
||||
class="d-inline-flex p-1 type-badge"
|
||||
[ngClass]="{
|
||||
buy: element.type === 'BUY',
|
||||
dividend: element.type === 'DIVIDEND',
|
||||
sell: element.type === 'SELL'
|
||||
}"
|
||||
<div class="activities">
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
matSort
|
||||
matSortActive="date"
|
||||
matSortDirection="desc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="count">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
i18n
|
||||
mat-header-cell
|
||||
></th>
|
||||
<td
|
||||
*matCellDef="let element; let i = index"
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
mat-cell
|
||||
>
|
||||
<ion-icon
|
||||
[name]="
|
||||
element.type === 'BUY' || element.type === 'DIVIDEND'
|
||||
? 'arrow-forward-circle-outline'
|
||||
: 'arrow-back-circle-outline'
|
||||
{{ dataSource.data.length - i }}
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Date
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
{{ element.date | date: defaultDateFormat }}
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Type
|
||||
</th>
|
||||
<td *matCellDef="let element" mat-cell class="px-1">
|
||||
<div
|
||||
class="d-inline-flex p-1 type-badge"
|
||||
[ngClass]="{
|
||||
buy: element.type === 'BUY',
|
||||
dividend: element.type === 'DIVIDEND',
|
||||
item: element.type === 'ITEM',
|
||||
sell: element.type === 'SELL'
|
||||
}"
|
||||
>
|
||||
<ion-icon
|
||||
*ngIf="element.type === 'BUY' || element.type === 'DIVIDEND'"
|
||||
name="arrow-forward-circle-outline"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="element.type === 'ITEM'"
|
||||
name="cube-outline"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="element.type === 'SELL'"
|
||||
name="arrow-back-circle-outline"
|
||||
></ion-icon>
|
||||
<span class="d-none d-lg-block mx-1">{{ element.type }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Symbol
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex align-items-center">
|
||||
<span *ngIf="isUUID(element.SymbolProfile.symbol); else symbol">
|
||||
{{ element.SymbolProfile.name }}
|
||||
</span>
|
||||
<ng-template #symbol>
|
||||
{{ element.SymbolProfile.symbol | gfSymbol }}
|
||||
</ng-template>
|
||||
<span *ngIf="element.isDraft" class="badge badge-secondary ml-1" i18n
|
||||
>Draft</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Currency
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
{{ element.currency }}
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
{{ baseCurrency }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Quantity
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.quantity"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="unitPrice">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Unit Price
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.unitPrice"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fee">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Fee
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.fee"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalFees"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Value
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.value"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isAbsolute]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalValue"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<span class="d-none d-lg-block" i18n>Account</span>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
<gf-symbol-icon
|
||||
*ngIf="element.Account?.Platform?.url"
|
||||
class="mr-1"
|
||||
[tooltip]="element.Account?.Platform?.name"
|
||||
[url]="element.Account?.Platform?.url"
|
||||
></gf-symbol-icon>
|
||||
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||
<button
|
||||
*ngIf="
|
||||
hasPermissionToExportActivities || hasPermissionToImportActivities
|
||||
"
|
||||
></ion-icon>
|
||||
<span class="d-none d-lg-block mx-1">{{ element.type }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Symbol
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex align-items-center">
|
||||
{{ element.symbol | gfSymbol }}
|
||||
<span *ngIf="element.isDraft" class="badge badge-secondary ml-1" i18n
|
||||
>Draft</span
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="activitiesMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Currency
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
{{ element.currency }}
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
{{ baseCurrency }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Quantity
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.quantity"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="unitPrice">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Unit Price
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.unitPrice"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fee">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Fee
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.fee"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalFees"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Value
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.value"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalValue"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<span class="d-none d-lg-block" i18n>Account</span>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
<gf-symbol-icon
|
||||
*ngIf="element.Account?.Platform?.url"
|
||||
class="mr-1"
|
||||
[tooltip]="element.Account?.Platform?.name"
|
||||
[url]="element.Account?.Platform?.url"
|
||||
></gf-symbol-icon>
|
||||
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="activitiesMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #activitiesMenu="matMenu" xPosition="before">
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #activitiesMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
*ngIf="hasPermissionToImportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="onImport()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
||||
<span i18n>Import</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="onExport()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
|
||||
<span i18n>Export</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<button
|
||||
*ngIf="hasPermissionToImportActivities"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="onImport()"
|
||||
*ngIf="this.showActions"
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="activityMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
||||
<span i18n>Import</span>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<button
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="onExport()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
|
||||
<span i18n>Export</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="activityMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #activityMenu="matMenu" xPosition="before">
|
||||
<button i18n mat-menu-item (click)="onUpdateActivity(element)">
|
||||
Edit
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onCloneActivity(element)">
|
||||
Clone
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onDeleteActivity(element.id)">
|
||||
Delete
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
<mat-menu #activityMenu="matMenu" xPosition="before">
|
||||
<button i18n mat-menu-item (click)="onUpdateActivity(element)">
|
||||
Edit
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onCloneActivity(element)">
|
||||
Clone
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onDeleteActivity(element.id)">
|
||||
Delete
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
mat-row
|
||||
(click)="
|
||||
hasPermissionToOpenDetails &&
|
||||
!row.isDraft &&
|
||||
onOpenPositionDialog({
|
||||
dataSource: row.dataSource,
|
||||
symbol: row.symbol
|
||||
})
|
||||
"
|
||||
[ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }"
|
||||
></tr>
|
||||
<tr
|
||||
*matFooterRowDef="displayedColumns"
|
||||
mat-footer-row
|
||||
[ngClass]="{ 'd-none': isLoading || dataSource.data.length === 0 }"
|
||||
></tr>
|
||||
</table>
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
mat-row
|
||||
(click)="
|
||||
hasPermissionToOpenDetails &&
|
||||
!row.isDraft &&
|
||||
row.type !== 'ITEM' &&
|
||||
onOpenPositionDialog({
|
||||
dataSource: row.dataSource,
|
||||
symbol: row.SymbolProfile.symbol
|
||||
})
|
||||
"
|
||||
[ngClass]="{
|
||||
'cursor-pointer':
|
||||
hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM'
|
||||
}"
|
||||
></tr>
|
||||
<tr
|
||||
*matFooterRowDef="displayedColumns"
|
||||
mat-footer-row
|
||||
[ngClass]="{ 'd-none': isLoading || dataSource.data.length === 0 }"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
|
@ -14,45 +14,53 @@
|
||||
min-height: 1.5rem !important;
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
td {
|
||||
&.mat-footer-cell {
|
||||
border-top: 1px solid
|
||||
rgba(
|
||||
var(--palette-foreground-divider),
|
||||
var(--palette-foreground-divider-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
.activities {
|
||||
overflow-x: auto;
|
||||
|
||||
th {
|
||||
::ng-deep {
|
||||
.mat-sort-header-container {
|
||||
justify-content: inherit;
|
||||
.mat-table {
|
||||
td {
|
||||
&.mat-footer-cell {
|
||||
border-top: 1px solid
|
||||
rgba(
|
||||
var(--palette-foreground-divider),
|
||||
var(--palette-foreground-divider-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
.type-badge {
|
||||
background-color: rgba(var(--palette-foreground-text), 0.05);
|
||||
border-radius: 1rem;
|
||||
line-height: 1em;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
th {
|
||||
::ng-deep {
|
||||
.mat-sort-header-container {
|
||||
justify-content: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.buy {
|
||||
color: var(--green);
|
||||
}
|
||||
.mat-row {
|
||||
.type-badge {
|
||||
background-color: rgba(var(--palette-foreground-text), 0.05);
|
||||
border-radius: 1rem;
|
||||
line-height: 1em;
|
||||
|
||||
&.dividend {
|
||||
color: var(--blue);
|
||||
}
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&.sell {
|
||||
color: var(--orange);
|
||||
&.buy {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
&.dividend {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
&.item {
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
&.sell {
|
||||
color: var(--orange);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { endOfToday, format, isAfter } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
|
||||
@ -43,6 +44,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateActivity: boolean;
|
||||
@Input() hasPermissionToExportActivities: boolean;
|
||||
@Input() hasPermissionToFilter = true;
|
||||
@Input() hasPermissionToImportActivities: boolean;
|
||||
@Input() hasPermissionToOpenDetails = true;
|
||||
@ -53,7 +55,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
@Output() activityDeleted = new EventEmitter<string>();
|
||||
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
|
||||
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
|
||||
@Output() export = new EventEmitter<void>();
|
||||
@Output() export = new EventEmitter<string[]>();
|
||||
@Output() import = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
|
||||
@ -68,6 +70,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
public filters: Observable<string[]> = this.filters$.asObservable();
|
||||
public isAfter = isAfter;
|
||||
public isLoading = true;
|
||||
public isUUID = isUUID;
|
||||
public placeholder = '';
|
||||
public routeQueryParams: Subscription;
|
||||
public searchControl = new FormControl();
|
||||
@ -132,18 +135,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
'date',
|
||||
'type',
|
||||
'symbol',
|
||||
'currency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'fee',
|
||||
'value',
|
||||
'account'
|
||||
'currency',
|
||||
'account',
|
||||
'actions'
|
||||
];
|
||||
|
||||
if (this.showActions) {
|
||||
this.displayedColumns.push('actions');
|
||||
}
|
||||
|
||||
if (!this.showSymbolColumn) {
|
||||
this.displayedColumns = this.displayedColumns.filter((column) => {
|
||||
return column !== 'symbol';
|
||||
@ -184,7 +184,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
this.export.emit();
|
||||
if (this.searchKeywords.length > 0) {
|
||||
this.export.emit(
|
||||
this.dataSource.filteredData.map((activity) => {
|
||||
return activity.id;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.export.emit();
|
||||
}
|
||||
}
|
||||
|
||||
public onImport() {
|
||||
@ -265,11 +273,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
activity: OrderWithAccount,
|
||||
fieldValues: Set<string> = new Set<string>()
|
||||
): string[] {
|
||||
fieldValues.add(activity.currency);
|
||||
fieldValues.add(activity.symbol);
|
||||
fieldValues.add(activity.type);
|
||||
fieldValues.add(activity.Account?.name);
|
||||
fieldValues.add(activity.Account?.Platform?.name);
|
||||
fieldValues.add(activity.SymbolProfile.currency);
|
||||
|
||||
if (!isUUID(activity.SymbolProfile.symbol)) {
|
||||
fieldValues.add(activity.SymbolProfile.symbol);
|
||||
}
|
||||
|
||||
fieldValues.add(activity.type);
|
||||
fieldValues.add(format(activity.date, 'yyyy'));
|
||||
|
||||
return [...fieldValues].filter((item) => {
|
||||
@ -296,7 +308,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
|
||||
for (const activity of this.dataSource.filteredData) {
|
||||
if (isNumber(activity.valueInBaseCurrency)) {
|
||||
if (activity.type === 'BUY') {
|
||||
if (activity.type === 'BUY' || activity.type === 'ITEM') {
|
||||
totalValue = totalValue.plus(activity.valueInBaseCurrency);
|
||||
} else if (activity.type === 'SELL') {
|
||||
totalValue = totalValue.minus(activity.valueInBaseCurrency);
|
||||
|
@ -34,12 +34,12 @@
|
||||
{{ currency }}
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="isDate">
|
||||
<ng-container *ngIf="isString">
|
||||
<div
|
||||
class="mb-0"
|
||||
class="mb-0 text-truncate"
|
||||
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
|
||||
>
|
||||
{{ formattedDate }}
|
||||
{{ formattedValue | titlecase }}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
OnChanges
|
||||
} from '@angular/core';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { format, isDate } from 'date-fns';
|
||||
import { format, isDate, parseISO } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
@Component({
|
||||
@ -17,6 +17,7 @@ import { isNumber } from 'lodash';
|
||||
export class ValueComponent implements OnChanges {
|
||||
@Input() colorizeSign = false;
|
||||
@Input() currency = '';
|
||||
@Input() isAbsolute = false;
|
||||
@Input() isCurrency = false;
|
||||
@Input() isPercent = false;
|
||||
@Input() label = '';
|
||||
@ -27,10 +28,9 @@ export class ValueComponent implements OnChanges {
|
||||
@Input() value: number | string = '';
|
||||
|
||||
public absoluteValue = 0;
|
||||
public formattedDate = '';
|
||||
public formattedValue = '';
|
||||
public isDate = false;
|
||||
public isNumber = false;
|
||||
public isString = false;
|
||||
public useAbsoluteValue = false;
|
||||
|
||||
public constructor() {}
|
||||
@ -38,8 +38,8 @@ export class ValueComponent implements OnChanges {
|
||||
public ngOnChanges() {
|
||||
if (this.value || this.value === 0) {
|
||||
if (isNumber(this.value)) {
|
||||
this.isDate = false;
|
||||
this.isNumber = true;
|
||||
this.isString = false;
|
||||
this.absoluteValue = Math.abs(<number>this.value);
|
||||
|
||||
if (this.colorizeSign) {
|
||||
@ -91,18 +91,25 @@ export class ValueComponent implements OnChanges {
|
||||
} else {
|
||||
this.formattedValue = this.value?.toString();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (isDate(new Date(this.value))) {
|
||||
this.isDate = true;
|
||||
this.isNumber = false;
|
||||
|
||||
this.formattedDate = format(
|
||||
if (this.isAbsolute) {
|
||||
// Remove algebraic sign
|
||||
this.formattedValue = this.formattedValue.replace(/^-/, '');
|
||||
}
|
||||
} else {
|
||||
this.isNumber = false;
|
||||
this.isString = true;
|
||||
|
||||
try {
|
||||
if (isDate(parseISO(this.value))) {
|
||||
this.formattedValue = format(
|
||||
new Date(<string>this.value),
|
||||
DEFAULT_DATE_FORMAT
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
this.formattedValue = this.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
66
package.json
66
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.111.0",
|
||||
"version": "1.115.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -49,16 +49,16 @@
|
||||
"workspace-generator": "nx workspace-generator"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "13.1.1",
|
||||
"@angular/cdk": "13.1.1",
|
||||
"@angular/common": "13.1.1",
|
||||
"@angular/compiler": "13.1.1",
|
||||
"@angular/core": "13.1.1",
|
||||
"@angular/forms": "13.1.1",
|
||||
"@angular/material": "13.1.1",
|
||||
"@angular/platform-browser": "13.1.1",
|
||||
"@angular/platform-browser-dynamic": "13.1.1",
|
||||
"@angular/router": "13.1.1",
|
||||
"@angular/animations": "13.2.2",
|
||||
"@angular/cdk": "13.2.2",
|
||||
"@angular/common": "13.2.2",
|
||||
"@angular/compiler": "13.2.2",
|
||||
"@angular/core": "13.2.2",
|
||||
"@angular/forms": "13.2.2",
|
||||
"@angular/material": "13.2.2",
|
||||
"@angular/platform-browser": "13.2.2",
|
||||
"@angular/platform-browser-dynamic": "13.2.2",
|
||||
"@angular/router": "13.2.2",
|
||||
"@codewithdan/observable-store": "2.2.11",
|
||||
"@dinero.js/currencies": "2.0.0-alpha.8",
|
||||
"@nestjs/common": "8.2.3",
|
||||
@ -69,8 +69,8 @@
|
||||
"@nestjs/platform-express": "8.2.3",
|
||||
"@nestjs/schedule": "1.0.2",
|
||||
"@nestjs/serve-static": "2.2.2",
|
||||
"@nrwl/angular": "13.4.1",
|
||||
"@prisma/client": "3.8.1",
|
||||
"@nrwl/angular": "13.8.1",
|
||||
"@prisma/client": "3.9.1",
|
||||
"@simplewebauthn/browser": "4.1.0",
|
||||
"@simplewebauthn/server": "4.1.0",
|
||||
"@simplewebauthn/typescript-types": "4.0.0",
|
||||
@ -107,7 +107,7 @@
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
"prisma": "3.8.1",
|
||||
"prisma": "3.9.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"round-to": "5.0.0",
|
||||
"rxjs": "7.4.0",
|
||||
@ -119,29 +119,29 @@
|
||||
"zone.js": "0.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "13.1.2",
|
||||
"@angular-devkit/build-angular": "13.2.3",
|
||||
"@angular-eslint/eslint-plugin": "13.0.1",
|
||||
"@angular-eslint/eslint-plugin-template": "13.0.1",
|
||||
"@angular-eslint/template-parser": "13.0.1",
|
||||
"@angular/cli": "13.1.2",
|
||||
"@angular/compiler-cli": "13.1.1",
|
||||
"@angular/language-service": "13.1.1",
|
||||
"@angular/localize": "13.1.1",
|
||||
"@angular/cli": "13.2.3",
|
||||
"@angular/compiler-cli": "13.2.2",
|
||||
"@angular/language-service": "13.2.2",
|
||||
"@angular/localize": "13.2.2",
|
||||
"@nestjs/schematics": "8.0.5",
|
||||
"@nestjs/testing": "8.2.3",
|
||||
"@nrwl/cli": "13.4.1",
|
||||
"@nrwl/cypress": "13.4.1",
|
||||
"@nrwl/eslint-plugin-nx": "13.4.1",
|
||||
"@nrwl/jest": "13.4.1",
|
||||
"@nrwl/nest": "13.4.1",
|
||||
"@nrwl/node": "13.4.1",
|
||||
"@nrwl/storybook": "13.4.1",
|
||||
"@nrwl/tao": "13.4.1",
|
||||
"@nrwl/workspace": "13.4.1",
|
||||
"@storybook/addon-essentials": "6.4.9",
|
||||
"@storybook/angular": "6.4.9",
|
||||
"@storybook/builder-webpack5": "6.4.9",
|
||||
"@storybook/manager-webpack5": "6.4.9",
|
||||
"@nrwl/cli": "13.8.1",
|
||||
"@nrwl/cypress": "13.8.1",
|
||||
"@nrwl/eslint-plugin-nx": "13.8.1",
|
||||
"@nrwl/jest": "13.8.1",
|
||||
"@nrwl/nest": "13.8.1",
|
||||
"@nrwl/node": "13.8.1",
|
||||
"@nrwl/storybook": "13.8.1",
|
||||
"@nrwl/tao": "13.8.1",
|
||||
"@nrwl/workspace": "13.8.1",
|
||||
"@storybook/addon-essentials": "6.4.18",
|
||||
"@storybook/angular": "6.4.18",
|
||||
"@storybook/builder-webpack5": "6.4.18",
|
||||
"@storybook/manager-webpack5": "6.4.18",
|
||||
"@types/big.js": "6.1.2",
|
||||
"@types/cache-manager": "3.4.2",
|
||||
"@types/color": "3.0.2",
|
||||
@ -169,7 +169,7 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "27.0.5",
|
||||
"ts-node": "9.1.1",
|
||||
"typescript": "4.5.4"
|
||||
"typescript": "4.5.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
@ -0,0 +1,6 @@
|
||||
-- Set default value
|
||||
UPDATE "User" SET "provider" = 'ANONYMOUS' WHERE "provider" IS NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "provider" SET NOT NULL,
|
||||
ALTER COLUMN "provider" SET DEFAULT E'ANONYMOUS';
|
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "DataSource" ADD VALUE 'MANUAL';
|
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "Type" ADD VALUE 'ITEM';
|
@ -156,7 +156,7 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
Order Order[]
|
||||
provider Provider?
|
||||
provider Provider @default(ANONYMOUS)
|
||||
role Role @default(USER)
|
||||
Settings Settings?
|
||||
Subscription Subscription[]
|
||||
@ -185,6 +185,7 @@ enum DataSource {
|
||||
ALPHA_VANTAGE
|
||||
GHOSTFOLIO
|
||||
GOOGLE_SHEETS
|
||||
MANUAL
|
||||
RAKUTEN
|
||||
YAHOO
|
||||
}
|
||||
@ -208,5 +209,6 @@ enum Role {
|
||||
enum Type {
|
||||
BUY
|
||||
DIVIDEND
|
||||
ITEM
|
||||
SELL
|
||||
}
|
||||
|
@ -78,30 +78,6 @@ async function main() {
|
||||
where: { id: '1377d9df-0d25-42c2-9d9b-e4c63156291f' }
|
||||
});
|
||||
|
||||
const userAdmin = await prisma.user.upsert({
|
||||
create: {
|
||||
accessToken:
|
||||
'c689bcc894e4a420cb609ee34271f3e07f200594f7d199c50d75add7102889eb60061a04cd2792ebc853c54e37308271271e7bf588657c9e0c37faacbc28c3c6',
|
||||
Account: {
|
||||
create: [
|
||||
{
|
||||
accountType: AccountType.SECURITIES,
|
||||
balance: 0,
|
||||
currency: 'USD',
|
||||
id: 'f4425b66-9ba9-4ac4-93d7-fdf9a145e8cb',
|
||||
isDefault: true,
|
||||
name: 'Default Account'
|
||||
}
|
||||
]
|
||||
},
|
||||
alias: 'Admin',
|
||||
id: '4e1af723-95f6-44f8-92a7-464df17f6ec3',
|
||||
role: Role.ADMIN
|
||||
},
|
||||
update: {},
|
||||
where: { id: '4e1af723-95f6-44f8-92a7-464df17f6ec3' }
|
||||
});
|
||||
|
||||
const userDemo = await prisma.user.upsert({
|
||||
create: {
|
||||
accessToken:
|
||||
@ -345,7 +321,6 @@ async function main() {
|
||||
platformInteractiveBrokers,
|
||||
platformPostFinance,
|
||||
platformSwissquote,
|
||||
userAdmin,
|
||||
userDemo
|
||||
});
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
Date,Code,Currency,Price,Quantity,Action,Fee
|
||||
17/11/2021,MSFT,USD,0.62,5,dividend,0.00
|
||||
16/09/2021,MSFT,USD,298.580,5,buy,19.00
|
||||
01/01/2022,Penthouse Apartment,USD,500000.0,1,item,0.00
|
||||
|
|
Reference in New Issue
Block a user