Compare commits
66 Commits
Author | SHA1 | Date | |
---|---|---|---|
edd3e75730 | |||
ab68c2c69a | |||
cbb95f21a3 | |||
74d3954335 | |||
92449b0369 | |||
65276483e0 | |||
dde0d1e465 | |||
3ad802c6f5 | |||
b81377a682 | |||
545180b88f | |||
a9819b9e25 | |||
897e941e7a | |||
aef840c2cc | |||
80d0638922 | |||
494ba36d44 | |||
dab9154092 | |||
cd4a85abbf | |||
e7977a9fbb | |||
684c1e55b0 | |||
1ffa831c5c | |||
40eed0016c | |||
b58631083b | |||
e0c0425d21 | |||
bf2de5d572 | |||
2b4a1dc480 | |||
ce022c024f | |||
0f4bf529d8 | |||
dad6bf7095 | |||
86ca9eaae6 | |||
9d9b805b0e | |||
851401be1e | |||
85052bc9bc | |||
bff09f529d | |||
f438458687 | |||
7125b12631 | |||
0cbf275a2e | |||
0ec50819f5 | |||
c9abe818bc | |||
bfa32537a8 | |||
cef15afab8 | |||
1b9587c454 | |||
de76b0d8c3 | |||
e62989c981 | |||
d6b71e6314 | |||
8c59bfd6d7 | |||
f32df73256 | |||
9d03a8002c | |||
3c36ca29af | |||
efed7e3c2b | |||
b09d3cea95 | |||
eabd2f3934 | |||
cc184c2827 | |||
436f791fa4 | |||
e935a57dec | |||
203909d917 | |||
eed4f57f30 | |||
7878036bac | |||
75d140b436 | |||
a79f31b006 | |||
45cfd61dbb | |||
7fcfca952e | |||
279f16cc67 | |||
e7b1d8a5d3 | |||
1b2f8e5586 | |||
e4468252c6 | |||
ad3ebd42bb |
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -41,7 +41,7 @@ jobs:
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.output.labels }}
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,6 +25,7 @@
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.env
|
||||
.env.prod
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
@ -38,4 +39,4 @@ yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
|
@ -1,10 +1,7 @@
|
||||
module.exports = {
|
||||
stories: [],
|
||||
addons: ['@storybook/addon-essentials']
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||
|
||||
// // Return the altered config
|
||||
// return config;
|
||||
// },
|
||||
|
149
CHANGELOG.md
149
CHANGELOG.md
@ -5,6 +5,151 @@ 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.240.0 - 2023-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a manual currency for the activity unit price
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post
|
||||
|
||||
## 1.239.0 - 2023-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Ghostfolio meets Umbrel_
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the dependency `rimraf`
|
||||
|
||||
## 1.238.0 - 2023-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added `COINGECKO` as a new data source type
|
||||
- Added support for data provider information to the position detail dialog
|
||||
- Added the configuration to publish a `linux/arm/v7` docker image
|
||||
- Added _Reddit_ to the _As seen in_ section on the landing page
|
||||
- Added _Umbrel_ to the _As seen in_ section on the landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the example environment variable file from `.env` to `.env.example`
|
||||
- Upgraded `zone.js` from version `0.11.8` to `0.12.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode
|
||||
- Reset the letter spacing in buttons
|
||||
|
||||
### Todo
|
||||
|
||||
- Ensure that you still have a `.env` file in your project
|
||||
|
||||
## 1.237.0 - 2023-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added the support details to the pricing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the file size limit for the activities import
|
||||
- Improved the style of the search results for symbols
|
||||
- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc)
|
||||
- Upgraded `angular` from version `15.1.2` to `15.1.5`
|
||||
- Upgraded `Nx` from version `15.6.3` to `15.7.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`)
|
||||
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||
|
||||
## 1.236.0 - 2023-02-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Beautified the ETF names in the asset profile
|
||||
- Removed the data source type `GHOSTFOLIO`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||
- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_
|
||||
- Fixed the url on logout during the local development
|
||||
|
||||
## 1.235.0 - 2023-02-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styles on the about page
|
||||
- Eliminated the `GhostfolioScraperApiService`
|
||||
|
||||
## 1.234.0 - 2023-02-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added the data import and export feature to the pricing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Copy the logic of `GhostfolioScraperApiService` to `ManualService`
|
||||
- Improved the content of the landing page
|
||||
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||
- Improved the usability of the _Import Activities..._ action
|
||||
- Eliminated the permission `enableImport`
|
||||
- Set the exposed port as an environment variable (`PORT`) in `Dockerfile`
|
||||
- Migrated the style of `AboutPageModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `BlogPageModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `ChangelogPageModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `ResourcesPageModule` to `@angular/material` `15` (mdc)
|
||||
- Upgraded `chart.js` from version `4.0.1` to `4.2.0`
|
||||
- Upgraded `ionicons` from version `6.0.4` to `6.1.2`
|
||||
- Upgraded `prettier` from version `2.8.1` to `2.8.4`
|
||||
- Upgraded `prisma` from version `4.9.0` to `4.10.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue on the landing page caused by the global heat map of subscribers
|
||||
- Fixed the links in the interstitial for the subscription
|
||||
|
||||
### Todo
|
||||
|
||||
- Remove the environment variable `ENABLE_FEATURE_IMPORT`
|
||||
- Rename the `dataSource` from `GHOSTFOLIO` to `MANUAL`
|
||||
- Eliminate `GhostfolioScraperApiService`
|
||||
|
||||
## 1.233.0 - 2023-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to export accounts
|
||||
- Added suport to import accounts
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling in the admin control panel
|
||||
- Removed the _Google Play_ badge from the landing page
|
||||
- Upgraded `eslint` dependencies
|
||||
|
||||
## 1.232.0 - 2023-02-05
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
- Migrated the style of `ActivitiesPageModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `GfCreateOrUpdateActivityDialogModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `GfMarketDataDetailDialogModule` to `@angular/material` `15` (mdc)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.1.2` to `2.5.0`
|
||||
- Upgraded `ngx-markdown` from version `14.0.1` to `15.1.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the `Upgrade Plan` button of the interstitial for the subscription
|
||||
|
||||
## 1.231.0 - 2023-02-04
|
||||
|
||||
### Added
|
||||
@ -656,7 +801,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added the alias to the `Access` database schema
|
||||
- Added support for translated time distances
|
||||
- Added a _GitHub Action_ to create an `arm64` docker image
|
||||
- Added a _GitHub Action_ to create an `linux/arm64` docker image
|
||||
|
||||
### Changed
|
||||
|
||||
@ -1269,7 +1414,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Beautified the ETF names in the symbol profile
|
||||
- Beautified the ETF names in the asset profile
|
||||
|
||||
### Fixed
|
||||
|
||||
|
25
DEVELOPMENT.md
Normal file
25
DEVELOPMENT.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Ghostfolio Development Guide
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
|
||||
`git rebase -i --autosquash main`
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Nx
|
||||
|
||||
#### Upgrade
|
||||
|
||||
1. Run `yarn nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||
1. Run `yarn nx migrate --run-migrations`
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Create schema migration (local)
|
||||
|
||||
Run `yarn prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
@ -57,5 +57,5 @@ RUN apt update && apt install -y \
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE 3333
|
||||
EXPOSE ${PORT:-3333}
|
||||
CMD [ "yarn", "start:prod" ]
|
||||
|
10
README.md
10
README.md
@ -75,7 +75,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
|
||||
## Self-hosting
|
||||
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@ -106,7 +106,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
- Basic knowledge of Docker
|
||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- Local copy of this Git repository (clone)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
#### a. Run environment
|
||||
|
||||
@ -150,7 +151,8 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- A local copy of this Git repository (clone)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
@ -175,7 +177,7 @@ Run `yarn start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client`
|
||||
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
|
@ -17,6 +17,10 @@ export class CreateAccountDto {
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
@ -244,6 +244,7 @@ export class AdminService {
|
||||
Analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
country: true,
|
||||
updatedAt: true
|
||||
}
|
||||
},
|
||||
@ -277,6 +278,7 @@ export class AdminService {
|
||||
id,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics.country,
|
||||
lastActivity: Analytics.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
};
|
||||
|
@ -61,8 +61,10 @@ export class AuthService {
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
thirdPartyId: principalId
|
||||
data: {
|
||||
provider,
|
||||
thirdPartyId: principalId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,8 +98,10 @@ export class AuthService {
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
thirdPartyId
|
||||
data: {
|
||||
provider,
|
||||
thirdPartyId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -14,6 +14,22 @@ export class ExportService {
|
||||
activityIds?: string[];
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
select: {
|
||||
accountType: true,
|
||||
balance: true,
|
||||
currency: true,
|
||||
id: true,
|
||||
isExcluded: true,
|
||||
name: true,
|
||||
platformId: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
@ -38,6 +54,7 @@ export class ExportService {
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
accounts,
|
||||
activities: activities.map(
|
||||
({
|
||||
accountId,
|
||||
|
@ -90,6 +90,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
||||
title = `Ghostfolio meets Umbrel - ${title}`;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -1,8 +1,15 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => CreateAccountDto)
|
||||
@ValidateNested({ each: true })
|
||||
accounts: CreateAccountDto[];
|
||||
|
||||
@IsArray()
|
||||
@Type(() => CreateOrderDto)
|
||||
@ValidateNested({ each: true })
|
||||
|
@ -2,6 +2,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -38,7 +39,13 @@ export class ImportController {
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
): Promise<ImportResponse> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createAccount
|
||||
) ||
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -60,9 +67,10 @@ export class ImportController {
|
||||
|
||||
try {
|
||||
const activities = await this.importService.import({
|
||||
maxActivitiesToImport,
|
||||
isDryRun,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
accountsDto: importData.accounts ?? [],
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
@ -100,18 +101,75 @@ export class ImportService {
|
||||
}
|
||||
|
||||
public async import({
|
||||
accountsDto,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
accountsDto: Partial<CreateAccountDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Activity[]> {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
const existingAccounts = await this.accountService.accounts({
|
||||
where: {
|
||||
id: {
|
||||
in: accountsDto.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const account of accountsDto) {
|
||||
// Check if there is any existing account with the same ID
|
||||
const accountWithSameId = existingAccounts.find(
|
||||
(existingAccount) => existingAccount.id === account.id
|
||||
);
|
||||
|
||||
// If there is no account or if the account belongs to a different user then create a new account
|
||||
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||
let oldAccountId: string;
|
||||
const platformId = account.platformId;
|
||||
|
||||
delete account.platformId;
|
||||
|
||||
if (accountWithSameId) {
|
||||
oldAccountId = account.id;
|
||||
delete account.id;
|
||||
}
|
||||
|
||||
const newAccountObject = {
|
||||
...account,
|
||||
User: { connect: { id: userId } }
|
||||
};
|
||||
|
||||
if (platformId) {
|
||||
Object.assign(newAccountObject, {
|
||||
Platform: { connect: { id: platformId } }
|
||||
});
|
||||
}
|
||||
|
||||
const newAccount = await this.accountService.createAccount(
|
||||
newAccountObject,
|
||||
userId
|
||||
);
|
||||
|
||||
// Store the new to old account ID mappings for updating activities
|
||||
if (accountWithSameId && oldAccountId) {
|
||||
accountIdMapping[oldAccountId] = newAccount.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
@ -120,6 +178,13 @@ export class ImportService {
|
||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
}
|
||||
}
|
||||
|
||||
// If a new account is created, then update the accountId in all activities
|
||||
if (!isDryRun) {
|
||||
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
|
||||
activity.accountId = accountIdMapping[activity.accountId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assetProfiles = await this.validateActivities({
|
||||
@ -128,12 +193,18 @@ export class ImportService {
|
||||
userId
|
||||
});
|
||||
|
||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||
(account) => {
|
||||
return account.id;
|
||||
return { id: account.id, name: account.name };
|
||||
}
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
accountsDto.forEach(({ id, name }) => {
|
||||
accounts.push({ id, name });
|
||||
});
|
||||
}
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const {
|
||||
@ -149,11 +220,15 @@ export class ImportService {
|
||||
unitPrice
|
||||
} of activitiesDto) {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const validatedAccountId = accountIds.includes(accountId)
|
||||
? accountId
|
||||
: undefined;
|
||||
const validatedAccount = accounts.find(({ id }) => {
|
||||
return id === accountId;
|
||||
});
|
||||
|
||||
let order: OrderWithAccount;
|
||||
let order:
|
||||
| OrderWithAccount
|
||||
| (Omit<OrderWithAccount, 'Account'> & {
|
||||
Account?: { id: string; name: string };
|
||||
});
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
@ -164,7 +239,7 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
accountId: validatedAccount?.id,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
id: uuidv4(),
|
||||
@ -187,6 +262,7 @@ export class ImportService {
|
||||
url: null,
|
||||
...assetProfiles[symbol]
|
||||
},
|
||||
Account: validatedAccount,
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
@ -199,7 +275,7 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
accountId: validatedAccount?.id,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
@ -221,6 +297,7 @@ export class ImportService {
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
|
||||
//@ts-ignore
|
||||
activities.push({
|
||||
...order,
|
||||
value,
|
||||
|
@ -72,10 +72,6 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
|
@ -110,9 +110,6 @@ export class OrderService {
|
||||
dataSource,
|
||||
symbol: id
|
||||
};
|
||||
} else {
|
||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
@ -48,8 +49,11 @@ export const CurrentRateServiceMock = {
|
||||
getValues: ({
|
||||
dataGatheringItems,
|
||||
dateQuery
|
||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||
const result: GetValueObject[] = [];
|
||||
}: GetValuesParams): Promise<{
|
||||
dataProviderInfos: DataProviderInfo[];
|
||||
values: GetValueObject[];
|
||||
}> => {
|
||||
const values: GetValueObject[] = [];
|
||||
if (dateQuery.lt) {
|
||||
for (
|
||||
let date = resetHours(dateQuery.gte);
|
||||
@ -57,7 +61,7 @@ export const CurrentRateServiceMock = {
|
||||
date = addDays(date, 1)
|
||||
) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
@ -70,7 +74,7 @@ export const CurrentRateServiceMock = {
|
||||
} else {
|
||||
for (const date of dateQuery.in) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
@ -81,6 +85,6 @@ export const CurrentRateServiceMock = {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
return Promise.resolve({ values, dataProviderInfos: [] });
|
||||
}
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
@ -103,17 +104,23 @@ describe('CurrentRateService', () => {
|
||||
},
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject<GetValueObject[]>([
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]);
|
||||
).toMatchObject<{
|
||||
dataProviderInfos: DataProviderInfo[];
|
||||
values: GetValueObject[];
|
||||
}>({
|
||||
dataProviderInfos: [],
|
||||
values: [
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
@ -22,7 +23,11 @@ export class CurrentRateService {
|
||||
dataGatheringItems,
|
||||
dateQuery,
|
||||
userCurrency
|
||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||
}: GetValuesParams): Promise<{
|
||||
dataProviderInfos: DataProviderInfo[];
|
||||
values: GetValueObject[];
|
||||
}> {
|
||||
const dataProviderInfos: DataProviderInfo[] = [];
|
||||
const includeToday =
|
||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
@ -38,6 +43,14 @@ export class CurrentRateService {
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
if (
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||
) {
|
||||
dataProviderInfos.push(
|
||||
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||
);
|
||||
}
|
||||
|
||||
result.push({
|
||||
date: today,
|
||||
marketPriceInBaseCurrency:
|
||||
@ -81,7 +94,10 @@ export class CurrentRateService {
|
||||
})
|
||||
);
|
||||
|
||||
return flatten(await Promise.all(promises));
|
||||
return {
|
||||
dataProviderInfos,
|
||||
values: flatten(await Promise.all(promises))
|
||||
};
|
||||
}
|
||||
|
||||
private containsToday(dates: Date[]): boolean {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
DataProviderInfo,
|
||||
EnhancedSymbolProfile,
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -7,6 +8,7 @@ import { Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
dataProviderInfo: DataProviderInfo;
|
||||
dividendInBaseCurrency: number;
|
||||
feeInBaseCurrency: number;
|
||||
firstBuyDate: string;
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
ResponseError,
|
||||
TimelinePosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
@ -45,6 +49,7 @@ export class PortfolioCalculator {
|
||||
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private dataProviderInfos: DataProviderInfo[];
|
||||
private orders: PortfolioOrder[];
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
@ -202,14 +207,17 @@ export class PortfolioCalculator {
|
||||
symbols[item.symbol] = true;
|
||||
}
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
const { dataProviderInfos, values: marketSymbols } =
|
||||
await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
this.dataProviderInfos = dataProviderInfos;
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
@ -368,14 +376,17 @@ export class PortfolioCalculator {
|
||||
|
||||
dates.push(resetHours(end));
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
const { dataProviderInfos, values: marketSymbols } =
|
||||
await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
this.dataProviderInfos = dataProviderInfos;
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
@ -463,6 +474,10 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfos() {
|
||||
return this.dataProviderInfos;
|
||||
}
|
||||
|
||||
public getInvestments(): { date: string; investment: Big }[] {
|
||||
if (this.transactionPoints.length === 0) {
|
||||
return [];
|
||||
@ -748,7 +763,7 @@ export class PortfolioCalculator {
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
const { values } = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
@ -757,6 +772,7 @@ export class PortfolioCalculator {
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
marketSymbols = values;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
|
@ -678,6 +678,7 @@ export class PortfolioService {
|
||||
return {
|
||||
tags,
|
||||
averagePrice: undefined,
|
||||
dataProviderInfo: undefined,
|
||||
dividendInBaseCurrency: undefined,
|
||||
feeInBaseCurrency: undefined,
|
||||
firstBuyDate: undefined,
|
||||
@ -849,6 +850,7 @@ export class PortfolioService {
|
||||
tags,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
|
||||
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee.toNumber(),
|
||||
@ -911,6 +913,7 @@ export class PortfolioService {
|
||||
SymbolProfile,
|
||||
tags,
|
||||
averagePrice: 0,
|
||||
dataProviderInfo: undefined,
|
||||
dividendInBaseCurrency: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
firstBuyDate: undefined,
|
||||
@ -1550,7 +1553,10 @@ export class PortfolioService {
|
||||
userCurrency
|
||||
}).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
Math.max(
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
)
|
||||
);
|
||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||
const firstOrderDate = activities[0]?.date;
|
||||
|
7
apps/api/src/app/user/create-user.dto.ts
Normal file
7
apps/api/src/app/user/create-user.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
country?: string;
|
||||
}
|
@ -22,6 +22,7 @@ import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { size } from 'lodash';
|
||||
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||
import { UserService } from './user.service';
|
||||
@ -65,7 +66,7 @@ export class UserController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
public async signupUser(): Promise<UserItem> {
|
||||
public async signupUser(@Body() data: CreateUserDto): Promise<UserItem> {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
@ -79,7 +80,8 @@ export class UserController {
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
|
||||
const { accessToken, id, role } = await this.userService.createUser({
|
||||
role: hasAdmin ? 'USER' : 'ADMIN'
|
||||
country: data.country,
|
||||
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -18,6 +18,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
@ -231,7 +233,10 @@ export class UserService {
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
||||
public async createUser({
|
||||
country,
|
||||
data
|
||||
}: CreateUserDto & { data: Prisma.UserCreateInput }): Promise<User> {
|
||||
if (!data?.provider) {
|
||||
data.provider = 'ANONYMOUS';
|
||||
}
|
||||
@ -256,6 +261,15 @@ export class UserService {
|
||||
}
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
await this.prismaService.analytics.create({
|
||||
data: {
|
||||
country,
|
||||
User: { connect: { id: user.id } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.provider === 'ANONYMOUS') {
|
||||
const accessToken = this.createAccessToken(
|
||||
user.id,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import Big from 'big.js';
|
||||
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||
|
||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
@ -59,7 +60,10 @@ export function redactAttributes({
|
||||
return redactAttributes({ options, object: currentObject });
|
||||
}
|
||||
);
|
||||
} else if (isObject(redactedObject[property])) {
|
||||
} else if (
|
||||
isObject(redactedObject[property]) &&
|
||||
!(redactedObject[property] instanceof Big)
|
||||
) {
|
||||
// Recursively call the function on the nested object
|
||||
redactedObject[property] = redactAttributes({
|
||||
options,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import * as bodyParser from 'body-parser';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
@ -33,6 +34,9 @@ async function bootstrap() {
|
||||
})
|
||||
);
|
||||
|
||||
// Support 10mb csv/json files for importing activities
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
|
||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||
const PORT = configService.get<number>('PORT') || 3333;
|
||||
await app.listen(PORT, HOST, () => {
|
||||
|
@ -19,12 +19,11 @@ export class ConfigurationService {
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({
|
||||
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
|
||||
default: [DataSource.MANUAL, DataSource.YAHOO]
|
||||
}),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||
ENABLE_FEATURE_IMPORT: bool({ default: true }),
|
||||
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||
|
@ -207,10 +207,6 @@ export class DataGatheringService {
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||
if (dataSource === 'MANUAL') {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.addJobToQueue(
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
{
|
||||
@ -253,11 +249,6 @@ export class DataGatheringService {
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
dataSource: {
|
||||
not: 'MANUAL'
|
||||
}
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
@ -278,7 +269,6 @@ export class DataGatheringService {
|
||||
return symbolProfiles
|
||||
.filter(({ dataSource }) => {
|
||||
return (
|
||||
dataSource !== DataSource.GHOSTFOLIO &&
|
||||
dataSource !== DataSource.MANUAL &&
|
||||
dataSource !== DataSource.RAPID_API
|
||||
);
|
||||
@ -300,11 +290,6 @@ export class DataGatheringService {
|
||||
dataSource: true,
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
dataSource: {
|
||||
not: 'MANUAL'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -0,0 +1,200 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format, fromUnixTime, getUnixTime } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class CoinGeckoService implements DataProviderInterface {
|
||||
private baseCurrency: string;
|
||||
private readonly URL = 'https://api.coingecko.com/api/v3';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
const response: Partial<SymbolProfile> = {
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: this.baseCurrency,
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
|
||||
try {
|
||||
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200);
|
||||
const { name } = await get();
|
||||
|
||||
response.name = name;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'CoinGeckoService');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
try {
|
||||
const get = bent(
|
||||
`${
|
||||
this.URL
|
||||
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
|
||||
from
|
||||
)}&to=${getUnixTime(to)}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const { prices } = await get();
|
||||
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {
|
||||
[aSymbol]: {}
|
||||
};
|
||||
|
||||
for (const [timestamp, marketPrice] of prices) {
|
||||
result[aSymbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
|
||||
marketPrice
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||
from,
|
||||
DATE_FORMAT
|
||||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public getMaxNumberOfSymbolsPerRequest() {
|
||||
return 50;
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.COINGECKO;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const results: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/simple/price?ids=${aSymbols.join(
|
||||
','
|
||||
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const response = await get();
|
||||
|
||||
for (const symbol in response) {
|
||||
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
|
||||
results[symbol] = {
|
||||
currency: this.baseCurrency,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
dataSource: DataSource.COINGECKO,
|
||||
marketPrice: response[symbol][this.baseCurrency.toLowerCase()],
|
||||
marketState: 'open'
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'CoinGeckoService');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
if (aQuery.length <= 2) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/search?query=${aQuery}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
const { coins } = await get();
|
||||
|
||||
items = coins.map(({ id: symbol, name }) => {
|
||||
return {
|
||||
name,
|
||||
symbol,
|
||||
currency: this.baseCurrency,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error, 'CoinGeckoService');
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
name: 'CoinGecko',
|
||||
url: 'https://coingecko.com'
|
||||
};
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
|
||||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||
@ -22,9 +22,9 @@ import { DataProviderService } from './data-provider.service';
|
||||
],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CoinGeckoService,
|
||||
DataProviderService,
|
||||
EodHistoricalDataService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RapidApiService,
|
||||
@ -32,8 +32,8 @@ import { DataProviderService } from './data-provider.service';
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
CoinGeckoService,
|
||||
EodHistoricalDataService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RapidApiService,
|
||||
@ -42,16 +42,16 @@ import { DataProviderService } from './data-provider.service';
|
||||
provide: 'DataProviderInterfaces',
|
||||
useFactory: (
|
||||
alphaVantageService,
|
||||
coinGeckoService,
|
||||
eodHistoricalDataService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
coinGeckoService,
|
||||
eodHistoricalDataService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rapidApiService,
|
||||
@ -59,10 +59,6 @@ import { DataProviderService } from './data-provider.service';
|
||||
]
|
||||
}
|
||||
],
|
||||
exports: [
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
exports: [DataProviderService, YahooFinanceService]
|
||||
})
|
||||
export class DataProviderModule {}
|
||||
|
@ -1,194 +0,0 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
extractNumberFromString,
|
||||
getYesterday
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { addDays, format, isBefore } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName()
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
try {
|
||||
const symbol = aSymbol;
|
||||
|
||||
const [symbolProfile] =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
|
||||
const { defaultMarketPrice, selector, url } =
|
||||
symbolProfile.scraperConfiguration;
|
||||
|
||||
if (defaultMarketPrice) {
|
||||
const historical: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {
|
||||
[symbol]: {}
|
||||
};
|
||||
let date = from;
|
||||
|
||||
while (isBefore(date, to)) {
|
||||
historical[symbol][format(date, DATE_FORMAT)] = {
|
||||
marketPrice: defaultMarketPrice
|
||||
};
|
||||
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
|
||||
return historical;
|
||||
} else if (selector === undefined || url === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const get = bent(url, 'GET', 'string', 200, {});
|
||||
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const value = extractNumberFromString($(selector).text());
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
marketPrice: value
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||
from,
|
||||
DATE_FORMAT
|
||||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.GHOSTFOLIO;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
if (aSymbols.length <= 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
||||
|
||||
const marketData = await this.prismaService.marketData.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
take: aSymbols.length,
|
||||
where: {
|
||||
symbol: {
|
||||
in: aSymbols
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const symbolProfile of symbolProfiles) {
|
||||
response[symbolProfile.symbol] = {
|
||||
currency: symbolProfile.currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbolProfile.symbol;
|
||||
}).marketPrice,
|
||||
marketState: 'delayed'
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GhostfolioScraperApiService');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
return { items };
|
||||
}
|
||||
}
|
@ -6,9 +6,17 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
extractNumberFromString,
|
||||
getYesterday
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { addDays, format, isBefore } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class ManualService implements DataProviderInterface {
|
||||
@ -18,7 +26,7 @@ export class ManualService implements DataProviderInterface {
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
@ -51,7 +59,57 @@ export class ManualService implements DataProviderInterface {
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
return {};
|
||||
try {
|
||||
const symbol = aSymbol;
|
||||
|
||||
const [symbolProfile] =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
|
||||
const { defaultMarketPrice, selector, url } =
|
||||
symbolProfile.scraperConfiguration ?? {};
|
||||
|
||||
if (defaultMarketPrice) {
|
||||
const historical: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {
|
||||
[symbol]: {}
|
||||
};
|
||||
let date = from;
|
||||
|
||||
while (isBefore(date, to)) {
|
||||
historical[symbol][format(date, DATE_FORMAT)] = {
|
||||
marketPrice: defaultMarketPrice
|
||||
};
|
||||
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
|
||||
return historical;
|
||||
} else if (selector === undefined || url === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const get = bent(url, 'GET', 'string', 200, {});
|
||||
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const value = extractNumberFromString($(selector).text());
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
marketPrice: value
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||
from,
|
||||
DATE_FORMAT
|
||||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
@ -88,10 +146,9 @@ export class ManualService implements DataProviderInterface {
|
||||
response[symbolProfile.symbol] = {
|
||||
currency: symbolProfile.currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice:
|
||||
marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbolProfile.symbol;
|
||||
})?.marketPrice ?? 0,
|
||||
marketPrice: marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbolProfile.symbol;
|
||||
})?.marketPrice,
|
||||
marketState: 'delayed'
|
||||
};
|
||||
}
|
||||
|
@ -441,8 +441,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
let name = longName;
|
||||
|
||||
if (name) {
|
||||
name = name.replace('Amundi Index Solutions - ', '');
|
||||
name = name.replace('iShares ETF (CH) - ', '');
|
||||
name = name.replace('iShares III Public Limited Company - ', '');
|
||||
name = name.replace('iShares V PLC - ', '');
|
||||
name = name.replace('iShares VI Public Limited Company - ', '');
|
||||
name = name.replace('iShares VII PLC - ', '');
|
||||
name = name.replace('Multi Units Luxembourg - ', '');
|
||||
|
@ -10,7 +10,6 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ENABLE_FEATURE_BLOG: boolean;
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
ENABLE_FEATURE_IMPORT: boolean;
|
||||
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||
ENABLE_FEATURE_STATISTICS: boolean;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { MarketState } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Account,
|
||||
@ -28,6 +28,7 @@ export interface IDataProviderHistoricalResponse {
|
||||
|
||||
export interface IDataProviderResponse {
|
||||
currency: string;
|
||||
dataProviderInfo?: DataProviderInfo;
|
||||
dataSource: DataSource;
|
||||
marketPrice: number;
|
||||
marketState: MarketState;
|
||||
|
@ -123,6 +123,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
||||
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2023/02/ghostfolio-meets-umbrel',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
|
||||
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
|
@ -104,7 +104,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.tokenStorageService.signOut();
|
||||
this.userService.remove();
|
||||
|
||||
document.location.href = '/';
|
||||
document.location.href = `/${document.documentElement.lang}`;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -40,7 +40,6 @@
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="true"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToImportActivities]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="false"
|
||||
|
@ -1,6 +1,6 @@
|
||||
<form class="d-flex flex-column h-100">
|
||||
<h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div class="flex-grow-1 pt-3" mat-dialog-content>
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Date</mat-label>
|
||||
@ -11,7 +11,7 @@
|
||||
[matDatepicker]="date"
|
||||
[(ngModel)]="data.date"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="date">
|
||||
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
|
||||
<ion-icon
|
||||
class="text-muted"
|
||||
matDatepickerToggleIcon
|
||||
@ -21,7 +21,7 @@
|
||||
<mat-datepicker #date disabled="true"></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<div class="align-items-start d-flex">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Market Price</mat-label>
|
||||
<input
|
||||
@ -30,16 +30,16 @@
|
||||
type="number"
|
||||
[(ngModel)]="data.marketPrice"
|
||||
/>
|
||||
<span class="ml-2" matSuffix>{{ data.currency }}</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
title="Fetch market price"
|
||||
(click)="onFetchSymbolForDate()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
</button>
|
||||
<span class="ml-2" matTextSuffix>{{ data.currency }}</span>
|
||||
</mat-form-field>
|
||||
<button
|
||||
class="apply-current-market-price ml-2 no-min-width"
|
||||
mat-button
|
||||
title="Fetch market price"
|
||||
(click)="onFetchSymbolForDate()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="justify-content-end" mat-dialog-actions>
|
||||
|
@ -2,10 +2,10 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
|
||||
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
||||
|
||||
|
@ -4,19 +4,9 @@
|
||||
.mat-dialog-content {
|
||||
max-height: unset;
|
||||
|
||||
.mat-form-field-appearance-outline {
|
||||
::ng-deep {
|
||||
.mat-form-field-suffix {
|
||||
top: -0.3rem;
|
||||
}
|
||||
|
||||
.mat-form-field-wrapper {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 130%;
|
||||
.mat-mdc-button {
|
||||
&.apply-current-market-price {
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './admin-overview.html'
|
||||
})
|
||||
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public couponDuration: StringValue = '30 days';
|
||||
public couponDuration: StringValue = '14 days';
|
||||
public coupons: Coupon[];
|
||||
public customCurrencies: string[];
|
||||
public exchangeRates: { label1: string; label2: string; value: number }[];
|
||||
|
@ -151,16 +151,23 @@
|
||||
>
|
||||
<div class="w-50" i18n>Coupons</div>
|
||||
<div class="w-50">
|
||||
<div *ngFor="let coupon of coupons">
|
||||
<span>{{ coupon.code }} ({{ coupon.duration }})</span>
|
||||
<button
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
(click)="onDeleteCoupon(coupon.code)"
|
||||
>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<tr *ngFor="let coupon of coupons">
|
||||
<td class="text-monospace">{{ coupon.code }}</td>
|
||||
<td class="d-flex justify-content-end pl-2">
|
||||
{{ coupon.duration }}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
(click)="onDeleteCoupon(coupon.code)"
|
||||
>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="mt-2">
|
||||
<form #couponForm="ngForm" class="align-items-center d-flex">
|
||||
<mat-form-field
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { AdminData, User } from '@ghostfolio/common/interfaces';
|
||||
import { getEmojiFlag } from '@ghostfolio/common/helper';
|
||||
import { AdminData, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import {
|
||||
differenceInSeconds,
|
||||
formatDistanceToNowStrict,
|
||||
@ -16,6 +18,9 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './admin-users.html'
|
||||
})
|
||||
export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
public getEmojiFlag = getEmojiFlag;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public info: InfoItem;
|
||||
public user: User;
|
||||
public users: AdminData['users'];
|
||||
|
||||
@ -26,6 +31,13 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
|
@ -7,7 +7,13 @@
|
||||
<tr class="mat-header-row">
|
||||
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||
<th class="mat-header-cell px-1 py-2 text-right">
|
||||
<th
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-header-cell px-1 py-2"
|
||||
>
|
||||
<ng-container i18n>Country</ng-container>
|
||||
</th>
|
||||
<th class="mat-header-cell px-1 py-2">
|
||||
<ng-container i18n>Registration</ng-container>
|
||||
</th>
|
||||
<th class="mat-header-cell px-1 py-2 text-right">
|
||||
@ -16,7 +22,10 @@
|
||||
<th class="mat-header-cell px-1 py-2 text-right">
|
||||
<ng-container i18n>Activities</ng-container>
|
||||
</th>
|
||||
<th class="mat-header-cell px-1 py-2 text-right">
|
||||
<th
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-header-cell px-1 py-2 text-right"
|
||||
>
|
||||
<ng-container i18n>Engagement per Day</ng-container>
|
||||
</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
|
||||
@ -28,10 +37,10 @@
|
||||
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="d-none d-sm-inline-block"
|
||||
<span class="d-none d-sm-inline-block text-monospace"
|
||||
>{{ userItem.id }}</span
|
||||
>
|
||||
<span class="d-inline-block d-sm-none"
|
||||
<span class="d-inline-block d-sm-none text-monospace"
|
||||
>{{ (userItem.id | slice:0:5) + '...' }}</span
|
||||
>
|
||||
<gf-premium-indicator
|
||||
@ -41,7 +50,15 @@
|
||||
></gf-premium-indicator>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
<td
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-cell px-1 py-2"
|
||||
>
|
||||
<span class="h5" [title]="userItem.country"
|
||||
>{{ getEmojiFlag(userItem.country) }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
{{ formatDistanceToNow(userItem.createdAt) }}
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
@ -58,7 +75,10 @@
|
||||
[value]="userItem.transactionCount"
|
||||
></gf-value>
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
<td
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="mat-cell px-1 py-2 text-right"
|
||||
>
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
|
@ -27,6 +27,7 @@ import { ColorScheme } from '@ghostfolio/common/types';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
Chart,
|
||||
ChartData,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
@ -57,7 +58,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
public chart: Chart<any>;
|
||||
public chart: Chart<'line'>;
|
||||
|
||||
public constructor() {
|
||||
Chart.register(
|
||||
@ -89,14 +90,14 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
const data = {
|
||||
const data: ChartData<'line'> = {
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
borderWidth: 2,
|
||||
data: this.performanceDataItems.map(({ date, value }) => {
|
||||
return { x: parseDate(date), y: value };
|
||||
return { x: parseDate(date).getTime(), y: value };
|
||||
}),
|
||||
label: $localize`Portfolio`
|
||||
},
|
||||
@ -105,7 +106,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
borderWidth: 2,
|
||||
data: this.benchmarkDataItems.map(({ date, value }) => {
|
||||
return { x: parseDate(date), y: value };
|
||||
return { x: parseDate(date).getTime(), y: value };
|
||||
}),
|
||||
label: $localize`Benchmark`
|
||||
}
|
||||
|
@ -110,11 +110,7 @@
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<ng-container *ngIf="user?.access?.length > 0">
|
||||
<button
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="impersonateAccount(null)"
|
||||
>
|
||||
<button mat-menu-item (click)="impersonateAccount(null)">
|
||||
<ion-icon
|
||||
*ngIf="user?.access?.length > 0"
|
||||
class="mr-2"
|
||||
@ -128,7 +124,6 @@
|
||||
</button>
|
||||
<button
|
||||
*ngFor="let accessItem of user?.access"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="impersonateAccount(accessItem.id)"
|
||||
>
|
||||
@ -147,7 +142,7 @@
|
||||
<hr class="m-0" />
|
||||
</ng-container>
|
||||
<a
|
||||
class="d-block d-sm-none"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
@ -157,7 +152,7 @@
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
class="d-block d-sm-none"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
@ -167,7 +162,7 @@
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
class="d-block d-sm-none"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||
@ -175,7 +170,6 @@
|
||||
>Accounts</a
|
||||
>
|
||||
<a
|
||||
class="align-items-center d-flex"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||
@ -184,7 +178,7 @@
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
class="d-block d-sm-none"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||
@ -193,7 +187,7 @@
|
||||
>
|
||||
<hr class="m-0" />
|
||||
<a
|
||||
class="d-block d-sm-none"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
@ -206,7 +200,7 @@
|
||||
*ngIf="
|
||||
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||
"
|
||||
class="d-block d-sm-none"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
||||
@ -214,14 +208,14 @@
|
||||
>Pricing</a
|
||||
>
|
||||
<a
|
||||
class="d-block d-sm-none"
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||
[routerLink]="['/about']"
|
||||
>About Ghostfolio</a
|
||||
>
|
||||
<hr class="d-block d-sm-none m-0" />
|
||||
<hr class="d-flex d-sm-none m-0" />
|
||||
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
||||
</mat-menu>
|
||||
</ng-container>
|
||||
@ -283,9 +277,9 @@
|
||||
>Markets</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1 no-min-width px-1"
|
||||
class="d-none d-sm-block no-min-width"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
mat-flat-button
|
||||
mat-icon-button
|
||||
><ion-icon name="logo-github"></ion-icon
|
||||
></a>
|
||||
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||
|
@ -11,7 +11,9 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.mat-flat-button {
|
||||
.mdc-button {
|
||||
height: unset;
|
||||
|
||||
&:not(.mat-primary) {
|
||||
background-color: transparent;
|
||||
text-decoration-color: rgba(var(--palette-primary-500), 1) !important;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||
|
@ -29,6 +29,7 @@ import {
|
||||
BarController,
|
||||
BarElement,
|
||||
Chart,
|
||||
ChartData,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
@ -62,7 +63,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
public chart: Chart<any>;
|
||||
public chart: Chart<'bar' | 'line'>;
|
||||
private investments: InvestmentItem[];
|
||||
private values: LineChartItem[];
|
||||
|
||||
@ -142,7 +143,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
const chartData: ChartData<'line'> = {
|
||||
labels: this.historicalDataItems.map(({ date }) => {
|
||||
return parseDate(date);
|
||||
}),
|
||||
@ -153,7 +154,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
borderWidth: this.groupBy ? 0 : 1,
|
||||
data: this.investments.map(({ date, investment }) => {
|
||||
return {
|
||||
x: parseDate(date),
|
||||
x: parseDate(date).getTime(),
|
||||
y: this.isInPercent ? investment * 100 : investment
|
||||
};
|
||||
}),
|
||||
@ -173,7 +174,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
borderWidth: 2,
|
||||
data: this.values.map(({ date, value }) => {
|
||||
return {
|
||||
x: parseDate(date),
|
||||
x: parseDate(date).getTime(),
|
||||
y: this.isInPercent ? value * 100 : value
|
||||
};
|
||||
}),
|
||||
|
@ -50,7 +50,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
|
||||
public onEditEmergencyFund() {
|
||||
const emergencyFundInput = prompt(
|
||||
$localize`Please enter the amount of your emergency fund:`,
|
||||
this.summary.emergencyFund.toString()
|
||||
this.summary.emergencyFund?.toString() ?? '0'
|
||||
);
|
||||
const emergencyFund = parseFloat(emergencyFundInput?.trim());
|
||||
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
EnhancedSymbolProfile,
|
||||
LineChartItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -40,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
public dataProviderInfo: DataProviderInfo;
|
||||
public dividendInBaseCurrency: number;
|
||||
public feeInBaseCurrency: number;
|
||||
public firstBuyDate: string;
|
||||
@ -83,6 +85,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
.subscribe(
|
||||
({
|
||||
averagePrice,
|
||||
dataProviderInfo,
|
||||
dividendInBaseCurrency,
|
||||
feeInBaseCurrency,
|
||||
firstBuyDate,
|
||||
@ -105,6 +108,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
this.averagePrice = averagePrice;
|
||||
this.benchmarkDataItems = [];
|
||||
this.countries = {};
|
||||
this.dataProviderInfo = dataProviderInfo;
|
||||
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
||||
this.feeInBaseCurrency = feeInBaseCurrency;
|
||||
this.firstBuyDate = firstBuyDate;
|
||||
|
@ -227,6 +227,12 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center">
|
||||
<hr />
|
||||
<gf-data-provider-credits [dataProviderInfos]="[dataProviderInfo]">
|
||||
</gf-data-provider-credits>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
||||
@ -239,7 +245,6 @@
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="true"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToImportActivities]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
[showActions]="false"
|
||||
@ -250,7 +255,7 @@
|
||||
</div>
|
||||
|
||||
<div *ngIf="tags?.length > 0" class="row">
|
||||
<div class="col mb-3">
|
||||
<div class="col">
|
||||
<div class="h5" i18n>Tags</div>
|
||||
<mat-chip-list>
|
||||
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
|
||||
@ -262,7 +267,7 @@
|
||||
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
|
||||
class="row"
|
||||
>
|
||||
<div class="col mb-3">
|
||||
<div class="col">
|
||||
<hr />
|
||||
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
|
||||
><ion-icon class="mr-1" name="flag-outline"></ion-icon
|
||||
|
@ -6,6 +6,7 @@ import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/lega
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
@ -18,6 +19,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfDataProviderCreditsModule,
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfLineChartModule,
|
||||
|
@ -19,7 +19,7 @@ export class SubscriptionInterstitialDialog {
|
||||
public dialogRef: MatDialogRef<SubscriptionInterstitialDialog>
|
||||
) {}
|
||||
|
||||
public onCancel() {
|
||||
public closeDialog() {
|
||||
this.dialogRef.close({});
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
<h1 class="align-items-center d-flex" mat-dialog-title>
|
||||
<span>Ghostfolio Premium</span>
|
||||
<gf-premium-indicator class="ml-1"></gf-premium-indicator>
|
||||
<gf-premium-indicator
|
||||
class="ml-1"
|
||||
[enableLink]="false"
|
||||
></gf-premium-indicator>
|
||||
</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<p class="h5" i18n>
|
||||
@ -28,14 +31,19 @@
|
||||
</li>
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||
<a i18n [routerLink]="['/features']">and more Features...</a>
|
||||
<span i18n>and more Features...</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p>Refine your personal investment strategy now.</p>
|
||||
</div>
|
||||
<div class="justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button (click)="onCancel()">Skip</button>
|
||||
<a color="primary" mat-flat-button [routerLink]="['/pricing']">
|
||||
<button i18n mat-button (click)="closeDialog()">Skip</button>
|
||||
<a
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/pricing']"
|
||||
(click)="closeDialog()"
|
||||
>
|
||||
<span i18n>Upgrade Plan</span>
|
||||
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
|
||||
</a>
|
||||
|
@ -48,7 +48,7 @@
|
||||
>hi@ghostfol.io</a
|
||||
></ng-container
|
||||
>
|
||||
or open an issue at
|
||||
or start a discussion at
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
title="Find Ghostfolio on GitHub"
|
||||
@ -62,7 +62,7 @@
|
||||
mat-icon-button
|
||||
title="Follow Ghostfolio on Twitter"
|
||||
>
|
||||
<ion-icon name="logo-twitter" size="large"></ion-icon>
|
||||
<ion-icon name="logo-twitter"></ion-icon>
|
||||
</a>
|
||||
<a
|
||||
class="mx-2"
|
||||
@ -70,7 +70,7 @@
|
||||
mat-icon-button
|
||||
title="Send an e-mail"
|
||||
>
|
||||
<ion-icon name="mail" size="large"></ion-icon>
|
||||
<ion-icon name="mail"></ion-icon>
|
||||
</a>
|
||||
<a
|
||||
class="mx-2"
|
||||
@ -78,7 +78,7 @@
|
||||
mat-icon-button
|
||||
title="Join the Ghostfolio Slack channel"
|
||||
>
|
||||
<ion-icon name="logo-slack" size="large"></ion-icon>
|
||||
<ion-icon name="logo-slack"></ion-icon>
|
||||
</a>
|
||||
<a
|
||||
class="mx-2"
|
||||
@ -86,7 +86,7 @@
|
||||
mat-icon-button
|
||||
title="Find Ghostfolio on GitHub"
|
||||
>
|
||||
<ion-icon name="logo-github" size="large"></ion-icon>
|
||||
<ion-icon name="logo-github"></ion-icon>
|
||||
</a>
|
||||
</p>
|
||||
<div
|
||||
@ -119,7 +119,7 @@
|
||||
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center">Ghostfolio in Numbers</h3>
|
||||
<mat-card>
|
||||
<mat-card appearance="outlined">
|
||||
<mat-card-content>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
@ -191,7 +191,7 @@
|
||||
<div class="row">
|
||||
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
class="py-4 w-100"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/faq']"
|
||||
@ -203,7 +203,7 @@
|
||||
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
|
||||
>
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
class="py-4 w-100"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/about', 'changelog']"
|
||||
@ -212,7 +212,7 @@
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
class="py-4 w-100"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/about', 'privacy-policy']"
|
||||
@ -221,7 +221,7 @@
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
class="py-4 w-100"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
[routerLink]="['/blog']"
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { AboutPageRoutingModule } from './about-page-routing.module';
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
||||
<mat-card class="changelog">
|
||||
<mat-card appearance="outlined" class="changelog">
|
||||
<mat-card-content>
|
||||
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
|
||||
</mat-card-content>
|
||||
@ -13,7 +13,7 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
||||
<mat-card>
|
||||
<mat-card appearance="outlined">
|
||||
<mat-card-content>
|
||||
<markdown [src]="'../assets/LICENSE'"></markdown>
|
||||
</mat-card-content>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
|
||||
import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
|
||||
|
@ -2,20 +2,18 @@
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
.mat-card {
|
||||
&.changelog {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-card {
|
||||
&.changelog {
|
||||
::ng-deep {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
|
||||
markdown {
|
||||
h1,
|
||||
p {
|
||||
|
@ -257,7 +257,7 @@
|
||||
</div>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50" i18n>User ID</div>
|
||||
<div class="pl-1 w-50">{{ user?.id }}</div>
|
||||
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
@ -30,7 +30,7 @@
|
||||
[queryParams]="{ createDialog: true }"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
<ion-icon class="mt-2" name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfAccountDetailDialogModule } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.module';
|
||||
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
|
||||
|
@ -1,15 +1,11 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
||||
import { MatLegacyTabsModule as MatTabsModule } from '@angular/material/legacy-tabs';
|
||||
import { GfAdminJobsModule } from '@ghostfolio/client/components/admin-jobs/admin-jobs.module';
|
||||
import { GfAdminMarketDataModule } from '@ghostfolio/client/components/admin-market-data/admin-market-data.module';
|
||||
import { GfAdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module';
|
||||
import { GfAdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { AdminPageRoutingModule } from './admin-page-routing.module';
|
||||
import { AdminPageComponent } from './admin-page.component';
|
||||
@ -24,10 +20,6 @@ import { AdminPageComponent } from './admin-page.component';
|
||||
GfAdminMarketDataModule,
|
||||
GfAdminOverviewModule,
|
||||
GfAdminUsersModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatMenuModule,
|
||||
MatTabsModule
|
||||
],
|
||||
providers: [CacheService],
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { TheImportanceOfTrackingYourPersonalFinancesRoutingModule } from './the-importance-of-tracking-your-personal-finances-page-routing.module';
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { GhostfolioMeetsUmbrelPageComponent } from './ghostfolio-meets-umbrel-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: GhostfolioMeetsUmbrelPageComponent,
|
||||
path: '',
|
||||
title: 'Ghostfolio meets Umbrel'
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class GhostfolioMeetsUmbrelPageRoutingModule {}
|
@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-ghostfolio-meets-umbrel-page',
|
||||
styleUrls: ['./ghostfolio-meets-umbrel-page.scss'],
|
||||
templateUrl: './ghostfolio-meets-umbrel-page.html'
|
||||
})
|
||||
export class GhostfolioMeetsUmbrelPageComponent {}
|
@ -0,0 +1,200 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1">Ghostfolio meets Umbrel</h1>
|
||||
<div class="mb-3 text-muted"><small>2023-02-25</small></div>
|
||||
<img
|
||||
alt="Ghostfolio meets Umbrel Teaser"
|
||||
class="border rounded w-100"
|
||||
src="../assets/images/blog/ghostfolio-x-umbrel.png"
|
||||
title="Ghostfolio meets Umbrel"
|
||||
/>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
We are happy to announce that
|
||||
<a href="https://ghostfol.io">Ghostfolio</a>, the web-based personal
|
||||
finance management software, is now available in the
|
||||
<a href="https://umbrel.com" target="_blank">Umbrel</a> App Store, a
|
||||
home server OS for self-hosting.
|
||||
</p>
|
||||
<p>
|
||||
In recent years, we have seen an increasing number of individuals
|
||||
and organizations moving their data to the cloud. While cloud
|
||||
computing has its benefits, such as accessibility and scalability,
|
||||
it also comes with some concerns regarding data privacy and
|
||||
security. However, there is an alternative to cloud computing that
|
||||
provides the convenience of the cloud while giving you ownership and
|
||||
control of your data: personal servers.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Umbrel – A personal server OS for self-hosting</h2>
|
||||
<p>
|
||||
<a href="https://github.com/getumbrel/umbrel" target="_blank"
|
||||
>Umbrel</a
|
||||
>
|
||||
is an operating system based on
|
||||
<a href="https://www.docker.com" target="_blank">Docker</a> that
|
||||
allows you to run a personal server in your home. With it, you can
|
||||
self-host open source apps directly from an integrated app store.
|
||||
This means that you can discover self-hosted apps directly in the
|
||||
<a href="https://github.com/getumbrel/umbrel-apps" target="_blank"
|
||||
>Umbrel App Store</a
|
||||
>
|
||||
and install them in one click. You can get up and running Umbrel on
|
||||
a Raspberry Pi 4, any Ubuntu / Debian system, or a VPS in only 5
|
||||
minutes.
|
||||
</p>
|
||||
<p>
|
||||
Umbrel offers numerous advantages for running a personal server in
|
||||
your home, such as enhanced data privacy and security, ownership and
|
||||
control of your data, and access to a diverse selection of
|
||||
self-hosted apps.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">
|
||||
Ghostfolio – Track your portfolio without being tracked
|
||||
</h2>
|
||||
<p>
|
||||
Keeping track of multiple assets can make managing your personal
|
||||
finance a challenging task. However, there are tools available
|
||||
beyond spreadsheets that can help you streamline the process and
|
||||
make well-informed investment decisions based on data.
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://github.com/ghostfolio/ghostfolio" target="_blank"
|
||||
>Ghostfolio</a
|
||||
>
|
||||
is a modern open source web application designed to manage your
|
||||
personal finance with ease and confidence. It presents your current
|
||||
assets in real-time, including stocks, ETFs, cryptocurrencies,
|
||||
commodities, and more. It allows you to track and analyze your
|
||||
investments in one place.
|
||||
</p>
|
||||
<p>
|
||||
The application has a range of features such as real-time asset
|
||||
tracking, data import and export and advanced portfolio analytics
|
||||
tools.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
To participate in the ongoing development of Ghostfolio, please feel
|
||||
free to reach out to us on our
|
||||
<a href="https://ghostfolio.slack.com" target="_blank"
|
||||
>Slack channel</a
|
||||
>
|
||||
or via Twitter
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
>@ghostfolio_</a
|
||||
>. We look forward to hearing from you!
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Announcement</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App Store</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Assets</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cloud</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Commodity</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cryptocurrency</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Debian</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Development</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Docker</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">ETF</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ghostfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Home Server</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investing</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Linux</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Open Source</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Operating System</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">OS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">OSS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Personal Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Personal Server</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Privacy</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Raspberry Pi</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Security</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Spreadsheet</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Stocks</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ubuntu</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Umbrel</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">VPS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,13 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { GhostfolioMeetsUmbrelPageRoutingModule } from './ghostfolio-meets-umbrel-page-routing.module';
|
||||
import { GhostfolioMeetsUmbrelPageComponent } from './ghostfolio-meets-umbrel-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [GhostfolioMeetsUmbrelPageComponent],
|
||||
imports: [CommonModule, GhostfolioMeetsUmbrelPageRoutingModule, RouterModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GhostfolioMeetsUmbrelPageModule {}
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -2,7 +2,33 @@
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2023/02/ghostfolio-meets-umbrel"
|
||||
>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
Ghostfolio meets Umbrel
|
||||
</div>
|
||||
<div class="d-flex text-muted">2023-02-25</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -28,7 +54,7 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -54,7 +80,11 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card *ngIf="hasPermissionForSubscription" class="mb-3">
|
||||
<mat-card
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
appearance="outlined"
|
||||
class="mb-3"
|
||||
>
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -78,7 +108,7 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -102,7 +132,7 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -126,7 +156,7 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -152,7 +182,7 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -178,7 +208,7 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -204,7 +234,7 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
@ -228,7 +258,7 @@
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
|
||||
import { BlogPageRoutingModule } from './blog-page-routing.module';
|
||||
import { BlogPageComponent } from './blog-page.component';
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
@ -8,9 +10,26 @@ import { Subject } from 'rxjs';
|
||||
templateUrl: './faq-page.html'
|
||||
})
|
||||
export class FaqPageComponent implements OnDestroy {
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor() {}
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
|
@ -115,7 +115,7 @@
|
||||
>.</mat-card-content
|
||||
>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card *ngIf="user?.subscription?.type === 'Premium'" class="mb-3">
|
||||
<mat-card-title
|
||||
>I cannot find my broker in the list of platforms. What can I
|
||||
do?</mat-card-title
|
||||
|
@ -52,8 +52,11 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService
|
||||
) {
|
||||
const { countriesOfSubscribers, globalPermissions, statistics } =
|
||||
this.dataService.fetchInfo();
|
||||
const {
|
||||
countriesOfSubscribers = [],
|
||||
globalPermissions,
|
||||
statistics
|
||||
} = this.dataService.fetchInfo();
|
||||
|
||||
for (const country of countriesOfSubscribers) {
|
||||
this.countriesOfSubscribersMap[country] = {
|
||||
|
@ -52,6 +52,7 @@
|
||||
|
||||
<div *ngIf="hasPermissionForStatistics" class="row mb-5">
|
||||
<div
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
@ -68,6 +69,24 @@
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!hasPermissionForSubscription"
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Contributors on GitHub"
|
||||
[routerLink]="['/about']"
|
||||
>
|
||||
<gf-value
|
||||
icon="people-outline"
|
||||
size="large"
|
||||
[value]="statistics?.gitHubContributors ?? '-'"
|
||||
>Contributors on GitHub</gf-value
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
@ -146,6 +165,14 @@
|
||||
title="Product Hunt – The best new products in tech."
|
||||
></a>
|
||||
</div>
|
||||
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-reddit mask"
|
||||
href="https://www.reddit.com"
|
||||
target="_blank"
|
||||
title="Reddit - Dive into anything"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-sackgeld mask"
|
||||
@ -162,6 +189,14 @@
|
||||
title="SourceForge: The Complete Open-Source and Business Software Platform"
|
||||
></a>
|
||||
</div>
|
||||
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-umbrel mask"
|
||||
href="https://umbrel.com"
|
||||
target="_blank"
|
||||
title="Umbrel — A personal server OS for self-hosting"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-unraid mask"
|
||||
@ -300,7 +335,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row my-3">
|
||||
<div *ngIf="hasPermissionForSubscription" class="row my-3">
|
||||
<div class="col-12">
|
||||
<h2 class="h4 mb-1 text-center">
|
||||
How does <strong>Ghostfolio</strong> work?
|
||||
@ -357,15 +392,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="downloads my-5 row justify-content-center">
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
|
||||
title="Get Ghostfolio on Google Play"
|
||||
>
|
||||
<img alt="Google Play Badge" src="../assets/badge-en-google-play.png" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
@ -13,12 +13,6 @@
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.downloads {
|
||||
img {
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 4vw;
|
||||
line-height: 1;
|
||||
@ -75,6 +69,11 @@
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
&.logo-reddit {
|
||||
mask-image: url('/assets/images/logo-reddit.svg');
|
||||
max-height: 1rem;
|
||||
}
|
||||
|
||||
&.logo-sackgeld {
|
||||
mask-image: url('/assets/images/logo-sackgeld.png');
|
||||
}
|
||||
@ -83,6 +82,11 @@
|
||||
mask-image: url('/assets/images/logo-sourceforge.svg');
|
||||
}
|
||||
|
||||
&.logo-umbrel {
|
||||
mask-image: url('/assets/images/logo-umbrel.svg');
|
||||
max-height: 1.5rem;
|
||||
}
|
||||
|
||||
&.logo-unraid {
|
||||
mask-image: url('/assets/images/logo-unraid.svg');
|
||||
}
|
||||
@ -120,8 +124,10 @@
|
||||
&.logo-agplv3,
|
||||
&.logo-alternative-to,
|
||||
&.logo-privacy-tools,
|
||||
&.logo-reddit,
|
||||
&.logo-sackgeld,
|
||||
&.logo-sourceforge,
|
||||
&.logo-umbrel,
|
||||
&.logo-unraid {
|
||||
background-color: rgba(var(--light-primary-text));
|
||||
}
|
||||
|
@ -36,7 +36,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateActivity: boolean;
|
||||
public hasPermissionToDeleteActivity: boolean;
|
||||
public hasPermissionToImportActivities: boolean;
|
||||
public routeQueryParams: Subscription;
|
||||
public user: User;
|
||||
|
||||
@ -91,10 +90,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((aId) => {
|
||||
this.hasImpersonationId = !!aId;
|
||||
|
||||
this.hasPermissionToImportActivities =
|
||||
hasPermission(globalPermissions, permissions.enableImport) &&
|
||||
!this.hasImpersonationId;
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
@ -356,13 +351,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
return account.isDefault;
|
||||
})?.id;
|
||||
|
||||
this.hasPermissionToCreateActivity = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
this.hasPermissionToDeleteActivity = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.deleteOrder
|
||||
);
|
||||
this.hasPermissionToCreateActivity =
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user.permissions, permissions.createOrder);
|
||||
this.hasPermissionToDeleteActivity =
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user.permissions, permissions.deleteOrder);
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,6 @@
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||
[hasPermissionToImportActivities]="hasPermissionToImportActivities"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
|
||||
(activityDeleted)="onDeleteActivity($event)"
|
||||
@ -33,7 +32,7 @@
|
||||
[queryParams]="{ createDialog: true }"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
<ion-icon class="mt-2" name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/legacy-snack-bar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||
import { MatLegacyAutocompleteSelectedEvent as MatAutocompleteSelectedEvent } from '@angular/material/legacy-autocomplete';
|
||||
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
|
||||
import {
|
||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||
MatLegacyDialogRef as MatDialogRef
|
||||
@ -106,6 +106,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.data.activity?.SymbolProfile?.currency,
|
||||
Validators.required
|
||||
],
|
||||
currencyOfUnitPrice: [
|
||||
this.data.activity?.SymbolProfile?.currency,
|
||||
Validators.required
|
||||
],
|
||||
dataSource: [
|
||||
this.data.activity?.SymbolProfile?.dataSource,
|
||||
Validators.required
|
||||
@ -131,16 +135,23 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
})
|
||||
],
|
||||
type: [undefined, Validators.required], // Set after value changes subscription
|
||||
unitPrice: [this.data.activity?.unitPrice, Validators.required]
|
||||
unitPrice: [this.data.activity?.unitPrice, Validators.required],
|
||||
unitPriceInCustomCurrency: [
|
||||
this.data.activity?.unitPrice,
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
this.activityForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(async () => {
|
||||
let exchangeRate = 1;
|
||||
let exchangeRateOfFee = 1;
|
||||
let exchangeRateOfUnitPrice = 1;
|
||||
|
||||
const currency = this.activityForm.controls['currency'].value;
|
||||
const currencyOfFee = this.activityForm.controls['currencyOfFee'].value;
|
||||
const currencyOfUnitPrice =
|
||||
this.activityForm.controls['currencyOfUnitPrice'].value;
|
||||
const date = this.activityForm.controls['date'].value;
|
||||
|
||||
if (currency && currencyOfFee && currency !== currencyOfFee && date) {
|
||||
@ -154,18 +165,49 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
);
|
||||
|
||||
exchangeRate = marketPrice;
|
||||
exchangeRateOfFee = marketPrice;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const feeInCustomCurrency =
|
||||
this.activityForm.controls['feeInCustomCurrency'].value *
|
||||
exchangeRate;
|
||||
exchangeRateOfFee;
|
||||
|
||||
this.activityForm.controls['fee'].setValue(feeInCustomCurrency, {
|
||||
emitEvent: false
|
||||
});
|
||||
|
||||
if (
|
||||
currency &&
|
||||
currencyOfUnitPrice &&
|
||||
currency !== currencyOfUnitPrice &&
|
||||
date
|
||||
) {
|
||||
try {
|
||||
const { marketPrice } = await lastValueFrom(
|
||||
this.dataService
|
||||
.fetchExchangeRateForDate({
|
||||
date,
|
||||
symbol: `${currencyOfUnitPrice}-${currency}`
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
);
|
||||
|
||||
exchangeRateOfUnitPrice = marketPrice;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const unitPriceInCustomCurrency =
|
||||
this.activityForm.controls['unitPriceInCustomCurrency'].value *
|
||||
exchangeRateOfUnitPrice;
|
||||
|
||||
this.activityForm.controls['unitPrice'].setValue(
|
||||
unitPriceInCustomCurrency,
|
||||
{
|
||||
emitEvent: false
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
this.activityForm.controls['type'].value === 'BUY' ||
|
||||
this.activityForm.controls['type'].value === 'ITEM'
|
||||
@ -231,6 +273,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.activityForm.controls['currencyOfFee'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['currencyOfUnitPrice'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['dataSource'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
@ -288,7 +333,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
|
||||
public applyCurrentMarketPrice() {
|
||||
this.activityForm.patchValue({
|
||||
unitPrice: this.currentMarketPrice
|
||||
currencyOfUnitPrice: this.activityForm.controls['currency'].value,
|
||||
unitPriceInCustomCurrency: this.currentMarketPrice
|
||||
});
|
||||
}
|
||||
|
||||
@ -415,6 +461,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||
this.activityForm.controls['currency'].setValue(currency);
|
||||
this.activityForm.controls['currencyOfFee'].setValue(currency);
|
||||
this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
|
||||
this.activityForm.controls['dataSource'].setValue(dataSource);
|
||||
|
||||
this.currentMarketPrice = marketPrice;
|
||||
|
@ -6,7 +6,7 @@
|
||||
>
|
||||
<h1 *ngIf="data.activity.id" i18n mat-dialog-title>Update activity</h1>
|
||||
<h1 *ngIf="!data.activity.id" i18n mat-dialog-title>Add activity</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div class="flex-grow-1 pt-3" mat-dialog-content>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
@ -54,11 +54,14 @@
|
||||
<ng-container>
|
||||
<mat-option
|
||||
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
|
||||
class="autocomplete"
|
||||
class="line-height-1"
|
||||
[value]="lookupItem"
|
||||
>
|
||||
<span class="mr-2 symbol">{{ lookupItem.symbol | gfSymbol }}</span
|
||||
><span><b>{{ lookupItem.name }}</b></span>
|
||||
<span><b>{{ lookupItem.name }}</b></span>
|
||||
<br />
|
||||
<small class="text-muted"
|
||||
>{{ lookupItem.symbol | gfSymbol }}</small
|
||||
>
|
||||
</mat-option>
|
||||
</ng-container>
|
||||
</mat-autocomplete>
|
||||
@ -76,7 +79,7 @@
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Currency</mat-label>
|
||||
<mat-select class="no-arrow" formControlName="currency">
|
||||
<mat-select formControlName="currency">
|
||||
<mat-option *ngFor="let currency of currencies" [value]="currency"
|
||||
>{{ currency }}</mat-option
|
||||
>
|
||||
@ -93,7 +96,7 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Date</mat-label>
|
||||
<input formControlName="date" matInput [matDatepicker]="date" />
|
||||
<mat-datepicker-toggle matSuffix [for]="date">
|
||||
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
|
||||
<ion-icon
|
||||
class="text-muted"
|
||||
matDatepickerToggleIcon
|
||||
@ -109,7 +112,46 @@
|
||||
<input formControlName="quantity" matInput type="number" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<div class="align-items-start d-flex">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label
|
||||
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
|
||||
<ng-container *ngSwitchCase="'DIVIDEND'" i18n
|
||||
>Dividend</ng-container
|
||||
>
|
||||
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
|
||||
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
|
||||
</ng-container>
|
||||
</mat-label>
|
||||
<input
|
||||
formControlName="unitPriceInCustomCurrency"
|
||||
matInput
|
||||
type="number"
|
||||
/>
|
||||
<div
|
||||
class="ml-2"
|
||||
matTextSuffix
|
||||
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
|
||||
>
|
||||
<mat-select formControlName="currencyOfUnitPrice">
|
||||
<mat-option *ngFor="let currency of currencies" [value]="currency">
|
||||
{{ currency }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</div>
|
||||
</mat-form-field>
|
||||
<button
|
||||
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
|
||||
class="apply-current-market-price ml-2 no-min-width"
|
||||
mat-button
|
||||
title="Apply current market price"
|
||||
type="button"
|
||||
(click)="applyCurrentMarketPrice()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-none">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label
|
||||
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
|
||||
@ -121,19 +163,9 @@
|
||||
</ng-container>
|
||||
</mat-label>
|
||||
<input formControlName="unitPrice" matInput type="number" />
|
||||
<span class="ml-2" matSuffix
|
||||
<span class="ml-2" matTextSuffix
|
||||
>{{ activityForm.controls['currency'].value }}</span
|
||||
>
|
||||
<button
|
||||
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
title="Apply current market price"
|
||||
type="button"
|
||||
(click)="applyCurrentMarketPrice()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
@ -142,7 +174,7 @@
|
||||
<input formControlName="feeInCustomCurrency" matInput type="number" />
|
||||
<div
|
||||
class="ml-2"
|
||||
matSuffix
|
||||
matTextSuffix
|
||||
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
|
||||
>
|
||||
<mat-select formControlName="currencyOfFee">
|
||||
@ -157,7 +189,7 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Fee</mat-label>
|
||||
<input formControlName="fee" matInput type="number" />
|
||||
<span class="ml-2" matSuffix
|
||||
<span class="ml-2" matTextSuffix
|
||||
>{{ activityForm.controls['currency'].value }}</span
|
||||
>
|
||||
</mat-form-field>
|
||||
@ -207,8 +239,8 @@
|
||||
<div [ngClass]="{ 'd-none': tags?.length <= 0 }">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-chip-list #tagsChipList>
|
||||
<mat-chip
|
||||
<mat-chip-grid #tagsChipList>
|
||||
<mat-chip-row
|
||||
*ngFor="let tag of activityForm.controls['tags']?.value"
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
@ -216,7 +248,7 @@
|
||||
>
|
||||
{{ tag.name }}
|
||||
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
|
||||
</mat-chip>
|
||||
</mat-chip-row>
|
||||
<input
|
||||
#tagInput
|
||||
name="close-outline"
|
||||
@ -224,7 +256,7 @@
|
||||
[matChipInputFor]="tagsChipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
/>
|
||||
</mat-chip-list>
|
||||
</mat-chip-grid>
|
||||
<mat-autocomplete
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
|
@ -2,14 +2,14 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatLegacyAutocompleteModule as MatAutocompleteModule } from '@angular/material/legacy-autocomplete';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyChipsModule as MatChipsModule } from '@angular/material/legacy-chips';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
|
||||
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
|
||||
import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
|
||||
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
|
@ -10,55 +10,24 @@
|
||||
.mat-dialog-content {
|
||||
max-height: unset;
|
||||
|
||||
.autocomplete {
|
||||
font-size: 90%;
|
||||
height: 2.5rem;
|
||||
|
||||
.symbol {
|
||||
display: inline-block;
|
||||
min-width: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-chip {
|
||||
cursor: pointer;
|
||||
min-height: 1.5rem !important;
|
||||
}
|
||||
|
||||
.mat-form-field-appearance-outline {
|
||||
::ng-deep {
|
||||
.mat-form-field-suffix {
|
||||
top: -0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 130%;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-select {
|
||||
&.no-arrow {
|
||||
::ng-deep {
|
||||
.mat-select-arrow {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-datepicker-input {
|
||||
&.mat-input-element:disabled {
|
||||
&.mat-mdc-input-element:disabled {
|
||||
color: var(--dark-primary-text);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-button {
|
||||
&.apply-current-market-price {
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.mat-dialog-content {
|
||||
.mat-datepicker-input {
|
||||
&.mat-input-element:disabled {
|
||||
&.mat-mdc-input-element:disabled {
|
||||
color: var(--light-primary-text);
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
MatLegacyDialogRef as MatDialogRef
|
||||
} from '@angular/material/legacy-dialog';
|
||||
import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||
@ -28,6 +29,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'import-activities-dialog.html'
|
||||
})
|
||||
export class ImportActivitiesDialog implements OnDestroy {
|
||||
public accounts: CreateAccountDto[] = [];
|
||||
public activities: Activity[] = [];
|
||||
public details: any[] = [];
|
||||
public errorMessages: string[] = [];
|
||||
@ -91,9 +93,10 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
try {
|
||||
this.snackBar.open('⏳ ' + $localize`Importing data...`);
|
||||
|
||||
await this.importActivitiesService.importSelectedActivities(
|
||||
this.selectedActivities
|
||||
);
|
||||
await this.importActivitiesService.importSelectedActivities({
|
||||
accounts: this.accounts,
|
||||
activities: this.selectedActivities
|
||||
});
|
||||
|
||||
this.snackBar.open(
|
||||
'✅ ' + $localize`Import has been completed`,
|
||||
@ -163,6 +166,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
if (file.name.endsWith('.json')) {
|
||||
const content = JSON.parse(fileContent);
|
||||
|
||||
this.accounts = content.accounts;
|
||||
|
||||
if (!isArray(content.activities)) {
|
||||
if (isArray(content.orders)) {
|
||||
this.handleImportError({
|
||||
@ -180,10 +185,13 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
try {
|
||||
this.activities = await this.importActivitiesService.importJson({
|
||||
content: content.activities,
|
||||
isDryRun: true
|
||||
});
|
||||
const { activities } =
|
||||
await this.importActivitiesService.importJson({
|
||||
accounts: content.accounts,
|
||||
activities: content.activities,
|
||||
isDryRun: true
|
||||
});
|
||||
this.activities = activities;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({ error, activities: content.activities });
|
||||
@ -192,11 +200,12 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
return;
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
try {
|
||||
this.activities = await this.importActivitiesService.importCsv({
|
||||
const data = await this.importActivitiesService.importCsv({
|
||||
fileContent,
|
||||
isDryRun: true,
|
||||
userAccounts: this.data.user.accounts
|
||||
});
|
||||
this.activities = data.activities;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
|
@ -70,7 +70,6 @@
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="false"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToImportActivities]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data?.user?.settings?.locale"
|
||||
[showActions]="false"
|
||||
|
@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -14,6 +15,15 @@ import { takeUntil } from 'rxjs/operators';
|
||||
export class PricingPageComponent implements OnDestroy, OnInit {
|
||||
public baseCurrency: string;
|
||||
public coupon: number;
|
||||
public importAndExportTooltipBasic = translate(
|
||||
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
|
||||
);
|
||||
public importAndExportTooltipOSS = translate(
|
||||
'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS'
|
||||
);
|
||||
public importAndExportTooltipPremium = translate(
|
||||
'DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM'
|
||||
);
|
||||
public isLoggedIn: boolean;
|
||||
public price: number;
|
||||
public user: User;
|
||||
|
@ -85,6 +85,20 @@
|
||||
></ion-icon>
|
||||
<span i18n>FIRE Calculator</span>
|
||||
</li>
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Data Import and Export</span>
|
||||
<span
|
||||
class="align-items-center d-flex ml-1"
|
||||
matTooltipPosition="above"
|
||||
[matTooltip]="importAndExportTooltipOSS"
|
||||
>
|
||||
<ion-icon name="information-circle-outline"></ion-icon>
|
||||
</span>
|
||||
</li>
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
@ -92,6 +106,13 @@
|
||||
></ion-icon>
|
||||
<a i18n [routerLink]="['/features']">and more Features...</a>
|
||||
</li>
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Community Support</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p i18n>Self-hosted, update manually.</p>
|
||||
@ -144,35 +165,19 @@
|
||||
></ion-icon>
|
||||
<span i18n>Portfolio Performance</span>
|
||||
</li>
|
||||
<li>
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<ion-icon
|
||||
class="invisible"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
</li>
|
||||
<li>
|
||||
<ion-icon
|
||||
class="invisible"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
</li>
|
||||
<li>
|
||||
<ion-icon
|
||||
class="invisible"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
</li>
|
||||
<li>
|
||||
<ion-icon
|
||||
class="invisible"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
</li>
|
||||
<li>
|
||||
<ion-icon
|
||||
class="invisible"
|
||||
class="mr-1"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Data Import and Export</span>
|
||||
<span
|
||||
class="align-items-center d-flex ml-1"
|
||||
matTooltipPosition="above"
|
||||
[matTooltip]="importAndExportTooltipBasic"
|
||||
>
|
||||
<ion-icon name="information-circle-outline"></ion-icon>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -261,6 +266,20 @@
|
||||
></ion-icon>
|
||||
<span i18n>FIRE Calculator</span>
|
||||
</li>
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Data Import and Export</span>
|
||||
<span
|
||||
class="align-items-center d-flex ml-1"
|
||||
matTooltipPosition="above"
|
||||
[matTooltip]="importAndExportTooltipPremium"
|
||||
>
|
||||
<ion-icon name="information-circle-outline"></ion-icon>
|
||||
</span>
|
||||
</li>
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
@ -268,6 +287,13 @@
|
||||
></ion-icon>
|
||||
<a i18n [routerLink]="['/features']">and more Features...</a>
|
||||
</li>
|
||||
<li class="align-items-center d-flex mb-1">
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="checkmark-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Email and Chat Support</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p i18n>Fully managed Ghostfolio cloud offering.</p>
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||
|
||||
@ -15,6 +16,7 @@ import { PricingPageComponent } from './pricing-page.component';
|
||||
GfPremiumIndicatorModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatTooltipModule,
|
||||
PricingPageRoutingModule,
|
||||
RouterModule
|
||||
],
|
||||
|
@ -4,6 +4,7 @@ import { Router } from '@angular/router';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { InfoItem, LineChartItem } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Role } from '@prisma/client';
|
||||
@ -37,7 +38,8 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||
private dialog: MatDialog,
|
||||
private internetIdentityService: InternetIdentityService,
|
||||
private router: Router,
|
||||
private tokenStorageService: TokenStorageService
|
||||
private tokenStorageService: TokenStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
@ -61,7 +63,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
public async createAccount() {
|
||||
this.dataService
|
||||
.postUser()
|
||||
.postUser({ country: this.userService.getCountry() })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ accessToken, authToken, role }) => {
|
||||
this.openShowAccessTokenDialog(accessToken, authToken, role);
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="d-none d-sm-block mb-3 text-center" i18n>Resources</h1>
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Resources</h1>
|
||||
<h2 class="h4 mb-3">Guides</h2>
|
||||
<div class="mb-5">
|
||||
<div class="mb-4 media">
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
|
||||
|
||||
import { ResourcesPageRoutingModule } from './resources-page-routing.module';
|
||||
import { ResourcesPageComponent } from './resources-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ResourcesPageComponent],
|
||||
imports: [CommonModule, MatCardModule, ResourcesPageRoutingModule],
|
||||
imports: [CommonModule, ResourcesPageRoutingModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class ResourcesPageModule {}
|
||||
|
@ -405,8 +405,8 @@ export class DataService {
|
||||
return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
|
||||
}
|
||||
|
||||
public postUser() {
|
||||
return this.http.post<UserItem>(`/api/v1/user`, {});
|
||||
public postUser({ country }: { country: string }) {
|
||||
return this.http.post<UserItem>(`/api/v1/user`, { country });
|
||||
}
|
||||
|
||||
public putAccount(aAccount: UpdateAccountDto) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { Account, DataSource, Type } from '@prisma/client';
|
||||
@ -33,7 +34,9 @@ export class ImportActivitiesService {
|
||||
fileContent: string;
|
||||
isDryRun?: boolean;
|
||||
userAccounts: Account[];
|
||||
}): Promise<Activity[]> {
|
||||
}): Promise<{
|
||||
activities: Activity[];
|
||||
}> {
|
||||
const content = csvToJson(fileContent, {
|
||||
dynamicTyping: true,
|
||||
header: true,
|
||||
@ -55,20 +58,26 @@ export class ImportActivitiesService {
|
||||
});
|
||||
}
|
||||
|
||||
return await this.importJson({ isDryRun, content: activities });
|
||||
return await this.importJson({ activities, isDryRun });
|
||||
}
|
||||
|
||||
public importJson({
|
||||
content,
|
||||
accounts,
|
||||
activities,
|
||||
isDryRun = false
|
||||
}: {
|
||||
content: CreateOrderDto[];
|
||||
activities: CreateOrderDto[];
|
||||
accounts?: CreateAccountDto[];
|
||||
isDryRun?: boolean;
|
||||
}): Promise<Activity[]> {
|
||||
}): Promise<{
|
||||
activities: Activity[];
|
||||
accounts?: CreateAccountDto[];
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.postImport(
|
||||
{
|
||||
activities: content
|
||||
accounts,
|
||||
activities
|
||||
},
|
||||
isDryRun
|
||||
)
|
||||
@ -80,22 +89,29 @@ export class ImportActivitiesService {
|
||||
)
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
resolve(data.activities);
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public importSelectedActivities(
|
||||
selectedActivities: Activity[]
|
||||
): Promise<Activity[]> {
|
||||
public importSelectedActivities({
|
||||
accounts,
|
||||
activities
|
||||
}: {
|
||||
accounts: CreateAccountDto[];
|
||||
activities: Activity[];
|
||||
}): Promise<{
|
||||
activities: Activity[];
|
||||
accounts?: CreateAccountDto[];
|
||||
}> {
|
||||
const importData: CreateOrderDto[] = [];
|
||||
|
||||
for (const activity of selectedActivities) {
|
||||
for (const activity of activities) {
|
||||
importData.push(this.convertToCreateOrderDto(activity));
|
||||
}
|
||||
|
||||
return this.importJson({ content: importData });
|
||||
return this.importJson({ accounts, activities: importData });
|
||||
}
|
||||
|
||||
private convertToCreateOrderDto({
|
||||
@ -347,7 +363,7 @@ export class ImportActivitiesService {
|
||||
}
|
||||
|
||||
private postImport(
|
||||
aImportData: { activities: CreateOrderDto[] },
|
||||
aImportData: { accounts: CreateAccountDto[]; activities: CreateOrderDto[] },
|
||||
aIsDryRun = false
|
||||
) {
|
||||
return this.http.post<{ activities: Activity[] }>(
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user