Compare commits

..

30 Commits

Author SHA1 Message Date
0ec50819f5 Release 1.234.0 (#1703) 2023-02-15 10:52:02 +01:00
c9abe818bc Revert import (#1702) 2023-02-15 10:50:19 +01:00
bfa32537a8 Feature/improve usability of import activities action (#1695)
* Improve usability of import activities action

* Update changelog
2023-02-15 10:07:25 +01:00
cef15afab8 Add styling (#1701) 2023-02-15 10:01:35 +01:00
1b9587c454 Update default coupon duration (#1700) 2023-02-15 10:00:04 +01:00
de76b0d8c3 Feature/add data import and export to pricing page (#1697)
* Add data import and export

* Update changelog
2023-02-15 09:52:09 +01:00
e62989c981 Feature/copy logic of ghostfolio scraper api service to manual service (#1691)
* Copy logic of GhostfolioScraperApiService to ManualService

* Update changelog
2023-02-15 09:50:31 +01:00
d6b71e6314 Bugfix/fix links in subscription interstitial dialog (#1696)
* Fix links

* Update changelog
2023-02-14 18:25:12 +01:00
8c59bfd6d7 Feature/upgrade prisma to version 4.10.1 (#1688)
* Upgrade prisma to version 4.10.1

* Update changelog
2023-02-14 11:35:04 +01:00
f32df73256 Feature/migrate pages to angular material 15 (#1689)
* Migrate to Angular Material 15

* Update changelog
2023-02-14 10:04:22 +01:00
9d03a8002c Feature/improve content of faq and landing page (#1687)
* Conditionally show content

* Update changelog
2023-02-13 09:41:25 +01:00
3c36ca29af Feature/upgrade ionicons to version 6.1.2 (#1676)
* Upgrade ionicons to version 6.1.2

* Update changelog
2023-02-12 10:08:04 +01:00
efed7e3c2b Modify default exposed port (#1681)
* Modify default exposed port

* Update changelog
2023-02-11 10:37:44 +01:00
b09d3cea95 Fix landing page by setting a default value for countriesOfSubscribers
* Set default value for countriesOfSubscribers

* Update changelog
2023-02-11 09:57:27 +01:00
eabd2f3934 Add url (#1683) 2023-02-11 09:55:03 +01:00
cc184c2827 Feature/upgrade prettier to version 2.8.4 (#1675)
* Upgrade prettier to version 2.8.4

* Update changelog
2023-02-10 09:27:26 +01:00
436f791fa4 Feature/upgrade chart.js to version 4.2.0 (#1567)
* Upgrade chart.js to version 4.2.0

* Update changelog
2023-02-09 21:22:55 +01:00
e935a57dec Release 1.233.0 (#1678) 2023-02-09 20:30:53 +01:00
203909d917 Feature/upgrade eslint dependencies (#1674)
* Upgrade eslint dependencies

* Update changelog
2023-02-09 10:22:50 +01:00
eed4f57f30 Clean up (#1669) 2023-02-09 09:59:29 +01:00
7878036bac Feature/remove google play badge from landing page (#1672)
* Remove Google Play badge

* Update changelog
2023-02-08 14:17:49 +01:00
75d140b436 Harmonize file name (#1662) 2023-02-07 08:44:22 +01:00
a79f31b006 Feature/add accounts import export (#1635)
* Add accounts to activities export

* Add logic for importing accounts

* Update changelog
2023-02-06 21:59:59 +01:00
45cfd61dbb Feature/improve styling in admin control panel (#1665)
* Improve styling

* Update changelog
2023-02-06 11:35:56 +01:00
7fcfca952e Release 1.232.0 (#1664) 2023-02-05 19:46:18 +01:00
279f16cc67 Feature/extract locales 20230205 (#1663)
* Extract locales

* Update changelog
2023-02-05 19:44:33 +01:00
e7b1d8a5d3 Feature/upgrade ngx markdown to version 15.1.0 (#1657)
* Upgrade ngx-markdown to version 15.1.0

* Update changelog
2023-02-05 18:57:35 +01:00
1b2f8e5586 Feature/extend analytics by country (#1661)
* Extend analytics by country

* Fix Upgrade Plan button of subscription interstitial

* Update changelog
2023-02-05 18:57:12 +01:00
e4468252c6 Feature/upgrade ng extract i18n merge to version 2.5.0 (#1656)
* Upgrade ng-extract-i18n-merge to version 2.5.0

* Update changelog
2023-02-05 11:44:06 +01:00
ad3ebd42bb Feature/migrate mat suffix to angular material 15 (#1655)
* Migrate matSuffix to @angular/material 15

* Update changelog
2023-02-05 09:49:37 +01:00
95 changed files with 3182 additions and 1721 deletions

View File

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

View File

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

View File

@ -175,7 +175,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_

View File

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

View File

@ -17,6 +17,10 @@ export class CreateAccountDto {
@IsString()
currency: string;
@IsOptional()
@IsString()
id?: string;
@IsBoolean()
@IsOptional()
isExcluded?: boolean;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsOptional()
country?: string;
}

View File

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

View File

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

View File

@ -24,7 +24,6 @@ export class ConfigurationService {
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 }),

View File

@ -65,7 +65,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
symbolProfile.scraperConfiguration ?? {};
if (defaultMarketPrice) {
const historical: {
@ -148,7 +148,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
dataSource: this.getName(),
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice,
})?.marketPrice,
marketState: 'delayed'
};
}

View File

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

View File

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

View File

@ -40,7 +40,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -239,7 +239,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"

View File

@ -19,7 +19,7 @@ export class SubscriptionInterstitialDialog {
public dialogRef: MatDialogRef<SubscriptionInterstitialDialog>
) {}
public onCancel() {
public closeDialog() {
this.dialogRef.close({});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
<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">
@ -28,7 +28,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 +54,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 +82,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 +106,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 +130,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 +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">
@ -178,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">
@ -204,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">
@ -228,7 +232,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">

View File

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

View File

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

View File

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

View File

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

View File

@ -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' }"
@ -300,7 +319,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 +376,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">

View File

@ -13,12 +13,6 @@
aspect-ratio: 16 / 9;
}
.downloads {
img {
height: 2.5rem;
}
}
.intro {
font-size: 4vw;
line-height: 1;

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
@ -76,7 +76,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 +93,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 +109,7 @@
<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">
@ -121,20 +121,20 @@
</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>
<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>
<mat-form-field appearance="outline" class="w-100">
@ -142,7 +142,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 +157,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 +207,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 +216,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 +224,7 @@
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-list>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"

View File

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

View File

@ -20,45 +20,24 @@
}
}
.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);
}
}

View File

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

View File

@ -70,7 +70,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale"
[showActions]="false"

View File

@ -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,12 @@ import { takeUntil } from 'rxjs/operators';
export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string;
public coupon: number;
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;

View File

@ -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"
@ -261,6 +275,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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[] }>(

View File

@ -6,6 +6,7 @@ import { SubscriptionInterstitialDialogParams } from '@ghostfolio/client/compone
import { SubscriptionInterstitialDialog } from '@ghostfolio/client/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { timezoneCitiesToCountries } from '@ghostfolio/common/timezone-cities-to-countries';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, of } from 'rxjs';
import { throwError } from 'rxjs';
@ -45,6 +46,20 @@ export class UserService extends ObservableStore<UserStoreState> {
}
}
public getCountry() {
let country: string;
if (Intl) {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const timeZoneArray = timeZone.split('/');
const city = timeZoneArray[timeZoneArray.length - 1];
country = timezoneCitiesToCountries[city];
}
return country;
}
public remove() {
this.setState({ user: null }, UserStoreActions.RemoveUser);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -203,7 +203,7 @@ body {
.blog {
a {
&:not(.mat-flat-button) {
&:not(.mdc-button) {
color: rgba(var(--palette-primary-500), 1) !important;
font-weight: 500;

View File

@ -118,6 +118,18 @@ export function getDateWithTimeFormatString(aLocale?: string) {
return `${getDateFormatString(aLocale)}, HH:mm:ss`;
}
export function getEmojiFlag(aCountryCode: string) {
if (!aCountryCode) {
return aCountryCode;
}
return aCountryCode
.toUpperCase()
.replace(/./g, (character) =>
String.fromCodePoint(127397 + character.charCodeAt(0))
);
}
export function getLocale() {
return navigator.languages?.length
? navigator.languages[0]

View File

@ -5,6 +5,7 @@ export interface AdminData {
userCount: number;
users: {
accountCount: number;
country: string;
createdAt: Date;
engagement: number;
id: string;

View File

@ -1,10 +1,11 @@
import { Order } from '@prisma/client';
import { Account, Order } from '@prisma/client';
export interface Export {
meta: {
date: string;
version: string;
};
accounts: Omit<Account, 'createdAt' | 'isDefault' | 'updatedAt' | 'userId'>[];
activities: (Omit<
Order,
| 'accountUserId'

View File

@ -0,0 +1,426 @@
export const timezoneCitiesToCountries = {
Abidjan: 'CI',
Accra: 'GH',
Adak: 'US',
Addis_Ababa: 'ET',
Adelaide: 'AU',
Aden: 'YE',
Algiers: 'DZ',
Almaty: 'KZ',
Amman: 'JO',
Amsterdam: 'NL',
Anadyr: 'RU',
Anchorage: 'US',
Andorra: 'AD',
Anguilla: 'AI',
Antananarivo: 'MG',
Antigua: 'AG',
Apia: 'WS',
Aqtau: 'KZ',
Aqtobe: 'KZ',
Araguaina: 'BR',
Aruba: 'AW',
Ashgabat: 'TM',
Asmara: 'ER',
Astrakhan: 'RU',
Asuncion: 'PY',
Athens: 'GR',
Atikokan: 'CA',
Atyrau: 'KZ',
Auckland: 'NZ',
Azores: 'PT',
Baghdad: 'IQ',
Bahia: 'BR',
Bahia_Banderas: 'MX',
Bahrain: 'BH',
Baku: 'AZ',
Bamako: 'ML',
Bangkok: 'TH',
Bangui: 'CF',
Banjul: 'GM',
Barbados: 'BB',
Barnaul: 'RU',
Beirut: 'LB',
Belem: 'BR',
Belgrade: 'RS',
Belize: 'BZ',
Berlin: 'DE',
Bermuda: 'BM',
Beulah: 'US',
Bishkek: 'KG',
Bissau: 'GW',
'Blanc-Sablon': 'CA',
Blantyre: 'MW',
Boa_Vista: 'BR',
Bogota: 'CO',
Boise: 'US',
Bougainville: 'PG',
Bratislava: 'SK',
Brazzaville: 'CG',
Brisbane: 'AU',
Broken_Hill: 'AU',
Brunei: 'BN',
Brussels: 'BE',
Bucharest: 'RO',
Budapest: 'HU',
Buenos_Aires: 'AR',
Bujumbura: 'BI',
Busingen: 'DE',
Cairo: 'EG',
Cambridge_Bay: 'CA',
Campo_Grande: 'BR',
Canary: 'ES',
Cancun: 'MX',
Cape_Verde: 'CV',
Caracas: 'VE',
Casablanca: 'MA',
Casey: 'AQ',
Catamarca: 'AR',
Cayenne: 'GF',
Cayman: 'KY',
Center: 'US',
Ceuta: 'ES',
Chagos: 'IO',
Chatham: 'NZ',
Chicago: 'US',
Chihuahua: 'MX',
Chisinau: 'MD',
Chita: 'RU',
Choibalsan: 'MN',
Christmas: 'CX',
Chuuk: 'FM',
Cocos: 'CC',
Colombo: 'LK',
Comoro: 'KM',
Conakry: 'GN',
Copenhagen: 'DK',
Cordoba: 'AR',
Costa_Rica: 'CR',
Creston: 'CA',
Cuiaba: 'BR',
Curacao: 'CW',
Dakar: 'SN',
Damascus: 'SY',
Danmarkshavn: 'GL',
Dar_es_Salaam: 'TZ',
Darwin: 'AU',
Davis: 'AQ',
Dawson: 'CA',
Dawson_Creek: 'CA',
Denver: 'US',
Detroit: 'US',
Dhaka: 'BD',
Dili: 'TL',
Djibouti: 'DJ',
Dominica: 'DM',
Douala: 'CM',
Dubai: 'AE',
Dublin: 'IE',
DumontDUrville: 'AQ',
Dushanbe: 'TJ',
Easter: 'CL',
Edmonton: 'CA',
Efate: 'VU',
Eirunepe: 'BR',
El_Aaiun: 'EH',
El_Salvador: 'SV',
Eucla: 'AU',
Fakaofo: 'TK',
Famagusta: 'CY',
Faroe: 'FO',
Fiji: 'FJ',
Fort_Nelson: 'CA',
Fortaleza: 'BR',
Freetown: 'SL',
Funafuti: 'TV',
Gaborone: 'BW',
Galapagos: 'EC',
Gambier: 'PF',
Gaza: 'PS',
Gibraltar: 'GI',
Glace_Bay: 'CA',
Goose_Bay: 'CA',
Grand_Turk: 'TC',
Grenada: 'GD',
Guadalcanal: 'SB',
Guadeloupe: 'GP',
Guam: 'GU',
Guatemala: 'GT',
Guayaquil: 'EC',
Guernsey: 'GG',
Guyana: 'GY',
Halifax: 'CA',
Harare: 'ZW',
Havana: 'CU',
Hebron: 'PS',
Helsinki: 'FI',
Hermosillo: 'MX',
Ho_Chi_Minh: 'VN',
Hobart: 'AU',
Hong_Kong: 'HK',
Honolulu: 'US',
Hovd: 'MN',
Indianapolis: 'US',
Inuvik: 'CA',
Iqaluit: 'CA',
Irkutsk: 'RU',
Isle_of_Man: 'IM',
Istanbul: 'TR',
Jakarta: 'ID',
Jamaica: 'JM',
Jayapura: 'ID',
Jersey: 'JE',
Jerusalem: 'IL',
Johannesburg: 'ZA',
Juba: 'SS',
Jujuy: 'AR',
Juneau: 'US',
Kabul: 'AF',
Kaliningrad: 'RU',
Kamchatka: 'RU',
Kampala: 'UG',
Kanton: 'KI',
Karachi: 'PK',
Kathmandu: 'NP',
Kerguelen: 'TF',
Khandyga: 'RU',
Khartoum: 'SD',
Kiev: 'UA',
Kigali: 'RW',
Kinshasa: 'CD',
Kiritimati: 'KI',
Kirov: 'RU',
Knox: 'US',
Kolkata: 'IN',
Kosrae: 'FM',
Kralendijk: 'NL',
Krasnoyarsk: 'RU',
Kuala_Lumpur: 'MY',
Kuching: 'MY',
Kuwait: 'KW',
Kwajalein: 'MH',
La_Paz: 'BO',
La_Rioja: 'AR',
Lagos: 'NG',
Libreville: 'GA',
Lima: 'PE',
Lindeman: 'AU',
Lisbon: 'PT',
Ljubljana: 'SI',
Lome: 'TG',
London: 'GB',
Longyearbyen: 'SJ',
Lord_Howe: 'AU',
Los_Angeles: 'US',
Louisville: 'US',
Lower_Princes: 'SX',
Luanda: 'AO',
Lubumbashi: 'CD',
Lusaka: 'ZM',
Luxembourg: 'LU',
Macau: 'MO',
Maceio: 'BR',
Macquarie: 'AU',
Madeira: 'PT',
Madrid: 'ES',
Magadan: 'RU',
Mahe: 'SC',
Majuro: 'MH',
Makassar: 'ID',
Malabo: 'GQ',
Maldives: 'MV',
Malta: 'MT',
Managua: 'NI',
Manaus: 'BR',
Manila: 'PH',
Maputo: 'MZ',
Marengo: 'US',
Mariehamn: 'AX',
Marigot: 'MF',
Marquesas: 'PF',
Martinique: 'MQ',
Maseru: 'LS',
Matamoros: 'MX',
Mauritius: 'MU',
Mawson: 'AQ',
Mayotte: 'YT',
Mazatlan: 'MX',
Mbabane: 'SZ',
McMurdo: 'AQ',
Melbourne: 'AU',
Mendoza: 'AR',
Menominee: 'US',
Merida: 'MX',
Metlakatla: 'US',
Mexico_City: 'MX',
Midway: 'UM',
Minsk: 'BY',
Miquelon: 'PM',
Mogadishu: 'SO',
Monaco: 'MC',
Moncton: 'CA',
Monrovia: 'LR',
Monterrey: 'MX',
Montevideo: 'UY',
Monticello: 'US',
Montserrat: 'MS',
Moscow: 'RU',
Muscat: 'OM',
Nairobi: 'KE',
Nassau: 'BS',
Nauru: 'NR',
Ndjamena: 'TD',
New_Salem: 'US',
New_York: 'US',
Niamey: 'NE',
Nicosia: 'CY',
Nipigon: 'CA',
Niue: 'NU',
Nome: 'US',
Norfolk: 'NF',
Noronha: 'BR',
Nouakchott: 'MR',
Noumea: 'NC',
Novokuznetsk: 'RU',
Novosibirsk: 'RU',
Nuuk: 'GL',
Ojinaga: 'MX',
Omsk: 'RU',
Oral: 'KZ',
Oslo: 'NO',
Ouagadougou: 'BF',
Pago_Pago: 'AS',
Palau: 'PW',
Palmer: 'AQ',
Panama: 'PA',
Pangnirtung: 'CA',
Paramaribo: 'SR',
Paris: 'FR',
Perth: 'AU',
Petersburg: 'US',
Phnom_Penh: 'KH',
Phoenix: 'US',
Pitcairn: 'PN',
Podgorica: 'ME',
Pohnpei: 'FM',
Pontianak: 'ID',
'Port-au-Prince': 'HT',
Port_Moresby: 'PG',
Port_of_Spain: 'TT',
'Porto-Novo': 'BJ',
Porto_Velho: 'BR',
Prague: 'CZ',
Puerto_Rico: 'PR',
Punta_Arenas: 'CL',
Pyongyang: 'KP',
Qatar: 'QA',
Qostanay: 'KZ',
Qyzylorda: 'KZ',
Rainy_River: 'CA',
Rankin_Inlet: 'CA',
Rarotonga: 'CK',
Recife: 'BR',
Regina: 'CA',
Resolute: 'CA',
Reunion: 'RE',
Reykjavik: 'IS',
Riga: 'LV',
Rio_Branco: 'BR',
Rio_Gallegos: 'AR',
Riyadh: 'SA',
Rome: 'IT',
Rothera: 'AQ',
Saipan: 'MP',
Sakhalin: 'RU',
Salta: 'AR',
Samara: 'RU',
Samarkand: 'UZ',
San_Juan: 'AR',
San_Luis: 'AR',
San_Marino: 'SM',
Santarem: 'BR',
Santiago: 'CL',
Santo_Domingo: 'DO',
Sao_Paulo: 'BR',
Sao_Tome: 'ST',
Sarajevo: 'BA',
Saratov: 'RU',
Scoresbysund: 'GL',
Seoul: 'KR',
Shanghai: 'CN',
Simferopol: 'RU',
Singapore: 'SG',
Sitka: 'US',
Skopje: 'MK',
Sofia: 'BG',
South_Georgia: 'GS',
Srednekolymsk: 'RU',
St_Barthelemy: 'BL',
St_Helena: 'SH',
St_Johns: 'CA',
St_Kitts: 'KN',
St_Lucia: 'LC',
St_Thomas: 'VI',
St_Vincent: 'VC',
Stanley: 'FK',
Stockholm: 'SE',
Swift_Current: 'CA',
Sydney: 'AU',
Syowa: 'AQ',
Tahiti: 'PF',
Taipei: 'TW',
Tallinn: 'EE',
Tarawa: 'KI',
Tashkent: 'UZ',
Tbilisi: 'GE',
Tegucigalpa: 'HN',
Tehran: 'IR',
Tell_City: 'US',
Thimphu: 'BT',
Thule: 'GL',
Thunder_Bay: 'CA',
Tijuana: 'MX',
Tirane: 'AL',
Tokyo: 'JP',
Tomsk: 'RU',
Tongatapu: 'TO',
Toronto: 'CA',
Tortola: 'VI (UK)',
Tripoli: 'LY',
Troll: 'AQ',
Tucuman: 'AR',
Tunis: 'TN',
Ulaanbaatar: 'MN',
Ulyanovsk: 'RU',
Urumqi: 'CN',
Ushuaia: 'AR',
'Ust-Nera': 'RU',
Uzhgorod: 'UA',
Vaduz: 'LI',
Vancouver: 'CA',
Vatican: 'VA',
Vevay: 'US',
Vienna: 'AT',
Vientiane: 'LA',
Vilnius: 'LT',
Vincennes: 'US',
Vladivostok: 'RU',
Volgograd: 'RU',
Vostok: 'AQ',
Wake: 'UM',
Wallis: 'WF',
Warsaw: 'PL',
Whitehorse: 'CA',
Winamac: 'US',
Windhoek: 'NA',
Winnipeg: 'CA',
Yakutat: 'US',
Yakutsk: 'RU',
Yangon: 'MM',
Yekaterinburg: 'RU',
Yellowknife: 'CA',
Yerevan: 'AM',
Zagreb: 'HR',
Zaporozhye: 'UA',
Zurich: 'CH'
};

View File

@ -1,7 +1,7 @@
import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import { AccountWithPlatform } from './account-with-platform.type';
import { AccountWithValue } from './account-with-value.type';
import type { ColorScheme } from './color-scheme';
import type { ColorScheme } from './color-scheme.type';
import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type';
import { GroupBy } from './group-by.type';

View File

@ -6,6 +6,56 @@
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
<div *ngIf="hasPermissionToCreateActivity" class="d-flex justify-content-end">
<button
class="align-items-center d-flex"
mat-stroked-button
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Import Activities...</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="mx-1 no-min-width px-2"
mat-stroked-button
[matMenuTriggerFor]="activitiesMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #activitiesMenu="matMenu" xPosition="before">
<button
mat-menu-item
[disabled]="dataSource.data.length === 0"
(click)="onImportDividends()"
>
<ion-icon class="mr-2" name="color-wand-outline"></ion-icon>
<span i18n>Import Dividends...</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource.data.length === 0"
(click)="onExport()"
>
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export Activities</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="!hasDrafts"
(click)="onExportDrafts()"
>
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Drafts as ICS</span>
</button>
</mat-menu>
</div>
<div class="activities">
<table
class="gf-table w-100"
@ -369,7 +419,7 @@
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
*ngIf="
hasPermissionToExportActivities || hasPermissionToImportActivities
!hasPermissionToCreateActivity && hasPermissionToExportActivities
"
class="mx-1 no-min-width px-2"
mat-button
@ -380,21 +430,22 @@
</button>
<mat-menu #activitiesMenu="matMenu" xPosition="before">
<button
*ngIf="hasPermissionToImportActivities"
*ngIf="hasPermissionToCreateActivity"
class="align-items-center d-flex"
mat-menu-item
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Import Activities</span>
<span i18n>Import Activities...</span>
</button>
<button
*ngIf="hasPermissionToImportActivities"
*ngIf="hasPermissionToCreateActivity"
mat-menu-item
[disabled]="dataSource.data.length === 0"
(click)="onImportDividends()"
>
<ion-icon class="mr-2" name="color-wand-outline"></ion-icon>
<span i18n>Import Dividends</span>
<span i18n>Import Dividends...</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"

View File

@ -40,7 +40,6 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToExportActivities: boolean;
@Input() hasPermissionToFilter = true;
@Input() hasPermissionToImportActivities: boolean;
@Input() hasPermissionToOpenDetails = true;
@Input() locale: string;
@Input() pageSize = DEFAULT_PAGE_SIZE;

View File

@ -61,7 +61,7 @@ export class FireCalculatorComponent
principalInvestmentAmount: new FormControl<number>(undefined),
time: new FormControl<number>(undefined)
});
public chart: Chart;
public chart: Chart<'bar'>;
public isLoading = true;
public projectedTotalAmount: number;

View File

@ -5,6 +5,8 @@ const locales = {
ASSET_CLASS: $localize`Asset Class`,
ASSET_SUB_CLASS: $localize`Asset Sub Class`,
CORE: $localize`Core`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM: $localize`Switch to Ghostfolio Open Source easily`,
EMERGENCY_FUND: $localize`Emergency Fund`,
GRANT: $localize`Grant`,
HIGHER_RISK: $localize`Higher Risk`,

View File

@ -66,7 +66,7 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart;
public chart: Chart<'line'>;
public isLoading = true;
private readonly ANIMATION_DURATION = 1200;

View File

@ -55,7 +55,7 @@ export class PortfolioProportionChartComponent
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart;
public chart: Chart<'pie'>;
public isLoading = true;
private readonly OTHER_KEY = 'OTHER';

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.231.0",
"version": "1.234.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -81,7 +81,7 @@
"@nestjs/schedule": "2.1.0",
"@nestjs/serve-static": "3.0.0",
"@nrwl/angular": "15.6.3",
"@prisma/client": "4.9.0",
"@prisma/client": "4.10.1",
"@simplewebauthn/browser": "5.2.1",
"@simplewebauthn/server": "5.2.1",
"@stripe/stripe-js": "1.22.0",
@ -92,9 +92,9 @@
"bull": "4.10.2",
"cache-manager": "3.4.3",
"cache-manager-redis-store": "2.0.0",
"chart.js": "4.0.1",
"chartjs-adapter-date-fns": "2.0.1",
"chartjs-plugin-annotation": "2.1.0",
"chart.js": "4.2.0",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-plugin-annotation": "2.1.2",
"chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.0.0-rc.12",
"class-transformer": "0.3.2",
@ -106,19 +106,20 @@
"envalid": "7.3.1",
"google-spreadsheet": "3.2.0",
"http-status-codes": "2.2.0",
"ionicons": "6.0.4",
"ionicons": "6.1.2",
"lodash": "4.17.21",
"marked": "4.2.12",
"ms": "3.0.0-canary.1",
"ng-extract-i18n-merge": "2.1.2",
"ng-extract-i18n-merge": "2.5.0",
"ngx-device-detector": "3.0.0",
"ngx-markdown": "14.0.1",
"ngx-markdown": "15.1.0",
"ngx-skeleton-loader": "5.0.0",
"ngx-stripe": "13.0.0",
"papaparse": "5.3.1",
"passport": "0.6.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "4.9.0",
"prisma": "4.10.1",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
"stripe": "8.199.0",
@ -161,17 +162,18 @@
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "28.1.8",
"@types/lodash": "4.14.191",
"@types/marked": "4.0.8",
"@types/node": "18.11.18",
"@types/papaparse": "5.3.7",
"@types/passport-google-oauth20": "2.0.11",
"@typescript-eslint/eslint-plugin": "5.4.0",
"@typescript-eslint/parser": "5.4.0",
"@typescript-eslint/eslint-plugin": "5.51.0",
"@typescript-eslint/parser": "5.51.0",
"codelyzer": "6.0.1",
"cypress": "6.2.1",
"eslint": "8.3.0",
"eslint-config-prettier": "8.3.0",
"eslint": "8.33.0",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-cypress": "2.12.1",
"eslint-plugin-import": "2.25.3",
"eslint-plugin-import": "2.27.5",
"import-sort-cli": "6.0.0",
"import-sort-parser-typescript": "6.0.0",
"import-sort-style-module": "6.0.0",
@ -179,7 +181,7 @@
"jest-environment-jsdom": "29.4.1",
"jest-preset-angular": "12.2.3",
"nx": "15.6.3",
"prettier": "2.8.1",
"prettier": "2.8.4",
"prettier-plugin-organize-attributes": "0.0.5",
"replace-in-file": "6.3.5",
"rimraf": "3.0.2",

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Analytics" ADD COLUMN "country" TEXT;

View File

@ -41,6 +41,7 @@ model Account {
model Analytics {
activityCount Int @default(0)
country String?
updatedAt DateTime @updatedAt
userId String @id
User User @relation(fields: [userId], references: [id])

View File

@ -0,0 +1,48 @@
{
"meta": {
"date": "2022-04-01T00:00:00.000Z",
"version": "dev"
},
"activities": [
{
"fee": 0,
"quantity": 0,
"type": "BUY",
"unitPrice": 0,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2050-06-05T22:00:00.000Z",
"symbol": "MSFT"
},
{
"fee": 0,
"quantity": 1,
"type": "ITEM",
"unitPrice": 500000,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2021-12-31T22:00:00.000Z",
"symbol": "Penthouse Apartment"
},
{
"fee": 0,
"quantity": 5,
"type": "DIVIDEND",
"unitPrice": 0.62,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-11-16T22:00:00.000Z",
"symbol": "MSFT"
},
{
"fee": 19,
"quantity": 5,
"type": "BUY",
"unitPrice": 298.58,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T22:00:00.000Z",
"symbol": "MSFT"
}
]
}

View File

@ -1,10 +1,23 @@
{
"meta": {
"date": "2022-04-01T00:00:00.000Z",
"date": "2023-02-05T00:00:00.000Z",
"version": "dev"
},
"accounts": [
{
"accountType": "SECURITIES",
"balance": 2000,
"currency": "USD",
"id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"isExcluded": false,
"name": "My Online Trading Account",
"platformId": null
}
],
"activities": [
{
"accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"comment": null,
"fee": 0,
"quantity": 0,
"type": "BUY",
@ -15,6 +28,8 @@
"symbol": "MSFT"
},
{
"accountId": null,
"comment": null,
"fee": 0,
"quantity": 1,
"type": "ITEM",
@ -25,6 +40,8 @@
"symbol": "Penthouse Apartment"
},
{
"accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"comment": null,
"fee": 0,
"quantity": 5,
"type": "DIVIDEND",
@ -35,6 +52,8 @@
"symbol": "MSFT"
},
{
"accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"comment": "My first order",
"fee": 19,
"quantity": 5,
"type": "BUY",

720
yarn.lock

File diff suppressed because it is too large Load Diff