Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
c9368c5cf2 | |||
29423efea3 | |||
f3ee99fb2b | |||
3df8810412 | |||
b8ca88c6df | |||
2c068c412d | |||
9fdbd22cb5 | |||
8f5f4c5875 | |||
50fb82a6e6 | |||
2c10cd7edf | |||
bbde86c66e | |||
73c0843d51 |
19
CHANGELOG.md
19
CHANGELOG.md
@ -5,6 +5,25 @@ 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).
|
||||
|
||||
## 2.45.0 - 2024-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the assistant by an account selector (experimental)
|
||||
- Added support to grant private access with permissions (experimental)
|
||||
- Added `permissions` to the `Access` model
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the tag selector to a form group in the assistant (experimental)
|
||||
- Formatted the name in the _EOD Historical Data_ service
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the import for activities with `MANUAL` data source and type `FEE`, `INTEREST`, `ITEM` or `LIABILITY`
|
||||
- Removed holdings with incomplete data from the _Top 3_ and _Bottom 3_ performers on the analysis page
|
||||
|
||||
## 2.44.0 - 2024-01-24
|
||||
|
||||
### Fixed
|
||||
|
@ -42,23 +42,27 @@ export class AccessController {
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map((access) => {
|
||||
if (access.GranteeUser) {
|
||||
return accessesWithGranteeUser.map(
|
||||
({ alias, GranteeUser, id, permissions }) => {
|
||||
if (GranteeUser) {
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: GranteeUser?.id,
|
||||
type: 'PRIVATE'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
alias: access.alias,
|
||||
grantee: access.GranteeUser?.id,
|
||||
id: access.id,
|
||||
type: 'RESTRICTED_VIEW'
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: 'Public',
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
alias: access.alias,
|
||||
grantee: 'Public',
|
||||
id: access.id,
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createAccess)
|
||||
@ -83,6 +87,7 @@ export class AccessController {
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
permissions: data.permissions,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
} catch {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { AccessPermission } from '@prisma/client';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccessDto {
|
||||
@IsOptional()
|
||||
@ -9,7 +10,7 @@ export class CreateAccessDto {
|
||||
@IsUUID()
|
||||
granteeUserId?: string;
|
||||
|
||||
@IsEnum(AccessPermission, { each: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
type?: 'PUBLIC';
|
||||
permissions?: AccessPermission[];
|
||||
}
|
||||
|
@ -575,7 +575,7 @@ export class ImportService {
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, symbol }
|
||||
{ currency, dataSource, symbol, type }
|
||||
] of uniqueActivitiesDto.entries()) {
|
||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||
throw new Error(
|
||||
@ -583,28 +583,33 @@ export class ImportService {
|
||||
);
|
||||
}
|
||||
|
||||
const assetProfile = (
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol];
|
||||
const assetProfile = {
|
||||
currency,
|
||||
...(
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol]
|
||||
};
|
||||
|
||||
if (!assetProfile?.name) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
||||
if (!assetProfile?.name) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
assetProfile.currency !== currency &&
|
||||
!this.exchangeRateDataService.hasCurrencyPair(
|
||||
currency,
|
||||
assetProfile.currency
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||
);
|
||||
if (
|
||||
assetProfile.currency !== currency &&
|
||||
!this.exchangeRateDataService.hasCurrencyPair(
|
||||
currency,
|
||||
assetProfile.currency
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
|
@ -74,6 +74,11 @@ export class PortfolioController {
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
let hasDetails = true;
|
||||
let hasError = false;
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
@ -108,7 +113,7 @@ export class PortfolioController {
|
||||
let portfolioSummary = summary;
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const totalInvestment = Object.values(holdings)
|
||||
@ -148,7 +153,7 @@ export class PortfolioController {
|
||||
|
||||
if (
|
||||
hasDetails === false ||
|
||||
impersonationId ||
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
portfolioSummary = nullifyValuesInObject(summary, [
|
||||
@ -164,6 +169,7 @@ export class PortfolioController {
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'fireWealth',
|
||||
'interest',
|
||||
'items',
|
||||
'liabilities',
|
||||
'netWorth',
|
||||
@ -216,6 +222,12 @@ export class PortfolioController {
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDividends> {
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
@ -230,7 +242,7 @@ export class PortfolioController {
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxDividend = dividends.reduce(
|
||||
@ -266,6 +278,12 @@ export class PortfolioController {
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
@ -281,7 +299,7 @@ export class PortfolioController {
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxInvestment = investments.reduce(
|
||||
@ -329,6 +347,12 @@ export class PortfolioController {
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccounts = false
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
@ -344,7 +368,7 @@ export class PortfolioController {
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
|
@ -38,6 +38,10 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
emergencyFund?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
'filters.accounts'?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
@ -105,6 +105,24 @@ export class UserService {
|
||||
return usersWithAdminRole.length > 0;
|
||||
}
|
||||
|
||||
public hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user
|
||||
}: {
|
||||
impersonationId: string;
|
||||
user: UserWithSettings;
|
||||
}) {
|
||||
if (!impersonationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const access = user.Access?.find(({ id }) => {
|
||||
return id === impersonationId;
|
||||
});
|
||||
|
||||
return access?.permissions?.includes('READ_RESTRICTED') ?? true;
|
||||
}
|
||||
|
||||
public isRestrictedView(aUser: UserWithSettings) {
|
||||
return aUser.Settings.settings.isRestrictedView ?? false;
|
||||
}
|
||||
@ -113,6 +131,7 @@ export class UserService {
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||
): Promise<UserWithSettings | null> {
|
||||
const {
|
||||
Access,
|
||||
accessToken,
|
||||
Account,
|
||||
Analytics,
|
||||
@ -127,6 +146,7 @@ export class UserService {
|
||||
updatedAt
|
||||
} = await this.prismaService.user.findUnique({
|
||||
include: {
|
||||
Access: true,
|
||||
Account: {
|
||||
include: { Platform: true }
|
||||
},
|
||||
@ -138,6 +158,7 @@ export class UserService {
|
||||
});
|
||||
|
||||
const user: UserWithSettings = {
|
||||
Access,
|
||||
accessToken,
|
||||
Account,
|
||||
authChallenge,
|
||||
|
@ -230,6 +230,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -568,6 +572,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -926,6 +934,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1130,6 +1142,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
@ -22,13 +23,20 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const hasImpersonationId =
|
||||
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
|
||||
const { headers, user }: { headers: Headers; user: UserWithSettings } =
|
||||
context.switchToHttp().getRequest();
|
||||
|
||||
const impersonationId =
|
||||
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
|
||||
const hasReadRestrictedPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user
|
||||
});
|
||||
|
||||
if (
|
||||
hasImpersonationId ||
|
||||
this.userService.isRestrictedView(request.user)
|
||||
hasReadRestrictedPermission ||
|
||||
this.userService.isRestrictedView(user)
|
||||
) {
|
||||
data = redactAttributes({
|
||||
object: data,
|
||||
|
@ -62,9 +62,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
|
||||
'.'
|
||||
)?.[0]}.json`,
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/funds/${
|
||||
symbol.split('.')?.[0]
|
||||
}.json`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
@ -104,9 +104,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
|
||||
'.'
|
||||
)?.[0]}.json`,
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
|
||||
symbol.split('.')?.[0]
|
||||
}.json`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
REPLACE_NAME_PARTS,
|
||||
UNKNOWN_KEY
|
||||
} from '@ghostfolio/common/config';
|
||||
import { isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
@ -137,18 +141,11 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
if (name) {
|
||||
name = name.replace('&', '&');
|
||||
|
||||
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 - ', '');
|
||||
name = name.replace('VanEck ETFs N.V. - ', '');
|
||||
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
|
||||
name = name.replace('Vanguard Funds Public Limited Company - ', '');
|
||||
name = name.replace('Vanguard Index Funds - ', '');
|
||||
name = name.replace('Xtrackers (IE) Plc - ', '');
|
||||
for (const part of REPLACE_NAME_PARTS) {
|
||||
name = name.replace(part, '');
|
||||
}
|
||||
|
||||
name = name.trim();
|
||||
}
|
||||
|
||||
if (quoteType === 'FUTURE') {
|
||||
|
@ -11,7 +11,10 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
REPLACE_NAME_PARTS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
@ -362,6 +365,18 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return aSymbol;
|
||||
}
|
||||
|
||||
private formatName({ name }: { name: string }) {
|
||||
if (name) {
|
||||
for (const part of REPLACE_NAME_PARTS) {
|
||||
name = name.replace(part, '');
|
||||
}
|
||||
|
||||
name = name.trim();
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
private async getSearchResult(aQuery: string): Promise<
|
||||
(LookupItem & {
|
||||
assetClass: AssetClass;
|
||||
@ -397,9 +412,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
isin,
|
||||
name,
|
||||
currency: this.convertCurrency(Currency),
|
||||
dataSource: this.getName(),
|
||||
name: this.formatName({ name }),
|
||||
symbol: `${Code}.${Exchange}`
|
||||
};
|
||||
}
|
||||
|
@ -42,10 +42,21 @@ export class ManualService implements DataProviderInterface {
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
const assetProfile: Partial<SymbolProfile> = {
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource: this.getName(), symbol: aSymbol }
|
||||
]);
|
||||
|
||||
if (symbolProfile) {
|
||||
assetProfile.currency = symbolProfile.currency;
|
||||
assetProfile.name = symbolProfile.name;
|
||||
}
|
||||
|
||||
return assetProfile;
|
||||
}
|
||||
|
||||
public async getDividends({}: GetDividendsParams) {
|
||||
|
@ -17,8 +17,13 @@
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
|
||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon class="mr-1" name="lock-closed-outline" />
|
||||
<ng-container i18n>Restricted View</ng-container>
|
||||
@if (element.permissions.includes('READ')) {
|
||||
<ion-icon class="mr-1" name="lock-open-outline" />
|
||||
<ng-container i18n>View</ng-container>
|
||||
} @else if (element.permissions.includes('READ_RESTRICTED')) {
|
||||
<ion-icon class="mr-1" name="lock-closed-outline" />
|
||||
<ng-container i18n>Restricted view</ng-container>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
@ -141,7 +141,7 @@
|
||||
[user]="user"
|
||||
(closed)="closeAssistant()"
|
||||
(dateRangeChanged)="onDateRangeChange($event)"
|
||||
(selectedTagChanged)="onSelectedTagChanged($event)"
|
||||
(filtersChanged)="onFiltersChanged($event)"
|
||||
/>
|
||||
</mat-menu>
|
||||
</li>
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatMenuTrigger } from '@angular/material/menu';
|
||||
import { Router } from '@angular/router';
|
||||
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
|
||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
@ -20,11 +21,10 @@ import {
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
|
||||
import { Tag } from '@prisma/client';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -162,6 +162,34 @@ export class HeaderComponent implements OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
public onFiltersChanged(filters: Filter[]) {
|
||||
const userSetting: UpdateUserSettingDto = {};
|
||||
|
||||
for (const filter of filters) {
|
||||
let filtersType: string;
|
||||
|
||||
if (filter.type === 'ACCOUNT') {
|
||||
filtersType = 'accounts';
|
||||
} else if (filter.type === 'TAG') {
|
||||
filtersType = 'tags';
|
||||
}
|
||||
|
||||
userSetting[`filters.${filtersType}`] = filter.id ? [filter.id] : null;
|
||||
}
|
||||
|
||||
this.dataService
|
||||
.putUserSetting(userSetting)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
public onMenuClosed() {
|
||||
this.isMenuOpen = false;
|
||||
}
|
||||
@ -174,20 +202,6 @@ export class HeaderComponent implements OnChanges {
|
||||
this.assistantElement.initialize();
|
||||
}
|
||||
|
||||
public onSelectedTagChanged(tag: Tag) {
|
||||
this.dataService
|
||||
.putUserSetting({ 'filters.tags': tag ? [tag.id] : null })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
this.signOut.next();
|
||||
}
|
||||
|
@ -74,7 +74,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
|
||||
this.showDetails =
|
||||
!this.hasImpersonationId &&
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
||||
|
@ -1,14 +1,12 @@
|
||||
<div class="container p-0">
|
||||
<div class="no-gutters row">
|
||||
<div
|
||||
class="status-container text-muted text-right"
|
||||
(click)="onShowErrors()"
|
||||
>
|
||||
<div class="status-container text-muted text-right">
|
||||
@if (errors?.length > 0 && !isLoading) {
|
||||
<ion-icon
|
||||
i18n-title
|
||||
name="time-outline"
|
||||
title="Oops! Our data provider partner is experiencing the hiccups."
|
||||
title="Oops! A data provider is experiencing the hiccups."
|
||||
(click)="onShowErrors()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
@ -58,7 +58,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
duration: 1,
|
||||
separator: getNumberFormatGroup(this.locale)
|
||||
}).start();
|
||||
} else if (this.performance?.currentValue === null) {
|
||||
} else if (this.showDetails === false) {
|
||||
new CountUp(
|
||||
'value',
|
||||
this.performance?.currentNetPerformancePercent * 100,
|
||||
@ -69,6 +69,8 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
separator: getNumberFormatGroup(this.locale)
|
||||
}
|
||||
).start();
|
||||
} else {
|
||||
this.value.nativeElement.innerHTML = '*****';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,8 +10,8 @@
|
||||
[hidden]="summary?.ordersCount === null"
|
||||
>
|
||||
<div class="flex-grow-1 ml-3 text-truncate" i18n>
|
||||
{{ summary?.ordersCount }} {summary?.ordersCount, plural, =1 {transaction}
|
||||
other {transactions}}
|
||||
{{ summary?.ordersCount }}
|
||||
{summary?.ordersCount, plural, =1 {transaction} other {transactions}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@ -71,7 +71,11 @@
|
||||
<div class="flex-nowrap px-3 py-1 row">
|
||||
<div class="align-items-center d-flex flex-grow-1 ml-3 text-truncate">
|
||||
<ng-container i18n>Gross Performance</ng-container>
|
||||
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
|
||||
<abbr
|
||||
class="initialism ml-2 text-muted"
|
||||
title="Time-Weighted Rate of Return"
|
||||
>(TWR)</abbr
|
||||
>
|
||||
</div>
|
||||
<div class="flex-column flex-wrap justify-content-end">
|
||||
<gf-value
|
||||
@ -117,7 +121,11 @@
|
||||
<div class="flex-nowrap px-3 py-1 row">
|
||||
<div class="flex-grow-1 text-truncate ml-3">
|
||||
<ng-container i18n>Net Performance</ng-container>
|
||||
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
|
||||
<abbr
|
||||
class="initialism ml-2 text-muted"
|
||||
title="Time-Weighted Rate of Return"
|
||||
>(TWR)</abbr
|
||||
>
|
||||
</div>
|
||||
<div class="flex-column flex-wrap justify-content-end">
|
||||
<gf-value
|
||||
|
@ -37,19 +37,23 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||
ngOnInit() {
|
||||
this.accessForm = this.formBuilder.group({
|
||||
alias: [this.data.access.alias],
|
||||
permissions: [this.data.access.permissions[0], Validators.required],
|
||||
type: [this.data.access.type, Validators.required],
|
||||
userId: [this.data.access.grantee, Validators.required]
|
||||
});
|
||||
|
||||
this.accessForm.get('type').valueChanges.subscribe((value) => {
|
||||
this.accessForm.get('type').valueChanges.subscribe((accessType) => {
|
||||
const permissionsControl = this.accessForm.get('permissions');
|
||||
const userIdControl = this.accessForm.get('userId');
|
||||
|
||||
if (value === 'PRIVATE') {
|
||||
if (accessType === 'PRIVATE') {
|
||||
permissionsControl.setValidators(Validators.required);
|
||||
userIdControl.setValidators(Validators.required);
|
||||
} else {
|
||||
userIdControl.clearValidators();
|
||||
}
|
||||
|
||||
permissionsControl.updateValueAndValidity();
|
||||
userIdControl.updateValueAndValidity();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
@ -64,7 +68,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||
const access: CreateAccessDto = {
|
||||
alias: this.accessForm.controls['alias'].value,
|
||||
granteeUserId: this.accessForm.controls['userId'].value,
|
||||
type: this.accessForm.controls['type'].value
|
||||
permissions: [this.accessForm.controls['permissions'].value]
|
||||
};
|
||||
|
||||
this.dataService
|
||||
|
@ -30,9 +30,20 @@
|
||||
@if (accessForm.controls['type'].value === 'PRIVATE') {
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label
|
||||
>Ghostfolio <ng-container i18n>User ID</ng-container></mat-label
|
||||
>
|
||||
<mat-label i18n>Permission</mat-label>
|
||||
<mat-select formControlName="permissions">
|
||||
<mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option>
|
||||
@if(data?.user?.settings?.isExperimentalFeatures) {
|
||||
<mat-option i18n value="READ">View</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label>
|
||||
Ghostfolio <ng-container i18n>User ID</ng-container>
|
||||
</mat-label>
|
||||
<input
|
||||
formControlName="userId"
|
||||
matInput
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface CreateOrUpdateAccessDialogParams {
|
||||
access: Access;
|
||||
user: User;
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||
@ -105,8 +104,10 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
|
||||
data: {
|
||||
access: {
|
||||
alias: '',
|
||||
permissions: ['READ_RESTRICTED'],
|
||||
type: 'PRIVATE'
|
||||
}
|
||||
},
|
||||
user: this.user
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
|
@ -176,6 +176,19 @@
|
||||
your university e-mail address.</mat-card-content
|
||||
>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title
|
||||
>Does the Ghostfolio Premium subscription renew
|
||||
automatically?</mat-card-title
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content
|
||||
>No, <a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> does
|
||||
not include auto-renewal. Upon expiration, you can choose whether to
|
||||
start a new subscription.</mat-card-content
|
||||
>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Which devices are supported?</mat-card-title>
|
||||
|
@ -15,7 +15,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { Filter, User } from '@ghostfolio/common/interfaces';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
@ -379,7 +379,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ positions }) => {
|
||||
const positionsSorted = sortBy(
|
||||
positions,
|
||||
positions.filter(({ netPerformancePercentage }) => {
|
||||
return isNumber(netPerformancePercentage);
|
||||
}),
|
||||
'netPerformancePercentage'
|
||||
).reverse();
|
||||
|
||||
|
@ -44,6 +44,7 @@ import { SumioPageComponent } from './products/sumio-page.component';
|
||||
import { TillerPageComponent } from './products/tiller-page.component';
|
||||
import { UtlunaPageComponent } from './products/utluna-page.component';
|
||||
import { VyzerPageComponent } from './products/vyzer-page.component';
|
||||
import { WealthfolioPageComponent } from './products/wealthfolio-page.component';
|
||||
import { WealthicaPageComponent } from './products/wealthica-page.component';
|
||||
import { WhalPageComponent } from './products/whal-page.component';
|
||||
import { YeekateePageComponent } from './products/yeekatee-page.component';
|
||||
@ -528,6 +529,14 @@ export const products: Product[] = [
|
||||
pricingPerYear: '$348',
|
||||
slogan: 'Virtual Family Office for Smart Wealth Management'
|
||||
},
|
||||
{
|
||||
component: WealthfolioPageComponent,
|
||||
hasSelfHostingAbility: true,
|
||||
key: 'wealthfolio',
|
||||
languages: ['English'],
|
||||
name: 'Wealthfolio',
|
||||
slogan: 'Desktop Investment Tracker'
|
||||
},
|
||||
{
|
||||
component: WealthicaPageComponent,
|
||||
founded: 2015,
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { products } from '../products';
|
||||
import { BaseProductPageComponent } from './base-page.component';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-wealthfolio-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class WealthfolioPageComponent extends BaseProductPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'wealthfolio';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -47,18 +47,26 @@ export class UserService extends ObservableStore<UserStoreState> {
|
||||
}
|
||||
|
||||
public getFilters() {
|
||||
const filters: Filter[] = [];
|
||||
const user = this.getState().user;
|
||||
|
||||
return user?.settings?.isExperimentalFeatures === true
|
||||
? user.settings['filters.tags']
|
||||
? <Filter[]>[
|
||||
{
|
||||
id: user.settings['filters.tags'][0],
|
||||
type: 'TAG'
|
||||
}
|
||||
]
|
||||
: []
|
||||
: [];
|
||||
if (user?.settings?.isExperimentalFeatures === true) {
|
||||
if (user.settings['filters.accounts']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.accounts'][0],
|
||||
type: 'ACCOUNT'
|
||||
});
|
||||
}
|
||||
|
||||
if (user.settings['filters.tags']) {
|
||||
filters.push({
|
||||
id: user.settings['filters.tags'][0],
|
||||
type: 'TAG'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
public remove() {
|
||||
|
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -112,6 +112,21 @@ export const QUEUE_JOB_STATUS_LIST = <JobStatus[]>[
|
||||
'waiting'
|
||||
];
|
||||
|
||||
export const REPLACE_NAME_PARTS = [
|
||||
'Amundi Index Solutions -',
|
||||
'iShares ETF (CH) -',
|
||||
'iShares III Public Limited Company -',
|
||||
'iShares V PLC -',
|
||||
'iShares VI Public Limited Company -',
|
||||
'iShares VII PLC -',
|
||||
'Multi Units Luxembourg -',
|
||||
'VanEck ETFs N.V. -',
|
||||
'Vaneck Vectors Ucits Etfs Plc -',
|
||||
'Vanguard Funds Public Limited Company -',
|
||||
'Vanguard Index Funds -',
|
||||
'Xtrackers (IE) Plc -'
|
||||
];
|
||||
|
||||
export const SUPPORTED_LANGUAGE_CODES = [
|
||||
'de',
|
||||
'en',
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { AccessType } from '@ghostfolio/common/types';
|
||||
import { AccessPermission } from '@prisma/client';
|
||||
|
||||
export interface Access {
|
||||
alias?: string;
|
||||
grantee?: string;
|
||||
id: string;
|
||||
type: 'PRIVATE' | 'PUBLIC' | 'RESTRICTED_VIEW';
|
||||
permissions: AccessPermission[];
|
||||
type: AccessType;
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ export interface UserSettings {
|
||||
colorScheme?: ColorScheme;
|
||||
dateRange?: DateRange;
|
||||
emergencyFund?: number;
|
||||
'filters.accounts'?: string[];
|
||||
'filters.tags'?: string[];
|
||||
isExperimentalFeatures?: boolean;
|
||||
isRestrictedView?: boolean;
|
||||
|
1
libs/common/src/lib/types/access-type.type.ts
Normal file
1
libs/common/src/lib/types/access-type.type.ts
Normal file
@ -0,0 +1 @@
|
||||
export type AccessType = 'PRIVATE' | 'PUBLIC';
|
@ -1,3 +1,4 @@
|
||||
import type { AccessType } from './access-type.type';
|
||||
import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
|
||||
import type { AccountWithPlatform } from './account-with-platform.type';
|
||||
import type { AccountWithValue } from './account-with-value.type';
|
||||
@ -18,6 +19,7 @@ import type { UserWithSettings } from './user-with-settings.type';
|
||||
import type { ViewMode } from './view-mode.type';
|
||||
|
||||
export type {
|
||||
AccessType,
|
||||
AccessWithGranteeUser,
|
||||
AccountWithPlatform,
|
||||
AccountWithValue,
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
|
||||
import { Account, Settings, User } from '@prisma/client';
|
||||
import { Access, Account, Settings, User } from '@prisma/client';
|
||||
|
||||
// TODO: Compare with User interface
|
||||
export type UserWithSettings = User & {
|
||||
Access: Access[];
|
||||
Account: Account[];
|
||||
activityCount: number;
|
||||
permissions?: string[];
|
||||
|
@ -15,14 +15,14 @@ import {
|
||||
ViewChild,
|
||||
ViewChildren
|
||||
} from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { FormBuilder, FormControl } from '@angular/forms';
|
||||
import { MatMenuTrigger } from '@angular/material/menu';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { Filter, User } from '@ghostfolio/common/interfaces';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { Tag } from '@prisma/client';
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
@ -81,7 +81,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@Output() dateRangeChanged = new EventEmitter<DateRange>();
|
||||
@Output() selectedTagChanged = new EventEmitter<Tag>();
|
||||
@Output() filtersChanged = new EventEmitter<Filter[]>();
|
||||
|
||||
@ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger;
|
||||
@ViewChild('search', { static: true }) searchElement: ElementRef;
|
||||
@ -91,6 +91,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
|
||||
|
||||
public accounts: Account[] = [];
|
||||
public dateRangeFormControl = new FormControl<string>(undefined);
|
||||
public readonly dateRangeOptions = [
|
||||
{ label: $localize`Today`, value: '1d' },
|
||||
@ -110,6 +111,10 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
{ label: $localize`5Y`, value: '5y' },
|
||||
{ label: $localize`Max`, value: 'max' }
|
||||
];
|
||||
public filterForm = this.formBuilder.group({
|
||||
account: new FormControl<string>(undefined),
|
||||
tag: new FormControl<string>(undefined)
|
||||
});
|
||||
public isLoading = false;
|
||||
public isOpen = false;
|
||||
public placeholder = $localize`Find holding...`;
|
||||
@ -119,7 +124,6 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
holdings: []
|
||||
};
|
||||
public tags: Tag[] = [];
|
||||
public tagsFormControl = new FormControl<string>(undefined);
|
||||
|
||||
private keyManager: FocusKeyManager<AssistantListItemComponent>;
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -127,12 +131,14 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService
|
||||
private dataService: DataService,
|
||||
private formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
const { tags } = this.dataService.fetchInfo();
|
||||
|
||||
this.accounts = this.user?.accounts;
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
@ -140,6 +146,23 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
};
|
||||
});
|
||||
|
||||
this.filterForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ account, tag }) => {
|
||||
this.filtersChanged.emit([
|
||||
{
|
||||
id: account,
|
||||
type: 'ACCOUNT'
|
||||
},
|
||||
{
|
||||
id: tag,
|
||||
type: 'TAG'
|
||||
}
|
||||
]);
|
||||
|
||||
this.onCloseAssistant();
|
||||
});
|
||||
|
||||
this.searchFormControl.valueChanges
|
||||
.pipe(
|
||||
map((searchTerm) => {
|
||||
@ -181,8 +204,15 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
public ngOnChanges() {
|
||||
this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null);
|
||||
this.tagsFormControl.setValue(
|
||||
this.user?.settings?.['filters.tags']?.[0] ?? null
|
||||
|
||||
this.filterForm.setValue(
|
||||
{
|
||||
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
|
||||
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
|
||||
},
|
||||
{
|
||||
emitEvent: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -219,16 +249,6 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
public onTagChange() {
|
||||
const selectedTag = this.tags.find(({ id }) => {
|
||||
return id === this.tagsFormControl.value;
|
||||
});
|
||||
|
||||
this.selectedTagChanged.emit(selectedTag);
|
||||
|
||||
this.onCloseAssistant();
|
||||
}
|
||||
|
||||
public setIsOpen(aIsOpen: boolean) {
|
||||
this.isOpen = aIsOpen;
|
||||
}
|
||||
|
@ -86,57 +86,80 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!(isLoading || searchFormControl.value) && user?.settings?.isExperimentalFeatures"
|
||||
class="filter-container"
|
||||
>
|
||||
<mat-tab-group
|
||||
animationDuration="0"
|
||||
mat-align-tabs="start"
|
||||
[mat-stretch-tabs]="false"
|
||||
(click)="$event.stopPropagation();"
|
||||
<form [formGroup]="filterForm">
|
||||
<div
|
||||
*ngIf="!(isLoading || searchFormControl.value) && user?.settings?.isExperimentalFeatures"
|
||||
class="filter-container"
|
||||
>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label
|
||||
><ion-icon class="mr-2" name="calendar-clear-outline" /><span i18n
|
||||
>Date Range</span
|
||||
></ng-template
|
||||
>
|
||||
<div class="p-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-select
|
||||
[formControl]="dateRangeFormControl"
|
||||
(selectionChange)="onChangeDateRange($event.value)"
|
||||
>
|
||||
@for (range of dateRangeOptions; track range) {
|
||||
<mat-option [value]="range.value">{{ range.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label
|
||||
><ion-icon class="mr-2" name="pricetag-outline" /><span i18n
|
||||
>Tags</span
|
||||
></ng-template
|
||||
>
|
||||
<div class="p-3">
|
||||
<mat-radio-group
|
||||
color="primary"
|
||||
[formControl]="tagsFormControl"
|
||||
(change)="onTagChange()"
|
||||
<mat-tab-group
|
||||
animationDuration="0"
|
||||
mat-align-tabs="start"
|
||||
[mat-stretch-tabs]="false"
|
||||
(click)="$event.stopPropagation();"
|
||||
>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label
|
||||
><ion-icon name="calendar-clear-outline" /><span
|
||||
class="d-none d-sm-block ml-2"
|
||||
i18n
|
||||
>Date Range</span
|
||||
></ng-template
|
||||
>
|
||||
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
|
||||
>No tag</mat-radio-button
|
||||
>
|
||||
@for (tag of tags; track tag.id) {
|
||||
<mat-radio-button class="d-flex flex-column" [value]="tag.id"
|
||||
>{{ tag.name }}</mat-radio-button
|
||||
>
|
||||
}
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-select
|
||||
[formControl]="dateRangeFormControl"
|
||||
(selectionChange)="onChangeDateRange($event.value)"
|
||||
>
|
||||
@for (range of dateRangeOptions; track range) {
|
||||
<mat-option [value]="range.value">{{ range.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label
|
||||
><ion-icon name="albums-outline" /><span
|
||||
class="d-none d-sm-block ml-2"
|
||||
i18n
|
||||
>Accounts</span
|
||||
></ng-template
|
||||
>
|
||||
<div class="p-3">
|
||||
<mat-radio-group color="primary" formControlName="account">
|
||||
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
|
||||
>No account</mat-radio-button
|
||||
>
|
||||
@for (account of accounts; track account.id) {
|
||||
<mat-radio-button class="d-flex flex-column" [value]="account.id"
|
||||
>{{ account.name }}</mat-radio-button
|
||||
>
|
||||
}
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label
|
||||
><ion-icon name="pricetag-outline" /><span
|
||||
class="d-none d-sm-block ml-2"
|
||||
i18n
|
||||
>Tags</span
|
||||
></ng-template
|
||||
>
|
||||
<div class="p-3">
|
||||
<mat-radio-group color="primary" formControlName="tag">
|
||||
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
|
||||
>No tag</mat-radio-button
|
||||
>
|
||||
@for (tag of tags; track tag.id) {
|
||||
<mat-radio-button class="d-flex flex-column" [value]="tag.id"
|
||||
>{{ tag.name }}</mat-radio-button
|
||||
>
|
||||
}
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -2,6 +2,10 @@
|
||||
display: block;
|
||||
|
||||
.filter-container {
|
||||
.mat-mdc-tab-group {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
|
@ -32,7 +32,7 @@
|
||||
}"
|
||||
>
|
||||
<ng-container *ngIf="value === null">
|
||||
<span class="text-monospace text-muted">***</span>
|
||||
<span class="text-monospace text-muted">*****</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="value !== null">
|
||||
{{ formattedValue }}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "2.44.0",
|
||||
"version": "2.45.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/ghostfolio/ghostfolio",
|
||||
|
@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccessPermission" AS ENUM ('READ', 'READ_RESTRICTED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Access" ADD COLUMN "permissions" "AccessPermission"[] DEFAULT ARRAY['READ_RESTRICTED']::"AccessPermission"[];
|
@ -11,31 +11,32 @@ datasource db {
|
||||
|
||||
model Access {
|
||||
alias String?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
granteeUserId String?
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
permissions AccessPermission[] @default([READ_RESTRICTED])
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String
|
||||
GranteeUser User? @relation("accessGet", fields: [granteeUserId], references: [id])
|
||||
User User @relation("accessGive", fields: [userId], references: [id])
|
||||
GranteeUser User? @relation("accessGet", fields: [granteeUserId], references: [id])
|
||||
User User @relation("accessGive", fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model Account {
|
||||
balance Float @default(0)
|
||||
balances AccountBalance[]
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
currency String?
|
||||
id String @default(uuid())
|
||||
isDefault Boolean @default(false)
|
||||
isExcluded Boolean @default(false)
|
||||
name String?
|
||||
platformId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String
|
||||
Platform Platform? @relation(fields: [platformId], references: [id])
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
Order Order[]
|
||||
balance Float @default(0)
|
||||
balances AccountBalance[]
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
currency String?
|
||||
id String @default(uuid())
|
||||
isDefault Boolean @default(false)
|
||||
isExcluded Boolean @default(false)
|
||||
name String?
|
||||
platformId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String
|
||||
Platform Platform? @relation(fields: [platformId], references: [id])
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
Order Order[]
|
||||
|
||||
@@id([id, userId])
|
||||
}
|
||||
@ -196,6 +197,11 @@ model User {
|
||||
Subscription Subscription[]
|
||||
}
|
||||
|
||||
enum AccessPermission {
|
||||
READ
|
||||
READ_RESTRICTED
|
||||
}
|
||||
|
||||
enum AssetClass {
|
||||
CASH
|
||||
COMMODITY
|
||||
|
Reference in New Issue
Block a user