mirror of https://github.com/ghostfolio/ghostfolio
173 changed files with 10581 additions and 9725 deletions
@ -0,0 +1,87 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; |
|||
import { |
|||
AssetProfilesResponse, |
|||
EnhancedSymbolProfile |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Patch, |
|||
Query, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource, Prisma } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { AssetProfilesService } from './asset-profiles.service'; |
|||
|
|||
@Controller('asset-profiles') |
|||
export class AssetProfilesController { |
|||
public constructor( |
|||
private readonly apiService: ApiService, |
|||
private readonly assetProfilesService: AssetProfilesService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getAssetProfiles( |
|||
@Query('assetSubClasses') filterByAssetSubClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('presetId') presetId?: MarketDataPreset, |
|||
@Query('query') filterBySearchQuery?: string, |
|||
@Query('skip') skip?: number, |
|||
@Query('sortColumn') sortColumn?: string, |
|||
@Query('sortDirection') sortDirection?: Prisma.SortOrder, |
|||
@Query('take') take?: number |
|||
): Promise<AssetProfilesResponse> { |
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAssetSubClasses, |
|||
filterByDataSource, |
|||
filterBySearchQuery |
|||
}); |
|||
|
|||
return this.assetProfilesService.getAssetProfiles({ |
|||
filters, |
|||
presetId, |
|||
sortColumn, |
|||
sortDirection, |
|||
skip: isNaN(skip) ? undefined : skip, |
|||
take: isNaN(take) ? undefined : take |
|||
}); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Patch(':dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async updateAssetProfileData( |
|||
@Body() assetProfileData: UpdateAssetProfileDataDto, |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<EnhancedSymbolProfile> { |
|||
if (!this.request.user.settings.settings.isExperimentalFeatures) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return this.assetProfilesService.updateAssetProfileData( |
|||
{ dataSource, symbol }, |
|||
assetProfileData |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
|||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AssetProfilesController } from './asset-profiles.controller'; |
|||
import { AssetProfilesService } from './asset-profiles.service'; |
|||
|
|||
@Module({ |
|||
controllers: [AssetProfilesController], |
|||
imports: [ |
|||
ActivitiesModule, |
|||
ApiModule, |
|||
BenchmarkModule, |
|||
ExchangeRateDataModule, |
|||
PrismaModule, |
|||
SymbolProfileModule |
|||
], |
|||
providers: [AssetProfilesService] |
|||
}) |
|||
export class AssetProfilesModule {} |
|||
@ -0,0 +1,482 @@ |
|||
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; |
|||
import { |
|||
applyAssetProfileOverrides, |
|||
getAssetProfileIdentifier, |
|||
getCurrencyFromSymbol, |
|||
isCurrency |
|||
} from '@ghostfolio/common/helper'; |
|||
import { |
|||
AssetProfileItem, |
|||
AssetProfileIdentifier, |
|||
AssetProfilesResponse, |
|||
EnhancedSymbolProfile, |
|||
Filter |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { MarketDataPreset } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable, NotFoundException } from '@nestjs/common'; |
|||
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; |
|||
import { groupBy } from 'lodash'; |
|||
|
|||
@Injectable() |
|||
export class AssetProfilesService { |
|||
public constructor( |
|||
private readonly activitiesService: ActivitiesService, |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
public async getAssetProfiles({ |
|||
filters = [], |
|||
presetId, |
|||
sortColumn, |
|||
sortDirection = 'asc', |
|||
skip, |
|||
take = Number.MAX_SAFE_INTEGER |
|||
}: { |
|||
filters?: Filter[]; |
|||
presetId?: MarketDataPreset; |
|||
skip?: number; |
|||
sortColumn?: string; |
|||
sortDirection?: Prisma.SortOrder; |
|||
take?: number; |
|||
}): Promise<AssetProfilesResponse> { |
|||
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> = |
|||
[{ symbol: 'asc' }]; |
|||
const where: Prisma.SymbolProfileWhereInput = {}; |
|||
|
|||
if (presetId === 'BENCHMARKS') { |
|||
const benchmarkAssetProfiles = |
|||
await this.benchmarkService.getBenchmarkAssetProfiles(); |
|||
|
|||
where.id = { |
|||
in: benchmarkAssetProfiles.map(({ id }) => { |
|||
return id; |
|||
}) |
|||
}; |
|||
} else if (presetId === 'CURRENCIES') { |
|||
return this.getAssetProfilesForCurrencies(); |
|||
} else if ( |
|||
presetId === 'ETF_WITHOUT_COUNTRIES' || |
|||
presetId === 'ETF_WITHOUT_SECTORS' |
|||
) { |
|||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; |
|||
} else if (presetId === 'NO_ACTIVITIES') { |
|||
where.activities = { |
|||
none: {} |
|||
}; |
|||
} |
|||
|
|||
const searchQuery = filters.find(({ type }) => { |
|||
return type === 'SEARCH_QUERY'; |
|||
})?.id; |
|||
|
|||
const { |
|||
ASSET_SUB_CLASS: filtersByAssetSubClass, |
|||
DATA_SOURCE: filtersByDataSource |
|||
} = groupBy(filters, ({ type }) => { |
|||
return type; |
|||
}); |
|||
|
|||
const marketDataItems = await this.prismaService.marketData.groupBy({ |
|||
_count: true, |
|||
by: ['dataSource', 'symbol'] |
|||
}); |
|||
|
|||
if (filtersByAssetSubClass) { |
|||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; |
|||
} |
|||
|
|||
if (filtersByDataSource) { |
|||
where.dataSource = DataSource[filtersByDataSource[0].id]; |
|||
} |
|||
|
|||
if (searchQuery) { |
|||
where.OR = [ |
|||
{ id: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ isin: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ name: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } |
|||
]; |
|||
} |
|||
|
|||
if (sortColumn) { |
|||
orderBy = [{ [sortColumn]: sortDirection }]; |
|||
|
|||
if (sortColumn === 'activitiesCount') { |
|||
orderBy = [ |
|||
{ |
|||
activities: { |
|||
_count: sortDirection |
|||
} |
|||
} |
|||
]; |
|||
} |
|||
} |
|||
|
|||
const extendedPrismaClient = this.getExtendedPrismaClient(); |
|||
|
|||
const symbolProfileResult = await Promise.all([ |
|||
extendedPrismaClient.symbolProfile.findMany({ |
|||
skip, |
|||
take, |
|||
where, |
|||
orderBy: [...orderBy, { id: sortDirection }], |
|||
select: { |
|||
_count: { |
|||
select: { |
|||
activities: true, |
|||
watchedBy: true |
|||
} |
|||
}, |
|||
activities: { |
|||
orderBy: [{ date: 'asc' }], |
|||
select: { date: true }, |
|||
take: 1 |
|||
}, |
|||
assetClass: true, |
|||
assetSubClass: true, |
|||
comment: true, |
|||
countries: true, |
|||
currency: true, |
|||
dataSource: true, |
|||
id: true, |
|||
isin: true, |
|||
isActive: true, |
|||
isUsedByUsersWithSubscription: true, |
|||
name: true, |
|||
scraperConfiguration: true, |
|||
sectors: true, |
|||
symbol: true, |
|||
SymbolProfileOverrides: true |
|||
} |
|||
}), |
|||
this.prismaService.symbolProfile.count({ where }) |
|||
]); |
|||
const symbolProfiles = symbolProfileResult[0]; |
|||
let count = symbolProfileResult[1]; |
|||
|
|||
const lastMarketPrices = await this.prismaService.marketData.findMany({ |
|||
distinct: ['dataSource', 'symbol'], |
|||
orderBy: { date: 'desc' }, |
|||
select: { |
|||
dataSource: true, |
|||
marketPrice: true, |
|||
symbol: true |
|||
}, |
|||
where: { |
|||
dataSource: { |
|||
in: symbolProfiles.map(({ dataSource }) => { |
|||
return dataSource; |
|||
}) |
|||
}, |
|||
symbol: { |
|||
in: symbolProfiles.map(({ symbol }) => { |
|||
return symbol; |
|||
}) |
|||
} |
|||
} |
|||
}); |
|||
|
|||
const lastMarketPriceMap = new Map<string, number>(); |
|||
|
|||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
|||
lastMarketPriceMap.set( |
|||
getAssetProfileIdentifier({ dataSource, symbol }), |
|||
marketPrice |
|||
); |
|||
} |
|||
|
|||
let assetProfiles: AssetProfileItem[] = await Promise.all( |
|||
symbolProfiles.map(async (assetProfile) => { |
|||
const { |
|||
_count, |
|||
activities, |
|||
comment, |
|||
currency, |
|||
dataSource, |
|||
id, |
|||
isin, |
|||
isActive, |
|||
isUsedByUsersWithSubscription, |
|||
symbol |
|||
} = assetProfile; |
|||
|
|||
const { assetClass, assetSubClass, countries, name, sectors } = |
|||
applyAssetProfileOverrides( |
|||
assetProfile, |
|||
assetProfile.SymbolProfileOverrides |
|||
); |
|||
|
|||
const countriesCount = countries ? Object.keys(countries).length : 0; |
|||
|
|||
const lastMarketPrice = lastMarketPriceMap.get( |
|||
getAssetProfileIdentifier({ dataSource, symbol }) |
|||
); |
|||
|
|||
const marketDataItemCount = |
|||
marketDataItems.find((marketDataItem) => { |
|||
return ( |
|||
marketDataItem.dataSource === dataSource && |
|||
marketDataItem.symbol === symbol |
|||
); |
|||
})?._count ?? 0; |
|||
|
|||
const sectorsCount = sectors ? Object.keys(sectors).length : 0; |
|||
|
|||
return { |
|||
assetClass, |
|||
assetSubClass, |
|||
comment, |
|||
countriesCount, |
|||
currency, |
|||
dataSource, |
|||
id, |
|||
isActive, |
|||
isin, |
|||
lastMarketPrice, |
|||
marketDataItemCount, |
|||
name, |
|||
sectorsCount, |
|||
symbol, |
|||
activitiesCount: _count.activities, |
|||
date: activities?.[0]?.date, |
|||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, |
|||
watchedByCount: _count.watchedBy |
|||
}; |
|||
}) |
|||
); |
|||
|
|||
if (presetId) { |
|||
if (presetId === 'ETF_WITHOUT_COUNTRIES') { |
|||
assetProfiles = assetProfiles.filter(({ countriesCount }) => { |
|||
return countriesCount === 0; |
|||
}); |
|||
} else if (presetId === 'ETF_WITHOUT_SECTORS') { |
|||
assetProfiles = assetProfiles.filter(({ sectorsCount }) => { |
|||
return sectorsCount === 0; |
|||
}); |
|||
} |
|||
|
|||
count = assetProfiles.length; |
|||
} |
|||
|
|||
return { |
|||
assetProfiles, |
|||
count |
|||
}; |
|||
} |
|||
|
|||
public async updateAssetProfileData( |
|||
{ dataSource, symbol }: AssetProfileIdentifier, |
|||
assetProfileData: UpdateAssetProfileDataDto |
|||
): Promise<EnhancedSymbolProfile> { |
|||
const notFoundMessage = `Could not find the asset profile for ${symbol} (${dataSource})`; |
|||
|
|||
const data = this.getAssetProfileDataUpdate(assetProfileData); |
|||
|
|||
if (Object.keys(data).length > 0) { |
|||
try { |
|||
await this.symbolProfileService.updateSymbolProfile( |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
this.symbolProfileService.getAssetProfileUpdateInput( |
|||
{ dataSource, symbol }, |
|||
data |
|||
) |
|||
); |
|||
} catch (error) { |
|||
if ( |
|||
error instanceof Prisma.PrismaClientKnownRequestError && |
|||
error.code === 'P2025' |
|||
) { |
|||
throw new NotFoundException(notFoundMessage); |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
} |
|||
]); |
|||
|
|||
if (!assetProfile) { |
|||
throw new NotFoundException(notFoundMessage); |
|||
} |
|||
|
|||
return assetProfile; |
|||
} |
|||
|
|||
private getAssetProfileDataUpdate({ |
|||
countries, |
|||
holdings, |
|||
sectors |
|||
}: UpdateAssetProfileDataDto): Pick< |
|||
Prisma.SymbolProfileUpdateInput, |
|||
'countries' | 'holdings' | 'sectors' |
|||
> { |
|||
const data: Pick< |
|||
Prisma.SymbolProfileUpdateInput, |
|||
'countries' | 'holdings' | 'sectors' |
|||
> = {}; |
|||
|
|||
if (countries !== undefined) { |
|||
data.countries = countries as Prisma.JsonArray; |
|||
} |
|||
|
|||
if (holdings !== undefined) { |
|||
data.holdings = holdings as Prisma.JsonArray; |
|||
} |
|||
|
|||
if (sectors !== undefined) { |
|||
data.sectors = sectors as Prisma.JsonArray; |
|||
} |
|||
|
|||
return data; |
|||
} |
|||
|
|||
private async getAssetProfilesForCurrencies(): Promise<AssetProfilesResponse> { |
|||
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); |
|||
|
|||
const [lastMarketPrices, marketDataItems] = await Promise.all([ |
|||
this.prismaService.marketData.findMany({ |
|||
distinct: ['dataSource', 'symbol'], |
|||
orderBy: { date: 'desc' }, |
|||
select: { |
|||
dataSource: true, |
|||
marketPrice: true, |
|||
symbol: true |
|||
}, |
|||
where: { |
|||
dataSource: { |
|||
in: currencyPairs.map(({ dataSource }) => { |
|||
return dataSource; |
|||
}) |
|||
}, |
|||
symbol: { |
|||
in: currencyPairs.map(({ symbol }) => { |
|||
return symbol; |
|||
}) |
|||
} |
|||
} |
|||
}), |
|||
this.prismaService.marketData.groupBy({ |
|||
_count: true, |
|||
by: ['dataSource', 'symbol'] |
|||
}) |
|||
]); |
|||
|
|||
const lastMarketPriceMap = new Map<string, number>(); |
|||
|
|||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
|||
lastMarketPriceMap.set( |
|||
getAssetProfileIdentifier({ dataSource, symbol }), |
|||
marketPrice |
|||
); |
|||
} |
|||
|
|||
const assetProfilePromises: Promise<AssetProfileItem>[] = currencyPairs.map( |
|||
async ({ dataSource, symbol }) => { |
|||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; |
|||
let currency: EnhancedSymbolProfile['currency'] = '-'; |
|||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; |
|||
|
|||
if (isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
currency = getCurrencyFromSymbol(symbol); |
|||
({ activitiesCount, dateOfFirstActivity } = |
|||
await this.activitiesService.getStatisticsByCurrency(currency)); |
|||
} |
|||
|
|||
const lastMarketPrice = lastMarketPriceMap.get( |
|||
getAssetProfileIdentifier({ dataSource, symbol }) |
|||
); |
|||
|
|||
const marketDataItemCount = |
|||
marketDataItems.find((marketDataItem) => { |
|||
return ( |
|||
marketDataItem.dataSource === dataSource && |
|||
marketDataItem.symbol === symbol |
|||
); |
|||
})?._count ?? 0; |
|||
|
|||
return { |
|||
activitiesCount, |
|||
currency, |
|||
dataSource, |
|||
lastMarketPrice, |
|||
marketDataItemCount, |
|||
symbol, |
|||
assetClass: AssetClass.LIQUIDITY, |
|||
assetSubClass: AssetSubClass.CASH, |
|||
countriesCount: 0, |
|||
date: dateOfFirstActivity, |
|||
id: undefined, |
|||
isActive: true, |
|||
name: symbol, |
|||
sectorsCount: 0, |
|||
watchedByCount: 0 |
|||
}; |
|||
} |
|||
); |
|||
|
|||
const assetProfiles = await Promise.all(assetProfilePromises); |
|||
return { assetProfiles, count: assetProfiles.length }; |
|||
} |
|||
|
|||
private getExtendedPrismaClient() { |
|||
const symbolProfileExtension = Prisma.defineExtension((client) => { |
|||
return client.$extends({ |
|||
result: { |
|||
symbolProfile: { |
|||
isUsedByUsersWithSubscription: { |
|||
compute: async ({ id }) => { |
|||
const { _count } = |
|||
await this.prismaService.symbolProfile.findUnique({ |
|||
select: { |
|||
_count: { |
|||
select: { |
|||
activities: { |
|||
where: { |
|||
user: { |
|||
subscriptions: { |
|||
some: { |
|||
expiresAt: { |
|||
gt: new Date() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
where: { |
|||
id |
|||
} |
|||
}); |
|||
|
|||
return _count.activities > 0; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
return this.prismaService.$extends(symbolProfileExtension); |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import { countries } from 'countries-list'; |
|||
|
|||
export function getCountryCodeByName({ |
|||
aliases = {}, |
|||
name |
|||
}: { |
|||
aliases?: Record<string, string>; |
|||
name: string; |
|||
}): string { |
|||
for (const [code, country] of Object.entries(countries)) { |
|||
if (country.name === name || country.name === aliases[name]) { |
|||
return code; |
|||
} |
|||
} |
|||
|
|||
return undefined; |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
import { SECTORS } from '@ghostfolio/common/config'; |
|||
import { SectorName } from '@ghostfolio/common/types'; |
|||
|
|||
import { Logger } from '@nestjs/common'; |
|||
|
|||
export function getSectorName({ |
|||
aliases = {}, |
|||
name |
|||
}: { |
|||
aliases?: Record<string, SectorName>; |
|||
name: string; |
|||
}): SectorName { |
|||
if (aliases[name]) { |
|||
return aliases[name]; |
|||
} |
|||
|
|||
if ((SECTORS as readonly string[]).includes(name)) { |
|||
return name as SectorName; |
|||
} |
|||
|
|||
if (name) { |
|||
const logger = new Logger('getSectorName'); |
|||
|
|||
logger.warn(`Could not map the sector "${name}" to the ontology`); |
|||
} |
|||
|
|||
return 'Other'; |
|||
} |
|||
@ -1,132 +1,142 @@ |
|||
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div> |
|||
|
|||
<div class="py-3" mat-dialog-content> |
|||
@if ( |
|||
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax |
|||
) { |
|||
<div class="w-100"> |
|||
<h6 class="d-flex mb-0"> |
|||
<ng-container i18n>Threshold range</ng-container>: |
|||
<gf-value |
|||
class="ml-1" |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.settings.thresholdMin" |
|||
/> |
|||
<span class="mx-1">-</span> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.settings.thresholdMax" |
|||
/> |
|||
</h6> |
|||
<div class="align-items-center d-flex w-100"> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.min" |
|||
/> |
|||
<mat-slider |
|||
class="flex-grow-1" |
|||
[max]="data.rule.configuration.threshold.max" |
|||
[min]="data.rule.configuration.threshold.min" |
|||
[step]="data.rule.configuration.threshold.step" |
|||
> |
|||
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" /> |
|||
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" /> |
|||
</mat-slider> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.max" |
|||
/> |
|||
<form |
|||
class="d-flex flex-column h-100" |
|||
[formGroup]="settingsForm" |
|||
(ngSubmit)="onSubmit()" |
|||
> |
|||
<div class="py-3" mat-dialog-content> |
|||
@if ( |
|||
data.rule.configuration.thresholdMin && |
|||
data.rule.configuration.thresholdMax |
|||
) { |
|||
<div class="w-100"> |
|||
<h6 class="d-flex mb-0"> |
|||
<ng-container i18n>Threshold range</ng-container>: |
|||
<gf-value |
|||
class="ml-1" |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="settingsForm.get('thresholdMin').value" |
|||
/> |
|||
<span class="mx-1">-</span> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="settingsForm.get('thresholdMax').value" |
|||
/> |
|||
</h6> |
|||
<div class="align-items-center d-flex w-100"> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.min" |
|||
/> |
|||
<mat-slider |
|||
class="flex-grow-1" |
|||
[max]="data.rule.configuration.threshold.max" |
|||
[min]="data.rule.configuration.threshold.min" |
|||
[step]="data.rule.configuration.threshold.step" |
|||
> |
|||
<input formControlName="thresholdMin" matSliderStartThumb /> |
|||
<input formControlName="thresholdMax" matSliderEndThumb /> |
|||
</mat-slider> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.max" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
} @else { |
|||
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin"> |
|||
<h6 class="d-flex mb-0"> |
|||
<ng-container i18n>Threshold Min</ng-container>: |
|||
<gf-value |
|||
class="ml-1" |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.settings.thresholdMin" |
|||
/> |
|||
</h6> |
|||
<div class="align-items-center d-flex w-100"> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.min" |
|||
/> |
|||
<mat-slider |
|||
class="flex-grow-1" |
|||
name="thresholdMin" |
|||
[max]="data.rule.configuration.threshold.max" |
|||
[min]="data.rule.configuration.threshold.min" |
|||
[step]="data.rule.configuration.threshold.step" |
|||
> |
|||
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" /> |
|||
</mat-slider> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.max" |
|||
/> |
|||
} @else { |
|||
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin"> |
|||
<h6 class="d-flex mb-0"> |
|||
<ng-container i18n>Threshold Min</ng-container>: |
|||
<gf-value |
|||
class="ml-1" |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="settingsForm.get('thresholdMin').value" |
|||
/> |
|||
</h6> |
|||
<div class="align-items-center d-flex w-100"> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.min" |
|||
/> |
|||
<mat-slider |
|||
class="flex-grow-1" |
|||
name="thresholdMin" |
|||
[max]="data.rule.configuration.threshold.max" |
|||
[min]="data.rule.configuration.threshold.min" |
|||
[step]="data.rule.configuration.threshold.step" |
|||
> |
|||
<input formControlName="thresholdMin" matSliderThumb /> |
|||
</mat-slider> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.max" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax"> |
|||
<h6 class="d-flex mb-0"> |
|||
<ng-container i18n>Threshold Max</ng-container>: |
|||
<gf-value |
|||
class="ml-1" |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.settings.thresholdMax" |
|||
/> |
|||
</h6> |
|||
<div class="align-items-center d-flex w-100"> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.min" |
|||
/> |
|||
<mat-slider |
|||
class="flex-grow-1" |
|||
name="thresholdMax" |
|||
[max]="data.rule.configuration.threshold.max" |
|||
[min]="data.rule.configuration.threshold.min" |
|||
[step]="data.rule.configuration.threshold.step" |
|||
> |
|||
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" /> |
|||
</mat-slider> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.max" |
|||
/> |
|||
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax"> |
|||
<h6 class="d-flex mb-0"> |
|||
<ng-container i18n>Threshold Max</ng-container>: |
|||
<gf-value |
|||
class="ml-1" |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="settingsForm.get('thresholdMax').value" |
|||
/> |
|||
</h6> |
|||
<div class="align-items-center d-flex w-100"> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.min" |
|||
/> |
|||
<mat-slider |
|||
class="flex-grow-1" |
|||
name="thresholdMax" |
|||
[max]="data.rule.configuration.threshold.max" |
|||
[min]="data.rule.configuration.threshold.min" |
|||
[step]="data.rule.configuration.threshold.step" |
|||
> |
|||
<input formControlName="thresholdMax" matSliderThumb /> |
|||
</mat-slider> |
|||
<gf-value |
|||
[isPercent]="data.rule.configuration.threshold.unit === '%'" |
|||
[locale]="data.locale" |
|||
[precision]="2" |
|||
[value]="data.rule.configuration.threshold.max" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
|
|||
<div align="end" mat-dialog-actions> |
|||
<button i18n mat-button (click)="dialogRef.close()">Close</button> |
|||
<button |
|||
color="primary" |
|||
mat-flat-button |
|||
(click)="dialogRef.close(data.settings)" |
|||
> |
|||
<ng-container i18n>Save</ng-container> |
|||
</button> |
|||
</div> |
|||
<div align="end" mat-dialog-actions> |
|||
<button mat-button type="button" (click)="dialogRef.close()"> |
|||
<ng-container i18n>Close</ng-container> |
|||
</button> |
|||
<button |
|||
color="primary" |
|||
mat-flat-button |
|||
type="submit" |
|||
[disabled]="!settingsForm.dirty" |
|||
> |
|||
<ng-container i18n>Save</ng-container> |
|||
</button> |
|||
</div> |
|||
</form> |
|||
|
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue