Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
07656c6a95 | |||
16f0743353 | |||
9b5ec0c56d | |||
8d2fcc6b42 | |||
e625e55784 | |||
bed3e5aae2 | |||
65bfe52db4 | |||
48b524de5a | |||
67d40333f6 | |||
48f6b8d353 | |||
f369996912 | |||
dc424a86ec | |||
5d8bde5a70 | |||
16360c0c67 | |||
526a6b2030 | |||
5000e9c79b | |||
161cb82820 | |||
fed28f29d1 | |||
8bd9330acc | |||
155c08d665 | |||
b8ad6d6662 | |||
9d6977e3f7 | |||
919b20197f | |||
62885ea890 | |||
035d8ad9eb |
69
CHANGELOG.md
69
CHANGELOG.md
@ -5,6 +5,73 @@ 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.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
|
||||
|
||||
- Added support for deleting symbol profile data in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Used `dataSource` and `symbol` from `SymbolProfile` instead of the `order` object (in `ExportService` and `PortfolioService`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the symbol selection of the 7d data gathering
|
||||
|
||||
## 1.110.0 - 02.02.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the data source of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.109.0 - 01.02.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for the (optional) `accountId` in the import functionality for activities
|
||||
- Added support for the (optional) `dataSource` in the import functionality for activities
|
||||
- Added support for the data source transformation
|
||||
- Added support for the cryptocurrency _Mina Protocol_ (`MINA-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the form in the create or edit transaction dialog
|
||||
- Improved the consistent use of `symbol` in combination with `dataSource`
|
||||
- Removed the primary data source from the client
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the unused endpoint `GET api/order/:id`
|
||||
|
||||
## 1.108.0 - 27.01.2022
|
||||
|
||||
### Changed
|
||||
@ -200,7 +267,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
|
||||
- Added support for the cryptocurrency _Solana_ (`SOL-USD`)
|
||||
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||
|
||||
### Fixed
|
||||
|
14
README.md
14
README.md
@ -124,16 +124,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 +149,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_
|
||||
|
||||
@ -187,6 +181,6 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
|
||||
|
||||
## License
|
||||
|
||||
© 2021 [Ghostfolio](https://ghostfol.io)
|
||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
@ -11,6 +11,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
@ -195,9 +196,10 @@ export class AdminController {
|
||||
return this.adminService.getMarketData();
|
||||
}
|
||||
|
||||
@Get('market-data/:symbol')
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
if (
|
||||
@ -212,7 +214,7 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.getMarketDataBySymbol(symbol);
|
||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||
@ -248,6 +250,27 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateProperty(
|
||||
|
@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
@ -20,7 +21,8 @@ import { AdminService } from './admin.service';
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
|
@ -5,6 +5,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
@ -13,7 +14,7 @@ import {
|
||||
AdminMarketDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Property } from '@prisma/client';
|
||||
import { DataSource, Property } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
@ -25,9 +26,21 @@ export class AdminService {
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async deleteProfileData({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
}
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
return {
|
||||
dataGatheringProgress:
|
||||
@ -121,16 +134,21 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol(
|
||||
aSymbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}): Promise<AdminMarketDataDetails> {
|
||||
return {
|
||||
marketData: await this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
where: {
|
||||
symbol: aSymbol
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
})
|
||||
};
|
||||
|
@ -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,25 +7,62 @@ 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,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
date: true,
|
||||
fee: true,
|
||||
id: true,
|
||||
quantity: true,
|
||||
symbol: true,
|
||||
SymbolProfile: true,
|
||||
type: true,
|
||||
unitPrice: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
orders = orders.filter((order) => {
|
||||
return activityIds.includes(order.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
orders
|
||||
orders: orders.map(
|
||||
({
|
||||
accountId,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
return {
|
||||
accountId,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
};
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,11 @@ export class ImportService {
|
||||
orders: Partial<Order>[];
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const order of orders) {
|
||||
order.dataSource =
|
||||
order.dataSource ?? this.dataProviderService.getPrimaryDataSource();
|
||||
}
|
||||
|
||||
await this.validateOrders({ orders, userId });
|
||||
|
||||
for (const {
|
||||
@ -34,6 +39,7 @@ export class ImportService {
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder({
|
||||
accountId,
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
@ -41,11 +47,7 @@ export class ImportService {
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
userId,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
@ -12,12 +11,14 @@ import {
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@ -27,7 +28,6 @@ export class InfoService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly jwtService: JwtService,
|
||||
@ -51,6 +51,10 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableBlog);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(DataSource.RAKUTEN);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
@ -92,7 +96,6 @@ export class InfoService {
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions()
|
||||
};
|
||||
|
@ -1,14 +1,22 @@
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsEnum(DataSource, { each: true })
|
||||
@IsOptional()
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -14,7 +16,8 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -58,6 +61,7 @@ export class OrderController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<Activities> {
|
||||
@ -91,19 +95,9 @@ export class OrderController {
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
|
||||
return this.orderService.order({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
@ -114,19 +108,9 @@ export class OrderController {
|
||||
);
|
||||
}
|
||||
|
||||
const date = parseISO(data.date);
|
||||
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
return this.orderService.createOrder({
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
date: parseISO(data.date),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
@ -141,12 +125,14 @@ export class OrderController {
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
User: { connect: { id: this.request.user.id } },
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
@ -24,7 +25,7 @@ import { OrderService } from './order.service';
|
||||
UserModule
|
||||
],
|
||||
controllers: [OrderController],
|
||||
providers: [CacheService, OrderService],
|
||||
providers: [AccountService, CacheService, OrderService],
|
||||
exports: [OrderService]
|
||||
})
|
||||
export class OrderModule {}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
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';
|
||||
@ -13,6 +14,7 @@ import { Activity } from './interfaces/activities.interface';
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
@ -47,7 +49,24 @@ export class OrderService {
|
||||
});
|
||||
}
|
||||
|
||||
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
|
||||
): Promise<Order> {
|
||||
const defaultAccount = (
|
||||
await this.accountService.getAccounts(data.userId)
|
||||
).find((account) => {
|
||||
return account.isDefault === true;
|
||||
});
|
||||
|
||||
const Account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
id: data.accountId ?? defaultAccount?.id
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
// Convert the symbol to uppercase to avoid case-sensitive duplicates
|
||||
@ -70,9 +89,15 @@ export class OrderService {
|
||||
|
||||
await this.cacheService.flush();
|
||||
|
||||
delete data.accountId;
|
||||
delete data.userId;
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
|
||||
return this.prismaService.order.create({
|
||||
data: {
|
||||
...data,
|
||||
...orderData,
|
||||
Account,
|
||||
isDraft,
|
||||
symbol
|
||||
}
|
||||
|
@ -337,8 +337,8 @@ 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);
|
||||
@ -394,7 +394,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 +417,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 +428,7 @@ export class PortfolioCalculatorNew {
|
||||
);
|
||||
|
||||
totalInvestment = totalInvestment
|
||||
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||
.plus(transactionInvestment)
|
||||
.plus(grossPerformanceFromSell);
|
||||
|
||||
lastAveragePrice = totalUnits.eq(0)
|
||||
@ -436,48 +441,52 @@ export class PortfolioCalculatorNew {
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestment
|
||||
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||
!lastValueOfInvestmentBeforeTransaction
|
||||
.plus(lastTransactionInvestment)
|
||||
.eq(0)
|
||||
) {
|
||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.sub(
|
||||
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
|
||||
.sub(fees.sub(order.fee))
|
||||
.sub(
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
lastNetValueOfInvestment = netValueOfInvestment;
|
||||
lastValueOfInvestment = valueOfInvestment;
|
||||
|
||||
lastTransactionInvestment = transactionInvestment;
|
||||
|
||||
lastValueOfInvestmentBeforeTransaction =
|
||||
valueOfInvestmentBeforeTransaction;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
|
@ -4,9 +4,12 @@ import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
@ -25,12 +28,11 @@ import {
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
@ -52,8 +54,7 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
@ -89,27 +90,29 @@ export class PortfolioController {
|
||||
});
|
||||
}
|
||||
|
||||
return <any>res.json({
|
||||
return {
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioDetails> {
|
||||
@Query('range') range
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
@ -158,21 +161,22 @@ export class PortfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json({ accounts, hasError, holdings });
|
||||
return { accounts, hasError, holdings };
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Res() res: Response
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json({});
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let investments = await this.portfolioServiceStrategy
|
||||
@ -194,15 +198,14 @@ export class PortfolioController {
|
||||
}));
|
||||
}
|
||||
|
||||
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
|
||||
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPerformance(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
@Query('range') range
|
||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||
const performanceInformation = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
@ -218,15 +221,15 @@ export class PortfolioController {
|
||||
);
|
||||
}
|
||||
|
||||
return <any>res.json(performanceInformation);
|
||||
return performanceInformation;
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
@Query('range') range
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
@ -246,13 +249,12 @@ export class PortfolioController {
|
||||
});
|
||||
}
|
||||
|
||||
return <any>res.json(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
public async getPublic(
|
||||
@Param('accessId') accessId,
|
||||
@Res() res: Response
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
const user = await this.userService.user({
|
||||
@ -260,8 +262,10 @@ export class PortfolioController {
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
res.status(StatusCodes.NOT_FOUND);
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
@ -304,7 +308,7 @@ export class PortfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json(portfolioPublicDetails);
|
||||
return portfolioPublicDetails;
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@ -337,15 +341,17 @@ export class PortfolioController {
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Get('position/:symbol')
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPosition(impersonationId, symbol);
|
||||
.getPosition(dataSource, impersonationId, symbol);
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
@ -374,21 +380,18 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Res() res: Response
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json({ rules: [] });
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return <any>(
|
||||
res.json(
|
||||
await this.portfolioServiceStrategy.get().getReport(impersonationId)
|
||||
)
|
||||
);
|
||||
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
|
||||
}
|
||||
}
|
||||
|
@ -357,6 +357,7 @@ export class PortfolioServiceNew {
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
exchange: dataProviderResponse.exchange,
|
||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||
grossPerformancePercent:
|
||||
@ -397,6 +398,7 @@ export class PortfolioServiceNew {
|
||||
}
|
||||
|
||||
public async getPosition(
|
||||
aDataSource: DataSource,
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
@ -405,7 +407,12 @@ export class PortfolioServiceNew {
|
||||
|
||||
const orders = (
|
||||
await this.orderService.getOrders({ userCurrency, userId })
|
||||
).filter((order) => order.symbol === aSymbol);
|
||||
).filter(({ SymbolProfile }) => {
|
||||
return (
|
||||
SymbolProfile.dataSource === aDataSource &&
|
||||
SymbolProfile.symbol === aSymbol
|
||||
);
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
|
@ -345,6 +345,7 @@ export class PortfolioService {
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
exchange: dataProviderResponse.exchange,
|
||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||
grossPerformancePercent:
|
||||
@ -385,6 +386,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getPosition(
|
||||
aDataSource: DataSource,
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
@ -393,7 +395,12 @@ export class PortfolioService {
|
||||
|
||||
const orders = (
|
||||
await this.orderService.getOrders({ userCurrency, userId })
|
||||
).filter((order) => order.symbol === aSymbol);
|
||||
).filter(({ SymbolProfile }) => {
|
||||
return (
|
||||
SymbolProfile.dataSource === aDataSource &&
|
||||
SymbolProfile.symbol === aSymbol
|
||||
);
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
@ -467,7 +474,6 @@ export class PortfolioService {
|
||||
} = position;
|
||||
|
||||
// Convert investment, gross and net performance to currency of user
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const investment = this.exchangeRateDataService.toCurrency(
|
||||
position.investment?.toNumber(),
|
||||
currency,
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
@ -17,7 +18,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
@ -32,11 +32,9 @@ export class SubscriptionController {
|
||||
) {}
|
||||
|
||||
@Post('redeem-coupon')
|
||||
@HttpCode(StatusCodes.OK)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async redeemCoupon(
|
||||
@Body() { couponCode }: { couponCode: string },
|
||||
@Res() res: Response
|
||||
) {
|
||||
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
||||
if (!this.request.user) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -74,12 +72,10 @@ export class SubscriptionController {
|
||||
`Subscription for user '${this.request.user.id}' has been created with coupon`
|
||||
);
|
||||
|
||||
res.status(StatusCodes.OK);
|
||||
|
||||
return <any>res.json({
|
||||
return {
|
||||
message: getReasonPhrase(StatusCodes.OK),
|
||||
statusCode: StatusCodes.OK
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@Get('stripe/callback')
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
Controller,
|
||||
@ -5,7 +7,8 @@ import {
|
||||
HttpException,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -25,6 +28,7 @@ export class SymbolController {
|
||||
*/
|
||||
@Get('lookup')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async lookupSymbol(
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
@ -43,6 +47,8 @@ export class SymbolController {
|
||||
*/
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string,
|
||||
|
@ -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,8 +83,10 @@ export class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
|
||||
const { accessToken, id } = await this.userService.createUser({
|
||||
provider: Provider.ANONYMOUS
|
||||
role: hasAdmin ? 'USER' : 'ADMIN'
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -0,0 +1,39 @@
|
||||
import { decodeDataSource } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { ConfigurationService } from '../services/configuration.service';
|
||||
|
||||
@Injectable()
|
||||
export class TransformDataSourceInRequestInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
{
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<T>
|
||||
): Observable<any> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest();
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
|
||||
if (request.body.dataSource) {
|
||||
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
||||
}
|
||||
|
||||
if (request.params.dataSource) {
|
||||
request.params.dataSource = decodeDataSource(request.params.dataSource);
|
||||
}
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { ConfigurationService } from '../services/configuration.service';
|
||||
|
||||
@Injectable()
|
||||
export class TransformDataSourceInResponseInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
{
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<T>
|
||||
): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (data.activities) {
|
||||
data.activities.map((activity) => {
|
||||
activity.SymbolProfile.dataSource = encodeDataSource(
|
||||
activity.SymbolProfile.dataSource
|
||||
);
|
||||
activity.dataSource = encodeDataSource(activity.dataSource);
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.dataSource) {
|
||||
data.dataSource = encodeDataSource(data.dataSource);
|
||||
}
|
||||
|
||||
if (data.holdings) {
|
||||
for (const symbol of Object.keys(data.holdings)) {
|
||||
if (data.holdings[symbol].dataSource) {
|
||||
data.holdings[symbol].dataSource = encodeDataSource(
|
||||
data.holdings[symbol].dataSource
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.items) {
|
||||
data.items.map((item) => {
|
||||
item.dataSource = encodeDataSource(item.dataSource);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.positions) {
|
||||
data.positions.map((position) => {
|
||||
position.dataSource = encodeDataSource(position.dataSource);
|
||||
return position;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@
|
||||
"AVAX": "Avalanche",
|
||||
"DOT": "Polkadot",
|
||||
"MATIC": "Polygon",
|
||||
"MINA": "Mina Protocol",
|
||||
"SHIB": "Shiba Inu",
|
||||
"SOL": "Solana",
|
||||
"UNI3": "Uniswap"
|
||||
|
@ -473,9 +473,18 @@ export class DataGatheringService {
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
});
|
||||
|
||||
// Only consider symbols with incomplete market data for the last
|
||||
// 7 days
|
||||
const symbolsToGather = (
|
||||
const symbolsNotToGather = (
|
||||
await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['symbol'],
|
||||
@ -485,24 +494,15 @@ export class DataGatheringService {
|
||||
})
|
||||
)
|
||||
.filter((group) => {
|
||||
return group._count < 6;
|
||||
return group._count >= 6;
|
||||
})
|
||||
.map((group) => {
|
||||
return group.symbol;
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
)
|
||||
const symbolProfilesToGather = symbolProfiles
|
||||
.filter(({ symbol }) => {
|
||||
return symbolsToGather.includes(symbol);
|
||||
return !symbolsNotToGather.includes(symbol);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
@ -514,7 +514,7 @@ export class DataGatheringService {
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ symbol }) => {
|
||||
return symbolsToGather.includes(symbol);
|
||||
return !symbolsNotToGather.includes(symbol);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
|
@ -9,6 +9,21 @@ import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||
export class MarketDataService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async deleteMany({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
return this.prismaService.marketData.deleteMany({
|
||||
where: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async get({
|
||||
date,
|
||||
symbol
|
||||
|
@ -4,14 +4,26 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
|
||||
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SymbolProfileService {
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async delete({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
return this.prismaService.symbolProfile.delete({
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
}
|
||||
|
||||
public async getSymbolProfiles(
|
||||
symbols: string[]
|
||||
|
@ -46,9 +46,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
public ngOnChanges() {
|
||||
this.displayedColumns = [
|
||||
'account',
|
||||
'currency',
|
||||
'platform',
|
||||
'transactions',
|
||||
'currency',
|
||||
'balance',
|
||||
'value'
|
||||
];
|
||||
|
@ -20,6 +20,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './admin-market-data.html'
|
||||
})
|
||||
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
public currentDataSource: DataSource;
|
||||
public currentSymbol: string;
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public marketData: AdminMarketDataItem[] = [];
|
||||
@ -43,6 +44,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
this.fetchAdminMarketData();
|
||||
}
|
||||
|
||||
public onDeleteProfileData({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.adminService
|
||||
.deleteProfileData({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGatherProfileDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
@ -69,22 +83,33 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public setCurrentSymbol(aSymbol: string) {
|
||||
this.marketDataDetails = [];
|
||||
|
||||
if (this.currentSymbol === aSymbol) {
|
||||
this.currentSymbol = '';
|
||||
} else {
|
||||
this.currentSymbol = aSymbol;
|
||||
|
||||
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||
if (withRefresh) {
|
||||
this.fetchAdminMarketData();
|
||||
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
|
||||
this.fetchAdminMarketDataBySymbol({
|
||||
dataSource: this.currentDataSource,
|
||||
symbol: this.currentSymbol
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public setCurrentProfile({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.marketDataDetails = [];
|
||||
|
||||
if (this.currentSymbol === symbol) {
|
||||
this.currentDataSource = undefined;
|
||||
this.currentSymbol = '';
|
||||
} else {
|
||||
this.currentDataSource = dataSource;
|
||||
this.currentSymbol = symbol;
|
||||
|
||||
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,9 +129,15 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private fetchAdminMarketDataBySymbol(aSymbol: string) {
|
||||
this.dataService
|
||||
.fetchAdminMarketDataBySymbol(aSymbol)
|
||||
private fetchAdminMarketDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.adminService
|
||||
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketData }) => {
|
||||
this.marketDataDetails = marketData;
|
||||
|
@ -16,7 +16,7 @@
|
||||
<ng-container *ngFor="let item of marketData; let i = index">
|
||||
<tr
|
||||
class="cursor-pointer mat-row"
|
||||
(click)="setCurrentSymbol(item.symbol)"
|
||||
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
|
||||
>
|
||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
||||
@ -49,11 +49,19 @@
|
||||
>
|
||||
Gather Profile Data
|
||||
</button>
|
||||
<button
|
||||
i18n
|
||||
mat-menu-item
|
||||
[disabled]="item.activityCount !== 0"
|
||||
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
|
||||
>
|
||||
Delete Profile Data
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
||||
<td class="p-1" colspan="4">
|
||||
<td class="p-1" colspan="6">
|
||||
<gf-admin-market-data-detail
|
||||
[dataSource]="item.dataSource"
|
||||
[marketData]="marketDataDetails"
|
||||
|
@ -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
|
||||
@ -12,6 +13,7 @@ import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -25,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;
|
||||
@ -39,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,
|
||||
@ -47,8 +51,15 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['positionDetailDialog'] && params['symbol']) {
|
||||
this.openPositionDialog({ symbol: params['symbol'] });
|
||||
if (
|
||||
params['dataSource'] &&
|
||||
params['positionDetailDialog'] &&
|
||||
params['symbol']
|
||||
) {
|
||||
this.openPositionDialog({
|
||||
dataSource: params['dataSource'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -74,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';
|
||||
|
||||
@ -91,7 +109,13 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openPositionDialog({ symbol }: { symbol: string }) {
|
||||
private openPositionDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -101,9 +125,11 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
dataSource,
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
|
@ -4,9 +4,8 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -19,6 +18,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public fearAndGreedIndex: number;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public historicalData: HistoricalDataItem[];
|
||||
public info: InfoItem;
|
||||
public isLoading = true;
|
||||
public readonly numberOfDays = 90;
|
||||
public user: User;
|
||||
@ -33,6 +33,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
this.isLoading = true;
|
||||
|
||||
this.userService.stateChanged
|
||||
@ -49,7 +50,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
if (this.hasPermissionToAccessFearAndGreedIndex) {
|
||||
this.dataService
|
||||
.fetchSymbolItem({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.info.fearAndGreedDataSource,
|
||||
includeHistoricalData: this.numberOfDays,
|
||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||
})
|
||||
|
@ -1,6 +0,0 @@
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
|
||||
export interface PositionDetailDialogParams {
|
||||
deviceType: string;
|
||||
historicalDataItems: LineChartItem[];
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-dialog-content {
|
||||
max-height: unset;
|
||||
|
||||
gf-line-chart {
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0 -1rem;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,94 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject
|
||||
} 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 { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { isToday, parse } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-performance-chart-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: 'performance-chart-dialog.html',
|
||||
styleUrls: ['./performance-chart-dialog.component.scss']
|
||||
})
|
||||
export class PerformanceChartDialog {
|
||||
public benchmarkDataItems: LineChartItem[];
|
||||
public benchmarkSymbol = 'VOO';
|
||||
public currency: string;
|
||||
public firstBuyDate: string;
|
||||
public marketPrice: number;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<PerformanceChartDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||
) {
|
||||
this.dataService
|
||||
.fetchPositionDetail(this.benchmarkSymbol)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
|
||||
this.benchmarkDataItems = [];
|
||||
this.currency = currency;
|
||||
this.firstBuyDate = firstBuyDate;
|
||||
this.historicalDataItems = [];
|
||||
this.marketPrice = marketPrice;
|
||||
|
||||
let coefficient = 1;
|
||||
|
||||
this.historicalDataItems = this.data.historicalDataItems;
|
||||
|
||||
this.historicalDataItems?.forEach((historicalDataItem) => {
|
||||
const benchmarkItem = historicalData.find((item) => {
|
||||
return item.date === historicalDataItem.date;
|
||||
});
|
||||
|
||||
if (benchmarkItem) {
|
||||
if (coefficient === 1) {
|
||||
coefficient = historicalDataItem.value / benchmarkItem.value || 1;
|
||||
}
|
||||
|
||||
this.benchmarkDataItems.push({
|
||||
date: historicalDataItem.date,
|
||||
value: benchmarkItem.value * coefficient
|
||||
});
|
||||
} else if (
|
||||
isToday(parse(historicalDataItem.date, DATE_FORMAT, new Date()))
|
||||
) {
|
||||
this.benchmarkDataItems.push({
|
||||
date: historicalDataItem.date,
|
||||
value: marketPrice * coefficient
|
||||
});
|
||||
} else {
|
||||
this.benchmarkDataItems.push({
|
||||
date: historicalDataItem.date,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
<gf-dialog-header
|
||||
mat-dialog-title
|
||||
title="Performance"
|
||||
[deviceType]="data.deviceType"
|
||||
(closeButtonClicked)="onClose()"
|
||||
></gf-dialog-header>
|
||||
|
||||
<div mat-dialog-content>
|
||||
<div class="container p-0">
|
||||
<gf-line-chart
|
||||
class="mb-4"
|
||||
symbol="Performance"
|
||||
[benchmarkDataItems]="benchmarkDataItems"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[showGradient]="true"
|
||||
[showLegend]="true"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="false"
|
||||
></gf-line-chart>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<gf-dialog-footer
|
||||
mat-dialog-actions
|
||||
[deviceType]="data.deviceType"
|
||||
(closeButtonClicked)="onClose()"
|
||||
></gf-dialog-footer>
|
@ -1,28 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
|
||||
import { PerformanceChartDialog } from './performance-chart-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [PerformanceChartDialog],
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfLineChartModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class GfPerformanceChartDialogModule {}
|
@ -1,6 +1,10 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface PositionDetailDialogParams {
|
||||
baseCurrency: string;
|
||||
dataSource: DataSource;
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ 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';
|
||||
@ -59,7 +59,10 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.dataService
|
||||
.fetchPositionDetail(this.data.symbol)
|
||||
.fetchPositionDetail({
|
||||
dataSource: this.data.dataSource,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(
|
||||
({
|
||||
@ -182,6 +185,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.symbol}-${format(
|
||||
parseISO(data.meta.date),
|
||||
'yyyyMMddHHmm'
|
||||
)}.json`,
|
||||
'text/plain'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -131,12 +131,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>
|
||||
|
||||
|
@ -3,7 +3,11 @@
|
||||
<a
|
||||
class="d-flex p-3 w-100"
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ positionDetailDialog: true, symbol: position?.symbol }"
|
||||
[queryParams]="{
|
||||
dataSource: position?.dataSource,
|
||||
positionDetailDialog: true,
|
||||
symbol: position?.symbol
|
||||
}"
|
||||
>
|
||||
<div class="d-flex mr-2">
|
||||
<gf-trend-indicator
|
||||
|
@ -108,7 +108,7 @@
|
||||
}"
|
||||
(click)="
|
||||
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
|
||||
onOpenPositionDialog({ symbol: row.symbol })
|
||||
onOpenPositionDialog({ dataSource: row.dataSource, symbol: row.symbol })
|
||||
"
|
||||
></tr>
|
||||
</table>
|
||||
|
@ -14,7 +14,7 @@ import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { AssetClass, Order as OrderModel } from '@prisma/client';
|
||||
import { AssetClass, DataSource, Order as OrderModel } from '@prisma/client';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
@ -75,9 +75,15 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}*/
|
||||
|
||||
public onOpenPositionDialog({ symbol }: { symbol: string }): void {
|
||||
public onOpenPositionDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}): void {
|
||||
this.router.navigate([], {
|
||||
queryParams: { positionDetailDialog: true, symbol }
|
||||
queryParams: { dataSource, symbol, positionDetailDialog: true }
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -14,13 +14,13 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { ToggleOption } from '@ghostfolio/common/types';
|
||||
import { AssetClass } from '@prisma/client';
|
||||
import { AssetClass, DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
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'
|
||||
@ -84,8 +84,13 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
this.routeQueryParams = route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['positionDetailDialog'] && params['symbol']) {
|
||||
if (
|
||||
params['dataSource'] &&
|
||||
params['positionDetailDialog'] &&
|
||||
params['symbol']
|
||||
) {
|
||||
this.openPositionDialog({
|
||||
dataSource: params['dataSource'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
}
|
||||
@ -291,7 +296,13 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openPositionDialog({ symbol }: { symbol: string }) {
|
||||
private openPositionDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -301,9 +312,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
dataSource,
|
||||
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'
|
||||
|
@ -107,18 +107,6 @@
|
||||
<mat-datepicker #date disabled="false"></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Quantity</mat-label>
|
||||
@ -141,6 +129,7 @@
|
||||
type="number"
|
||||
[(ngModel)]="data.transaction.unitPrice"
|
||||
/>
|
||||
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
|
||||
<button
|
||||
*ngIf="currentMarketPrice && (data.transaction.type === 'BUY' || data.transaction.type === 'SELL')"
|
||||
mat-icon-button
|
||||
@ -152,6 +141,19 @@
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<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>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex" mat-dialog-actions>
|
||||
<gf-value
|
||||
|
@ -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'
|
||||
@ -39,7 +40,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
public routeQueryParams: Subscription;
|
||||
public user: User;
|
||||
|
||||
private primaryDataSource: DataSource;
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
@ -57,9 +57,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
private snackBar: MatSnackBar,
|
||||
private userService: UserService
|
||||
) {
|
||||
const { primaryDataSource } = this.dataService.fetchInfo();
|
||||
this.primaryDataSource = primaryDataSource;
|
||||
|
||||
this.routeQueryParams = route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
@ -75,8 +72,13 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
} else {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
}
|
||||
} else if (params['positionDetailDialog'] && params['symbol']) {
|
||||
} else if (
|
||||
params['dataSource'] &&
|
||||
params['positionDetailDialog'] &&
|
||||
params['symbol']
|
||||
) {
|
||||
this.openPositionDialog({
|
||||
dataSource: params['dataSource'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
}
|
||||
@ -89,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
|
||||
@ -101,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
|
||||
@ -146,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),
|
||||
@ -190,8 +191,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
try {
|
||||
await this.importTransactionsService.importJson({
|
||||
content: content.orders,
|
||||
defaultAccountId: this.defaultAccountId
|
||||
content: content.orders
|
||||
});
|
||||
|
||||
this.handleImportSuccess();
|
||||
@ -205,8 +205,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
try {
|
||||
await this.importTransactionsService.importCsv({
|
||||
fileContent,
|
||||
defaultAccountId: this.defaultAccountId,
|
||||
primaryDataSource: this.primaryDataSource
|
||||
userAccounts: this.user.accounts
|
||||
});
|
||||
|
||||
this.handleImportSuccess();
|
||||
@ -304,20 +303,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();
|
||||
|
||||
@ -387,7 +372,13 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private openPositionDialog({ symbol }: { symbol: string }) {
|
||||
private openPositionDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -397,9 +388,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
dataSource,
|
||||
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'
|
||||
|
@ -14,7 +14,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'
|
||||
|
@ -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'
|
||||
|
@ -3,8 +3,10 @@ import { Injectable } from '@angular/core';
|
||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -12,6 +14,37 @@ import { format } from 'date-fns';
|
||||
export class AdminService {
|
||||
public constructor(private http: HttpClient) {}
|
||||
|
||||
public deleteProfileData({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
return this.http.delete<void>(
|
||||
`/api/admin/profile-data/${dataSource}/${symbol}`
|
||||
);
|
||||
}
|
||||
|
||||
public fetchAdminMarketDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}): Observable<AdminMarketDataDetails> {
|
||||
return this.http
|
||||
.get<any>(`/api/admin/market-data/${dataSource}/${symbol}`)
|
||||
.pipe(
|
||||
map((data) => {
|
||||
for (const item of data.marketData) {
|
||||
item.date = parseISO(item.date);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public gatherMax() {
|
||||
return this.http.post<void>(`/api/admin/gather/max`, {});
|
||||
}
|
||||
|
@ -18,7 +18,6 @@ import {
|
||||
Accounts,
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
Export,
|
||||
InfoItem,
|
||||
PortfolioChart,
|
||||
@ -69,19 +68,6 @@ export class DataService {
|
||||
return this.http.get<AdminMarketData>('/api/admin/market-data');
|
||||
}
|
||||
|
||||
public fetchAdminMarketDataBySymbol(
|
||||
aSymbol: string
|
||||
): Observable<AdminMarketDataDetails> {
|
||||
return this.http.get<any>(`/api/admin/market-data/${aSymbol}`).pipe(
|
||||
map((data) => {
|
||||
for (const item of data.marketData) {
|
||||
item.date = parseISO(item.date);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public deleteAccess(aId: string) {
|
||||
return this.http.delete<any>(`/api/access/${aId}`);
|
||||
}
|
||||
@ -108,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 {
|
||||
@ -141,7 +135,7 @@ export class DataService {
|
||||
includeHistoricalData,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
dataSource: DataSource | string;
|
||||
includeHistoricalData?: number;
|
||||
symbol: string;
|
||||
}) {
|
||||
@ -225,19 +219,27 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
public fetchPositionDetail(aSymbol: string) {
|
||||
return this.http.get<any>(`/api/portfolio/position/${aSymbol}`).pipe(
|
||||
map((data) => {
|
||||
if (data.orders) {
|
||||
for (const order of data.orders) {
|
||||
order.createdAt = parseISO(order.createdAt);
|
||||
order.date = parseISO(order.date);
|
||||
public fetchPositionDetail({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
return this.http
|
||||
.get<any>(`/api/portfolio/position/${dataSource}/${symbol}`)
|
||||
.pipe(
|
||||
map((data) => {
|
||||
if (data.orders) {
|
||||
for (const order of data.orders) {
|
||||
order.createdAt = parseISO(order.createdAt);
|
||||
order.date = parseISO(order.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
);
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public loginAnonymous(accessToken: string) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { Account, DataSource, Type } from '@prisma/client';
|
||||
import { parse } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
import { parse as csvToJson } from 'papaparse';
|
||||
@ -12,7 +12,9 @@ import { catchError } from 'rxjs/operators';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ImportTransactionsService {
|
||||
private static ACCOUNT_KEYS = ['account', 'accountid'];
|
||||
private static CURRENCY_KEYS = ['ccy', 'currency'];
|
||||
private static DATA_SOURCE_KEYS = ['datasource'];
|
||||
private static DATE_KEYS = ['date'];
|
||||
private static FEE_KEYS = ['commission', 'fee'];
|
||||
private static QUANTITY_KEYS = ['qty', 'quantity', 'shares', 'units'];
|
||||
@ -23,13 +25,11 @@ export class ImportTransactionsService {
|
||||
public constructor(private http: HttpClient) {}
|
||||
|
||||
public async importCsv({
|
||||
defaultAccountId,
|
||||
fileContent,
|
||||
primaryDataSource
|
||||
userAccounts
|
||||
}: {
|
||||
defaultAccountId: string;
|
||||
fileContent: string;
|
||||
primaryDataSource: DataSource;
|
||||
userAccounts: Account[];
|
||||
}) {
|
||||
const content = csvToJson(fileContent, {
|
||||
dynamicTyping: true,
|
||||
@ -38,12 +38,11 @@ export class ImportTransactionsService {
|
||||
}).data;
|
||||
|
||||
const orders: CreateOrderDto[] = [];
|
||||
|
||||
for (const [index, item] of content.entries()) {
|
||||
orders.push({
|
||||
accountId: defaultAccountId,
|
||||
accountId: this.parseAccount({ item, userAccounts }),
|
||||
currency: this.parseCurrency({ content, index, item }),
|
||||
dataSource: primaryDataSource,
|
||||
dataSource: this.parseDataSource({ item }),
|
||||
date: this.parseDate({ content, index, item }),
|
||||
fee: this.parseFee({ content, index, item }),
|
||||
quantity: this.parseQuantity({ content, index, item }),
|
||||
@ -53,21 +52,13 @@ export class ImportTransactionsService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.importJson({ defaultAccountId, content: orders });
|
||||
await this.importJson({ content: orders });
|
||||
}
|
||||
|
||||
public importJson({
|
||||
content,
|
||||
defaultAccountId
|
||||
}: {
|
||||
content: CreateOrderDto[];
|
||||
defaultAccountId: string;
|
||||
}): Promise<void> {
|
||||
public importJson({ content }: { content: CreateOrderDto[] }): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.postImport({
|
||||
orders: content.map((order) => {
|
||||
return { ...order, accountId: defaultAccountId };
|
||||
})
|
||||
orders: content
|
||||
})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
@ -90,6 +81,29 @@ export class ImportTransactionsService {
|
||||
}, {});
|
||||
}
|
||||
|
||||
private parseAccount({
|
||||
item,
|
||||
userAccounts
|
||||
}: {
|
||||
item: any;
|
||||
userAccounts: Account[];
|
||||
}) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.ACCOUNT_KEYS) {
|
||||
if (item[key]) {
|
||||
return userAccounts.find((account) => {
|
||||
return (
|
||||
account.id === item[key] ||
|
||||
account.name.toLowerCase() === item[key].toLowerCase()
|
||||
);
|
||||
})?.id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private parseCurrency({
|
||||
content,
|
||||
index,
|
||||
@ -110,6 +124,18 @@ export class ImportTransactionsService {
|
||||
throw { message: `orders.${index}.currency is not valid`, orders: content };
|
||||
}
|
||||
|
||||
private parseDataSource({ item }: { item: any }) {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportTransactionsService.DATA_SOURCE_KEYS) {
|
||||
if (item[key]) {
|
||||
return DataSource[item[key].toUpperCase()];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private parseDate({
|
||||
content,
|
||||
index,
|
||||
|
@ -164,6 +164,10 @@ ngx-skeleton-loader {
|
||||
min-width: unset !important;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.svgMap-tooltip {
|
||||
border-bottom: none;
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import * as currencies from '@dinero.js/currencies';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
|
||||
|
||||
import { ghostfolioScraperApiSymbolPrefix } from './config';
|
||||
@ -7,6 +8,28 @@ export function capitalize(aString: string) {
|
||||
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
export function getBackgroundColor() {
|
||||
return getCssVariable(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
@ -1,16 +1,14 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import { Statistics } from './statistics.interface';
|
||||
import { Subscription } from './subscription.interface';
|
||||
|
||||
export interface InfoItem {
|
||||
currencies: string[];
|
||||
demoAuthToken: string;
|
||||
fearAndGreedDataSource?: string;
|
||||
globalPermissions: string[];
|
||||
isReadOnlyMode?: boolean;
|
||||
lastDataGathering?: Date;
|
||||
platforms: { id: string; name: string }[];
|
||||
primaryDataSource: DataSource;
|
||||
statistics: Statistics;
|
||||
stripePublicKey?: string;
|
||||
subscriptions: Subscription[];
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
|
||||
import { Country } from './country.interface';
|
||||
import { Sector } from './sector.interface';
|
||||
@ -11,6 +11,7 @@ export interface PortfolioPosition {
|
||||
assetSubClass?: AssetSubClass | 'CASH';
|
||||
countries: Country[];
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
exchange?: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { AssetClass } from '@prisma/client';
|
||||
import { AssetClass, DataSource } from '@prisma/client';
|
||||
|
||||
export interface Position {
|
||||
assetClass: AssetClass;
|
||||
averagePrice: number;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
firstBuyDate: string;
|
||||
grossPerformance?: number;
|
||||
grossPerformancePercentage?: number;
|
||||
|
@ -36,308 +36,335 @@
|
||||
</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',
|
||||
sell: element.type === 'SELL'
|
||||
}"
|
||||
>
|
||||
<ion-icon
|
||||
[name]="
|
||||
element.type === 'BUY' || element.type === 'DIVIDEND'
|
||||
? 'arrow-forward-circle-outline'
|
||||
: '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">
|
||||
{{ element.symbol | gfSymbol }}
|
||||
<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({
|
||||
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 &&
|
||||
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>
|
||||
</div>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
|
@ -14,45 +14,49 @@
|
||||
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);
|
||||
}
|
||||
|
||||
&.sell {
|
||||
color: var(--orange);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ import { Router } from '@angular/router';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
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 { endOfToday, format, isAfter } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
@ -42,6 +43,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;
|
||||
@ -52,7 +54,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;
|
||||
@ -131,18 +133,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
'date',
|
||||
'type',
|
||||
'symbol',
|
||||
'currency',
|
||||
'quantity',
|
||||
'currency',
|
||||
'unitPrice',
|
||||
'fee',
|
||||
'value',
|
||||
'account'
|
||||
'account',
|
||||
'actions'
|
||||
];
|
||||
|
||||
if (this.showActions) {
|
||||
this.displayedColumns.push('actions');
|
||||
}
|
||||
|
||||
if (!this.showSymbolColumn) {
|
||||
this.displayedColumns = this.displayedColumns.filter((column) => {
|
||||
return column !== 'symbol';
|
||||
@ -183,16 +182,30 @@ 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() {
|
||||
this.import.emit();
|
||||
}
|
||||
|
||||
public onOpenPositionDialog({ symbol }: { symbol: string }): void {
|
||||
public onOpenPositionDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}): void {
|
||||
this.router.navigate([], {
|
||||
queryParams: { positionDetailDialog: true, symbol }
|
||||
queryParams: { dataSource, symbol, positionDetailDialog: true }
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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 = '';
|
||||
@ -91,6 +92,11 @@ export class ValueComponent implements OnChanges {
|
||||
} else {
|
||||
this.formattedValue = this.value?.toString();
|
||||
}
|
||||
|
||||
if (this.isAbsolute) {
|
||||
// Remove algebraic sign
|
||||
this.formattedValue = this.formattedValue.replace(/^-/, '');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (isDate(new Date(this.value))) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.108.0",
|
||||
"version": "1.112.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -14,6 +14,7 @@
|
||||
"affected:test": "nx affected:test",
|
||||
"angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng",
|
||||
"build:all": "ng build --configuration production api && ng build --configuration production client && yarn replace-placeholders-in-build",
|
||||
"build:dev": "nx build api && nx build client && yarn replace-placeholders-in-build",
|
||||
"build:storybook": "nx run ui:build-storybook",
|
||||
"clean": "rimraf dist",
|
||||
"database:format-schema": "prisma format",
|
||||
@ -69,7 +70,7 @@
|
||||
"@nestjs/schedule": "1.0.2",
|
||||
"@nestjs/serve-static": "2.2.2",
|
||||
"@nrwl/angular": "13.4.1",
|
||||
"@prisma/client": "3.8.1",
|
||||
"@prisma/client": "3.9.1",
|
||||
"@simplewebauthn/browser": "4.1.0",
|
||||
"@simplewebauthn/server": "4.1.0",
|
||||
"@simplewebauthn/typescript-types": "4.0.0",
|
||||
@ -106,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",
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ALTER COLUMN "dataSource" DROP NOT NULL;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ALTER COLUMN "symbol" DROP NOT NULL;
|
@ -0,0 +1,8 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Order" DROP CONSTRAINT "Order_symbolProfileId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ALTER COLUMN "symbolProfileId" SET NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_symbolProfileId_fkey" FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -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';
|
@ -70,24 +70,24 @@ model MarketData {
|
||||
}
|
||||
|
||||
model Order {
|
||||
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
|
||||
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
|
||||
accountId String?
|
||||
accountUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
currency String?
|
||||
dataSource DataSource
|
||||
dataSource DataSource?
|
||||
date DateTime
|
||||
fee Float
|
||||
id String @default(uuid())
|
||||
isDraft Boolean @default(false)
|
||||
id String @default(uuid())
|
||||
isDraft Boolean @default(false)
|
||||
quantity Float
|
||||
symbol String
|
||||
SymbolProfile SymbolProfile? @relation(fields: [symbolProfileId], references: [id])
|
||||
symbolProfileId String?
|
||||
symbol String?
|
||||
SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id])
|
||||
symbolProfileId String
|
||||
type Type
|
||||
unitPrice Float
|
||||
updatedAt DateTime @updatedAt
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
updatedAt DateTime @updatedAt
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
@@id([id, userId])
|
||||
@ -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[]
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
36
yarn.lock
36
yarn.lock
@ -3349,22 +3349,22 @@
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
|
||||
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
|
||||
|
||||
"@prisma/client@3.8.1":
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.8.1.tgz#c11eda8e84760867552ffde4de7b48fb2cf1e1c0"
|
||||
integrity sha512-NxD1Xbkx1eT1mxSwo1RwZe665mqBETs0VxohuwNfFIxMqcp0g6d4TgugPxwZ4Jb4e5wCu8mQ9quMedhNWIWcZQ==
|
||||
"@prisma/client@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.9.1.tgz#565c8121f1220637bcab4a1d1f106b8c1334406c"
|
||||
integrity sha512-aLwfXKLvL+loQ0IuPPCXkcq8cXBg1IeoHHa5lqQu3dJHdj45wnislA/Ny4UxRQjD5FXqrfAb8sWtF+jhdmjFTg==
|
||||
dependencies:
|
||||
"@prisma/engines-version" "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
|
||||
"@prisma/engines-version" "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
|
||||
|
||||
"@prisma/engines-version@3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f":
|
||||
version "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f.tgz#4c8d9744b5e54650a8ba5fde0a711399d6adba24"
|
||||
integrity sha512-G2JH6yWt6ixGKmsRmVgaQYahfwMopim0u/XLIZUo2o/mZ5jdu7+BL+2V5lZr7XiG1axhyrpvlyqE/c0OgYSl3g==
|
||||
"@prisma/engines-version@3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
|
||||
version "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz#ea03ffa723382a526dc6625ce6eae9b6ad984400"
|
||||
integrity sha512-5Dh+qTDhpPR66w6NNAnPs+/W/Qt4r1DSd+qhfPFcDThUK4uxoZKGlPb2IYQn5LL+18aIGnmteDf7BnVMmvBNSQ==
|
||||
|
||||
"@prisma/engines@3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f":
|
||||
version "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f.tgz#4479099b99f6a082ce5843ee7208943ccedd127f"
|
||||
integrity sha512-bHYubuItSN/DGYo36aDu7xJiJmK52JOSHs4MK+KbceAtwS20BCWadRgtpQ3iZ2EXfN/B1T0iCXlNraaNwnpU2w==
|
||||
"@prisma/engines@3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
|
||||
version "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz#e5c345cdedb7be83d11c1e0c5ab61d866b411256"
|
||||
integrity sha512-qM+uJbkelB21bnK44gYE049YTHIjHysOuj0mj5U2gDGyNLfmiazlggzFPCgEjgme4U5YB2tYs6Z5Hq08Kl8pjA==
|
||||
|
||||
"@samverschueren/stream-to-observable@^0.3.0":
|
||||
version "0.3.1"
|
||||
@ -15025,12 +15025,12 @@ pretty-hrtime@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
|
||||
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
|
||||
|
||||
prisma@3.8.1:
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.8.1.tgz#44395cef7cbb1ea86216cb84ee02f856c08a7873"
|
||||
integrity sha512-Q8zHwS9m70TaD7qI8u+8hTAmiTpK+IpvRYF3Rgb/OeWGQJOMgZCFFvNCiSfoLEQ95wilK7ctW3KOpc9AuYnRUA==
|
||||
prisma@3.9.1:
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.9.1.tgz#7510a8bf06018a5313b9427b1127ce4750b1ce5c"
|
||||
integrity sha512-IGcJAu5LzlFv+i+NNhOEh1J1xVVttsVdRBxmrMN7eIH+7mRN6L89Hz1npUAiz4jOpNlHC7n9QwaOYZGxTqlwQw==
|
||||
dependencies:
|
||||
"@prisma/engines" "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
|
||||
"@prisma/engines" "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
|
||||
|
||||
prismjs@^1.21.0, prismjs@~1.24.0:
|
||||
version "1.24.1"
|
||||
|
Reference in New Issue
Block a user