diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8f2f70e..1f319b3e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a close holding button to the holding detail dialog - Added the _Sponsors_ section to the about page - Extended the user detail dialog in the users section of the admin control panel +- Enable filtering (by account, holdings, tag or asset class) in public access ### Changed diff --git a/apps/api/src/app/access/access-filter.dto.ts b/apps/api/src/app/access/access-filter.dto.ts new file mode 100644 index 000000000..ac8c81bb3 --- /dev/null +++ b/apps/api/src/app/access/access-filter.dto.ts @@ -0,0 +1,40 @@ +import { DataSource } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested +} from 'class-validator'; + +export class HoldingFilterDto { + @IsEnum(DataSource) + dataSource: DataSource; + + @IsString() + symbol: string; +} + +export class AccessFilterDto { + @IsArray() + @IsOptional() + @IsString({ each: true }) + accountIds?: string[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + assetClasses?: string[]; + + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => HoldingFilterDto) + holdings?: HoldingFilterDto[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + tagIds?: string[]; +} diff --git a/apps/api/src/app/access/access-settings.interface.ts b/apps/api/src/app/access/access-settings.interface.ts new file mode 100644 index 000000000..8e173fdae --- /dev/null +++ b/apps/api/src/app/access/access-settings.interface.ts @@ -0,0 +1 @@ +export type { AccessSettings } from '@ghostfolio/common/interfaces'; diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index cb1e2d4af..791ea7d26 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -22,6 +22,7 @@ import { AuthGuard } from '@nestjs/passport'; import { Access as AccessModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { AccessSettings } from './access-settings.interface'; import { AccessService } from './access.service'; import { CreateAccessDto } from './create-access.dto'; import { UpdateAccessDto } from './update-access.dto'; @@ -34,40 +35,6 @@ export class AccessController { @Inject(REQUEST) private readonly request: RequestWithUser ) {} - @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async getAllAccesses(): Promise { - const accessesWithGranteeUser = await this.accessService.accesses({ - include: { - granteeUser: true - }, - orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }], - where: { userId: this.request.user.id } - }); - - return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions }) => { - if (granteeUser) { - return { - alias, - id, - permissions, - grantee: granteeUser?.id, - type: 'PRIVATE' - }; - } - - return { - alias, - id, - permissions, - grantee: 'Public', - type: 'PUBLIC' - }; - } - ); - } - @HasPermission(permissions.createAccess) @Post() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -85,12 +52,17 @@ export class AccessController { } try { + const settings: AccessSettings = data.filter + ? { filter: data.filter } + : {}; + return this.accessService.createAccess({ alias: data.alias || undefined, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + settings: settings as any, user: { connect: { id: this.request.user.id } } }); } catch { @@ -122,6 +94,48 @@ export class AccessController { }); } + @Get() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getAllAccesses(): Promise { + const accessesWithGranteeUser = await this.accessService.accesses({ + include: { + granteeUser: true + }, + orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }], + where: { userId: this.request.user.id } + }); + + return accessesWithGranteeUser.map( + ({ + alias, + granteeUser, + id, + permissions: accessPermissions, + settings + }) => { + if (granteeUser) { + return { + alias, + id, + permissions: accessPermissions, + settings: settings as AccessSettings, + grantee: granteeUser?.id, + type: 'PRIVATE' + }; + } + + return { + alias, + id, + permissions: accessPermissions, + settings: settings as AccessSettings, + grantee: 'Public', + type: 'PUBLIC' + }; + } + ); + } + @HasPermission(permissions.updateAccess) @Put(':id') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -152,13 +166,18 @@ export class AccessController { } try { + const settings: AccessSettings = data.filter + ? { filter: data.filter } + : {}; + return this.accessService.updateAccess({ data: { alias: data.alias, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : { disconnect: true }, - permissions: data.permissions + permissions: data.permissions, + settings: settings as any }, where: { id } }); diff --git a/apps/api/src/app/access/create-access.dto.ts b/apps/api/src/app/access/create-access.dto.ts index 087df7183..29d92bb8b 100644 --- a/apps/api/src/app/access/create-access.dto.ts +++ b/apps/api/src/app/access/create-access.dto.ts @@ -1,3 +1,5 @@ +import { AccessFilter } from '@ghostfolio/common/interfaces'; + import { AccessPermission } from '@prisma/client'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; @@ -6,6 +8,9 @@ export class CreateAccessDto { @IsString() alias?: string; + @IsOptional() + filter?: AccessFilter; + @IsOptional() @IsUUID() granteeUserId?: string; diff --git a/apps/api/src/app/access/update-access.dto.ts b/apps/api/src/app/access/update-access.dto.ts index 2850186f9..f7c163b0d 100644 --- a/apps/api/src/app/access/update-access.dto.ts +++ b/apps/api/src/app/access/update-access.dto.ts @@ -1,3 +1,5 @@ +import { AccessFilter } from '@ghostfolio/common/interfaces'; + import { AccessPermission } from '@prisma/client'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; @@ -6,6 +8,9 @@ export class UpdateAccessDto { @IsString() alias?: string; + @IsOptional() + filter?: AccessFilter; + @IsOptional() @IsUUID() granteeUserId?: string; diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index b09ced4fb..8a59ffc3a 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -8,7 +8,11 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { getSum } from '@ghostfolio/common/helper'; -import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; +import { + AccessSettings, + Filter, + PublicPortfolioResponse +} from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -20,7 +24,7 @@ import { UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; -import { Type as ActivityType } from '@prisma/client'; +import { Type as ActivityType, AssetSubClass } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -61,6 +65,62 @@ export class PublicController { hasDetails = user.subscription.type === 'Premium'; } + // Get filter configuration from access settings + const accessSettings = (access.settings ?? {}) as AccessSettings; + const accessFilter = accessSettings.filter; + + // Convert access filter to portfolio filters + const portfolioFilters: Filter[] = []; + + if (accessFilter) { + // Add account filters + if (accessFilter.accountIds && accessFilter.accountIds.length > 0) { + portfolioFilters.push( + ...accessFilter.accountIds.map((accountId) => ({ + id: accountId, + type: 'ACCOUNT' as const + })) + ); + } + + // Add asset class filters + if (accessFilter.assetClasses && accessFilter.assetClasses.length > 0) { + portfolioFilters.push( + ...accessFilter.assetClasses.map((assetClass) => ({ + id: assetClass, + type: 'ASSET_CLASS' as const + })) + ); + } + + // Add tag filters + if (accessFilter.tagIds && accessFilter.tagIds.length > 0) { + portfolioFilters.push( + ...accessFilter.tagIds.map((tagId) => ({ + id: tagId, + type: 'TAG' as const + })) + ); + } + + // Add holding filters (symbol + dataSource) + // Each holding needs both DATA_SOURCE and SYMBOL filters + if (accessFilter.holdings && accessFilter.holdings.length > 0) { + accessFilter.holdings.forEach((holding) => { + portfolioFilters.push( + { + id: holding.dataSource, + type: 'DATA_SOURCE' as const + }, + { + id: holding.symbol, + type: 'SYMBOL' as const + } + ); + }); + } + } + const [ { createdAt, holdings, markets }, { performance: performance1d }, @@ -68,6 +128,7 @@ export class PublicController { { performance: performanceYtd } ] = await Promise.all([ this.portfolioService.getDetails({ + filters: portfolioFilters.length > 0 ? portfolioFilters : undefined, impersonationId: access.userId, userId: user.id, withMarkets: true @@ -75,29 +136,84 @@ export class PublicController { ...['1d', 'max', 'ytd'].map((dateRange) => { return this.portfolioService.getPerformance({ dateRange, + filters: portfolioFilters.length > 0 ? portfolioFilters : undefined, impersonationId: undefined, userId: user.id }); }) ]); + // Filter out only the base currency cash holdings + const baseCurrency = + user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; + const filteredHoldings = Object.fromEntries( + Object.entries(holdings).filter(([, holding]) => { + // Remove only cash holdings that match the base currency + const isCash = holding.assetSubClass === AssetSubClass.CASH; + const isBaseCurrency = holding.symbol === baseCurrency; + return !(isCash && isBaseCurrency); + }) + ); + + // Use filters for activities, but exclude DATA_SOURCE/SYMBOL filters + // if there are multiple holdings (the service can't handle multiple symbol filters) + const hasMultipleHoldingFilters = + accessFilter?.holdings && accessFilter.holdings.length > 1; + + const activityFilters = portfolioFilters.filter((filter) => { + // Always include ACCOUNT, ASSET_CLASS, TAG filters + if ( + filter.type === 'ACCOUNT' || + filter.type === 'ASSET_CLASS' || + filter.type === 'TAG' + ) { + return true; + } + + // Include DATA_SOURCE and SYMBOL only if there's a single holding filter + if ( + !hasMultipleHoldingFilters && + (filter.type === 'DATA_SOURCE' || filter.type === 'SYMBOL') + ) { + return true; + } + + return false; + }); + const { activities } = await this.orderService.getOrders({ + filters: activityFilters.length > 0 ? activityFilters : undefined, includeDrafts: false, sortColumn: 'date', sortDirection: 'desc', - take: 10, + take: hasMultipleHoldingFilters ? 1000 : 10, // Get more if we need to filter manually types: [ActivityType.BUY, ActivityType.SELL], userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, userId: user.id, withExcludedAccountsAndActivities: false }); + // If multiple holdings, filter activities manually + let filteredActivities = activities; + if (hasMultipleHoldingFilters && accessFilter.holdings) { + filteredActivities = activities.filter((activity) => { + return accessFilter.holdings.some( + (holding) => + activity.SymbolProfile.dataSource === holding.dataSource && + activity.SymbolProfile.symbol === holding.symbol + ); + }); + } + + // Take only the latest 10 activities after filtering + const latestActivitiesData = filteredActivities.slice(0, 10); + // Experimental const latestActivities = this.configurationService.get( 'ENABLE_FEATURE_SUBSCRIPTION' ) ? [] - : activities.map( + : latestActivitiesData.map( ({ currency, date, @@ -128,12 +244,12 @@ export class PublicController { }); const publicPortfolioResponse: PublicPortfolioResponse = { + alias: access.alias, createdAt, hasDetails, latestActivities, - markets, - alias: access.alias, holdings: {}, + markets, performance: { '1d': { relativeChange: @@ -151,19 +267,23 @@ export class PublicController { }; const totalValue = getSum( - Object.values(holdings).map(({ currency, marketPrice, quantity }) => { - return new Big( - this.exchangeRateDataService.toCurrency( - quantity * marketPrice, - currency, - this.request.user?.settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY - ) - ); - }) + Object.values(filteredHoldings).map( + ({ currency, marketPrice, quantity }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + quantity * marketPrice, + currency, + this.request.user?.settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ) + ); + } + ) ).toNumber(); - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + for (const [symbol, portfolioPosition] of Object.entries( + filteredHoldings + )) { publicPortfolioResponse.holdings[symbol] = { allocationInPercentage: portfolioPosition.valueInBaseCurrency / totalValue, diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index 315f86244..a099504a4 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -1,8 +1,7 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto'; -import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; +import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; import { ChangeDetectionStrategy, @@ -13,6 +12,7 @@ import { OnInit } from '@angular/core'; import { + AbstractControl, FormBuilder, FormGroup, FormsModule, @@ -28,9 +28,13 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { AccessPermission } from '@prisma/client'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, Subject, catchError, takeUntil } from 'rxjs'; +import { NotificationService } from '../../../core/notification/notification.service'; +import { DataService } from '../../../services/data.service'; +import { validateObjectForForm } from '../../../util/form.util'; import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; @Component({ @@ -54,13 +58,20 @@ export class GfCreateOrUpdateAccessDialogComponent { public accessForm: FormGroup; public mode: 'create' | 'update'; + public showFilterPanel = false; + + // Datos para el filtro + public accounts: AccountWithPlatform[] = []; + public assetClasses: Filter[] = []; + public holdings: PortfolioPosition[] = []; + public tags: Filter[] = []; private unsubscribeSubject = new Subject(); public constructor( + public dialogRef: MatDialogRef, private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams, - public dialogRef: MatDialogRef, private dataService: DataService, private formBuilder: FormBuilder, private notificationService: NotificationService @@ -73,14 +84,23 @@ export class GfCreateOrUpdateAccessDialogComponent this.accessForm = this.formBuilder.group({ alias: [this.data.access.alias], + filterAccount: [null], + filterAssetClass: [null], + filterHolding: [null], + filterTag: [null], granteeUserId: [ this.data.access.grantee, - isPublic ? null : Validators.required + isPublic + ? null + : [(control: AbstractControl) => Validators.required(control)] + ], + permissions: [ + this.data.access.permissions[0], + [(control: AbstractControl) => Validators.required(control)] ], - permissions: [this.data.access.permissions[0], Validators.required], type: [ { disabled: this.mode === 'update', value: this.data.access.type }, - Validators.required + [(control: AbstractControl) => Validators.required(control)] ] }); @@ -89,17 +109,33 @@ export class GfCreateOrUpdateAccessDialogComponent const permissionsControl = this.accessForm.get('permissions'); if (accessType === 'PRIVATE') { - granteeUserIdControl.setValidators(Validators.required); + granteeUserIdControl.setValidators([ + (control: AbstractControl) => Validators.required(control) + ]); + this.showFilterPanel = false; + // Limpiar los filtros + this.accessForm.get('filterAccount')?.setValue(null); + this.accessForm.get('filterAssetClass')?.setValue(null); + this.accessForm.get('filterHolding')?.setValue(null); + this.accessForm.get('filterTag')?.setValue(null); } else { granteeUserIdControl.clearValidators(); granteeUserIdControl.setValue(null); permissionsControl.setValue(this.data.access.permissions[0]); + this.showFilterPanel = true; + this.loadFilterData(); } granteeUserIdControl.updateValueAndValidity(); this.changeDetectorRef.markForCheck(); }); + + // Si ya es público al iniciar, mostrar el panel y cargar datos + if (isPublic) { + this.showFilterPanel = true; + this.loadFilterData(); + } } public onCancel() { @@ -119,11 +155,154 @@ export class GfCreateOrUpdateAccessDialogComponent this.unsubscribeSubject.complete(); } + private buildFilterObject(): + | { + accountIds?: string[]; + assetClasses?: string[]; + holdings?: { dataSource: string; symbol: string }[]; + tagIds?: string[]; + } + | undefined { + const filterAccount = this.accessForm.get('filterAccount')?.value as + | string + | null; + const filterAssetClass = this.accessForm.get('filterAssetClass')?.value as + | string + | null; + const filterHolding = this.accessForm.get('filterHolding')?.value as + | string + | null; + const filterTag = this.accessForm.get('filterTag')?.value as string | null; + + // Solo retornar filtro si hay al menos un campo con valor + if (!filterAccount && !filterAssetClass && !filterHolding && !filterTag) { + return undefined; + } + + const filter: { + accountIds?: string[]; + assetClasses?: string[]; + holdings?: { dataSource: string; symbol: string }[]; + tagIds?: string[]; + } = {}; + + if (filterAccount) { + filter.accountIds = [filterAccount]; + } + + if (filterAssetClass) { + filter.assetClasses = [filterAssetClass]; + } + + if (filterHolding) { + // Buscar el holding seleccionado para obtener dataSource y symbol + const holding = this.holdings.find((h) => h.symbol === filterHolding); + if (holding) { + filter.holdings = [ + { + dataSource: holding.dataSource, + symbol: holding.symbol + } + ]; + } + } + + if (filterTag) { + filter.tagIds = [filterTag]; + } + + return filter; + } + + private loadFilterData() { + const existingFilter = this.data.access.settings?.filter; + + // Cargar cuentas + this.dataService + .fetchAccounts() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((response) => { + this.accounts = response.accounts; + + // Si existe un filtro de cuenta, establecerlo + if (existingFilter?.accountIds?.[0]) { + this.accessForm + .get('filterAccount') + ?.setValue(existingFilter.accountIds[0]); + } + + this.changeDetectorRef.markForCheck(); + }); + + // Cargar holdings y asset classes + this.dataService + .fetchPortfolioDetails({}) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((response) => { + if (response.holdings) { + this.holdings = Object.values(response.holdings); + + // Extraer asset classes únicas + const assetClassesSet = new Set(); + Object.values(response.holdings).forEach((holding) => { + if (holding.assetClass) { + assetClassesSet.add(holding.assetClass); + } + }); + this.assetClasses = Array.from(assetClassesSet).map((ac) => ({ + id: ac, + label: ac, + type: 'ASSET_CLASS' as const + })); + + // Si existe un filtro de asset class, establecerlo + if (existingFilter?.assetClasses?.[0]) { + this.accessForm + .get('filterAssetClass') + ?.setValue(existingFilter.assetClasses[0]); + } + + // Si existe un filtro de holding, establecerlo + if (existingFilter?.holdings?.[0]?.symbol) { + this.accessForm + .get('filterHolding') + ?.setValue(existingFilter.holdings[0].symbol); + } + } + this.changeDetectorRef.markForCheck(); + }); + + // Cargar tags + this.dataService + .fetchTags() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((response) => { + this.tags = response.map((tag) => ({ + id: tag.id, + label: tag.name, + type: 'TAG' as const + })); + + // Si existe un filtro de tag, establecerlo + if (existingFilter?.tagIds?.[0]) { + this.accessForm.get('filterTag')?.setValue(existingFilter.tagIds[0]); + } + + this.changeDetectorRef.markForCheck(); + }); + } + private async createAccess() { + // Construir el objeto filter si estamos en modo PUBLIC + const filter = this.showFilterPanel ? this.buildFilterObject() : undefined; + const access: CreateAccessDto = { - alias: this.accessForm.get('alias').value, - granteeUserId: this.accessForm.get('granteeUserId').value, - permissions: [this.accessForm.get('permissions').value] + alias: this.accessForm.get('alias')?.value as string, + filter: filter, + granteeUserId: this.accessForm.get('granteeUserId')?.value as string, + permissions: [ + this.accessForm.get('permissions')?.value as AccessPermission + ] }; try { @@ -136,8 +315,8 @@ export class GfCreateOrUpdateAccessDialogComponent this.dataService .postAccess(access) .pipe( - catchError((error) => { - if (error.status === StatusCodes.BAD_REQUEST) { + catchError((error: { status?: number }) => { + if (error.status === (StatusCodes.BAD_REQUEST as number)) { this.notificationService.alert({ title: $localize`Oops! Could not grant access.` }); @@ -156,11 +335,17 @@ export class GfCreateOrUpdateAccessDialogComponent } private async updateAccess() { + // Construir el objeto filter si estamos en modo PUBLIC + const filter = this.showFilterPanel ? this.buildFilterObject() : undefined; + const access: UpdateAccessDto = { - alias: this.accessForm.get('alias').value, - granteeUserId: this.accessForm.get('granteeUserId').value, + alias: this.accessForm.get('alias')?.value as string, + filter: filter, + granteeUserId: this.accessForm.get('granteeUserId')?.value as string, id: this.data.access.id, - permissions: [this.accessForm.get('permissions').value] + permissions: [ + this.accessForm.get('permissions')?.value as AccessPermission + ] }; try { @@ -173,8 +358,8 @@ export class GfCreateOrUpdateAccessDialogComponent this.dataService .putAccess(access) .pipe( - catchError(({ status }) => { - if (status.status === StatusCodes.BAD_REQUEST) { + catchError((error: { status?: number }) => { + if (error.status === (StatusCodes.BAD_REQUEST as number)) { this.notificationService.alert({ title: $localize`Oops! Could not update access.` }); diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index 11669041d..42001c3b4 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -59,6 +59,80 @@ } + + @if (showFilterPanel) { +
+

+ Filter Settings (Optional) +

+

+ Configure which accounts, holdings, tags, and asset classes will be + visible in this public access. Leave empty to show all data. +

+ +
+ + Account + + + @for (account of accounts; track account.id) { + + {{ account.name }} + + } + + +
+ +
+ + Holding + + + @for (holding of holdings; track holding.symbol) { + +
+ {{ holding.name }} +
+ {{ holding.symbol }} · {{ holding.currency }} +
+
+ } +
+
+
+ +
+ + Tag + + + @for (tag of tags; track tag.id) { + {{ tag.label }} + } + + +
+ +
+ + Asset Class + + + @for (assetClass of assetClasses; track assetClass.id) { + {{ + assetClass.label + }} + } + + +
+
+ }