Add analytics

This commit is contained in:
Kostya Struga 2022-07-06 10:03:39 +03:00
parent 6c70c8144f
commit 73c5e9319b
48 changed files with 10778 additions and 7530 deletions

View File

@ -50,7 +50,7 @@ module.exports = {
'src/app/sections/notifications/notifications.component.ts',
'src/app/sections/load/fraud-uploader/dnd.directive.ts',
'src/app/sections/groups/services/remove-reference/remove-reference.service.ts',
'src/app/shared/components/testing-data-set-list/services/data-set/testing-data-set.service.ts',
'src/app/shared/components/testing-data-set-list/services/data-set/testing-analytics.service.ts',
],
overrides: [
{

View File

@ -39,7 +39,7 @@
},
{
"name": "lists",
"x-displayName": "Lists"
"x-displayName": "Payments lists"
},
{
"name": "emulation",
@ -60,6 +60,22 @@
{
"name": "historical-data",
"x-displayName": "Historical data"
},
{
"name": "notification-template",
"x-displayName": "Notification template"
},
{
"name": "data-sets",
"x-displayName": "Data sets"
},
{
"name": "analytics",
"x-displayName": "Analytics"
},
{
"name": "dictionary",
"x-displayName": "Dictionary"
}
],
"paths": {
@ -2701,6 +2717,318 @@
}
}
}
},
"/analytics/fraud-payments/count": {
"get": {
"summary": "Получить количество платежей с подозрением на мошенничество",
"operationId": "getFraudPaymentsCount",
"tags": ["analytics"],
"parameters": [
{
"$ref": "#/components/parameters/fromTime"
},
{
"$ref": "#/components/parameters/toTime"
},
{
"$ref": "#/components/parameters/currency"
},
{
"$ref": "#/components/parameters/merchantId"
},
{
"$ref": "#/components/parameters/shopId"
}
],
"responses": {
"200": {
"description": "Attempted fraud-payments count",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CountResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
},
"/analytics/fraud-payments/blocked/count": {
"get": {
"summary": "Получить количество заблокированных мошеннических платежей",
"operationId": "getBlockedFraudPaymentsCount",
"tags": ["analytics"],
"parameters": [
{
"$ref": "#/components/parameters/fromTime"
},
{
"$ref": "#/components/parameters/toTime"
},
{
"$ref": "#/components/parameters/currency"
},
{
"$ref": "#/components/parameters/merchantId"
},
{
"$ref": "#/components/parameters/shopId"
}
],
"responses": {
"200": {
"description": "Blocked fraud-payments count",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CountResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
},
"/analytics/fraud-payments/blocked/sum": {
"get": {
"summary": "Получить общую сумму заблокированных мошеннических платежей",
"operationId": "getBlockedFraudPaymentsSum",
"tags": ["analytics"],
"parameters": [
{
"$ref": "#/components/parameters/fromTime"
},
{
"$ref": "#/components/parameters/toTime"
},
{
"$ref": "#/components/parameters/currency"
},
{
"$ref": "#/components/parameters/merchantId"
},
{
"$ref": "#/components/parameters/shopId"
}
],
"responses": {
"200": {
"description": "Blocked fraud-payments sum",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SumResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
},
"/analytics/fraud-payments/blocked/count/ratio": {
"get": {
"summary": "Получить соотношение количества заблокированных платежей к общему количеству мошеннических платежей",
"operationId": "getBlockedFraudPaymentsCountRatio",
"tags": ["analytics"],
"parameters": [
{
"$ref": "#/components/parameters/fromTime"
},
{
"$ref": "#/components/parameters/toTime"
},
{
"$ref": "#/components/parameters/currency"
},
{
"$ref": "#/components/parameters/merchantId"
},
{
"$ref": "#/components/parameters/shopId"
}
],
"responses": {
"200": {
"description": "Blocked fraud-payments count ratio",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RatioResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
},
"/analytics/fraud-payments/scores/split-count/ratio": {
"get": {
"summary": "Получить соотношение количества платежей по различными оценкам риска операции к общему числу мошеннических \nплатежей в разрезе временного интервала\n",
"operationId": "getFraudPaymentsScoreSplitCountRatio",
"tags": ["analytics"],
"parameters": [
{
"$ref": "#/components/parameters/fromTime"
},
{
"$ref": "#/components/parameters/toTime"
},
{
"$ref": "#/components/parameters/currency"
},
{
"$ref": "#/components/parameters/splitUnit"
},
{
"$ref": "#/components/parameters/merchantId"
},
{
"$ref": "#/components/parameters/shopId"
}
],
"responses": {
"200": {
"description": "Split fraud-payments risc score count ratio",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SplitRiskScoreCountRatioResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
},
"/analytics/fraud-payments/results/summary": {
"get": {
"summary": "Получить статистику по результатам проверок платежей",
"operationId": "getFraudPaymentsResultsSummary",
"tags": ["analytics"],
"parameters": [
{
"$ref": "#/components/parameters/fromTime"
},
{
"$ref": "#/components/parameters/toTime"
},
{
"$ref": "#/components/parameters/currency"
},
{
"$ref": "#/components/parameters/merchantId"
},
{
"$ref": "#/components/parameters/shopId"
}
],
"responses": {
"200": {
"description": "Fraud-payments results summary",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FraudResultListSummaryResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
},
"/dictionaries/currencies": {
"get": {
"summary": "Получить список денежных единиц",
"operationId": "getCurrencies",
"tags": ["dictionary"],
"parameters": [
{
"$ref": "#/components/parameters/fromTime"
},
{
"$ref": "#/components/parameters/toTime"
},
{
"$ref": "#/components/parameters/shopId"
}
],
"responses": {
"200": {
"description": "List of currencies",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
}
},
"servers": [
@ -3804,6 +4132,139 @@
"$ref": "#/components/schemas/MerchantInfo"
}
}
},
"CountResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"format": "int32"
}
}
},
"SumResponse": {
"type": "object",
"properties": {
"sum": {
"type": "integer",
"format": "int32"
}
}
},
"RatioResponse": {
"type": "object",
"properties": {
"ratio": {
"type": "number",
"format": "float"
}
}
},
"SplitUnit": {
"description": "Единица времени сегмента разбиения",
"type": "string",
"enum": ["minute", "hour", "day", "week", "month", "year"]
},
"OffsetCountRatio": {
"type": "object",
"allOf": [
{
"type": "object",
"required": ["countRatio", "offset"],
"properties": {
"countRatio": {
"description": "Соотношение платежей c данной оценкой к общему количеству платежей",
"type": "integer",
"format": "int64",
"minimum": 0
},
"offset": {
"description": "Номер интервала в списке",
"type": "integer",
"format": "int64",
"minimum": 0
}
}
}
]
},
"RiscScoreOffsetCountRatio": {
"type": "object",
"required": ["score", "offsetCountRatio"],
"properties": {
"score": {
"description": "Оценка платежа",
"type": "string"
},
"offsetCountRatio": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OffsetCountRatio"
}
}
}
},
"SplitRiskScoreCountRatioResponse": {
"type": "object",
"required": ["splitUnit", "offsetCountRatios"],
"properties": {
"splitUnit": {
"$ref": "#/components/schemas/SplitUnit"
},
"offsetCountRatios": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RiscScoreOffsetCountRatio"
}
}
}
},
"Summary": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"format": "int32"
},
"sum": {
"type": "integer",
"format": "int32"
},
"ratio": {
"type": "number",
"format": "float"
}
}
},
"FraudResultSummary": {
"type": "object",
"required": ["status", "summary"],
"properties": {
"status": {
"type": "string"
},
"checkedRule": {
"type": "string"
},
"template": {
"type": "string"
},
"summary": {
"$ref": "#/components/schemas/Summary"
}
}
},
"FraudResultListSummaryResponse": {
"type": "object",
"required": ["result"],
"properties": {
"result": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FraudResultSummary"
}
}
}
}
},
"parameters": {
@ -3876,6 +4337,59 @@
"schema": {
"type": "string"
}
},
"fromTime": {
"name": "fromTime",
"in": "query",
"description": "Начало временного отрезка",
"required": true,
"schema": {
"type": "string"
}
},
"toTime": {
"name": "toTime",
"in": "query",
"description": "Конец временного отрезка",
"required": true,
"schema": {
"type": "string"
}
},
"currency": {
"name": "currency",
"in": "query",
"description": "Денежные единицы",
"required": true,
"schema": {
"type": "string"
}
},
"merchantId": {
"name": "merchantId",
"in": "query",
"description": "Идентификатор мерчанта",
"schema": {
"type": "string"
}
},
"shopId": {
"name": "shopId",
"in": "query",
"description": "Идентификатор магазина",
"schema": {
"type": "string"
}
},
"splitUnit": {
"name": "splitUnit",
"in": "query",
"description": "Единица времени сегмента разбиения",
"required": true,
"schema": {
"type": "string",
"enum": ["minute", "hour", "day", "week", "month", "year"]
}
}
}
}

16952
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,7 @@
"@angular/platform-browser-dynamic": "~10.1.3",
"@angular/router": "~10.1.3",
"angular-file": "^3.1.2",
"apexcharts": "^3.35.3",
"coerce-property": "^0.3.2",
"concurrently": "^7.0.0",
"eslint": "^8.10.0",
@ -93,4 +94,4 @@
"lint-staged": {
"*.{html,js,ts,css,scss,md,json,prettierrc,svg,huskyrc}": "prettier --write"
}
}
}

View File

@ -0,0 +1,8 @@
import { NgModule } from '@angular/core';
import { AnalyticsService } from './analytics.service';
@NgModule({
providers: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@ -0,0 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ConfigService } from '../../../config';
import { filterParameters } from '../../../shared/utils/filter-params';
import { ListResponse } from '../../fb-management/swagger-codegen/model/listResponse';
import { map } from 'rxjs/operators';
import { SearchAnalyticsCurrenciesParams } from '../../../sections/analytics/components/search/search-currencies-params';
@Injectable()
export class AnalyticsService {
private readonly fbAnalyticsDictionaries = `${this.configService.fbManagementEndpoint}/dictionaries`;
constructor(private http: HttpClient, private configService: ConfigService) {}
getCurrencies(params: SearchAnalyticsCurrenciesParams): Observable<string[]> {
return this.http
.get<ListResponse>(`${this.fbAnalyticsDictionaries}/currencies`, {
params: filterParameters(params),
})
.pipe(map((response: ListResponse) => response.result));
}
}

View File

@ -0,0 +1,2 @@
export * from './analytics.module';
export * from './analytics.service';

View File

@ -7,7 +7,7 @@ import { RouterModule } from '@angular/router';
[
{
path: '',
redirectTo: 'emulation/template',
redirectTo: 'analytics',
pathMatch: 'full',
},
],

View File

@ -0,0 +1,33 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AuthGuard, Roles } from '../../auth';
import { AnalyticsComponent } from './analytics.component';
import { BaseAnalyticsComponent } from './components/base/base-analytics.component';
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
component: AnalyticsComponent,
canActivate: [AuthGuard],
data: { roles: [Roles.FraudOfficer] },
children: [
{
path: 'base',
component: BaseAnalyticsComponent,
canActivate: [AuthGuard],
data: { roles: [Roles.FraudOfficer] },
},
{
path: '',
redirectTo: 'base',
},
],
},
]),
],
exports: [RouterModule],
})
export class AnalyticsRoutingModule {}

View File

@ -0,0 +1,16 @@
<div class="mat-headline">Analytics</div>
<div fxLayout="column" fxLayoutGap="8px">
<nav mat-tab-nav-bar>
<a
mat-tab-link
*ngFor="let link of links"
[routerLink]="link.path"
routerLinkActive
#rla="routerLinkActive"
[active]="rla.isActive || hasActiveFragments(link.otherActiveUrlFragments)"
>
{{ link.name }}
</a>
</nav>
<router-outlet></router-outlet>
</div>

View File

@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { Router } from '@angular/router';
import { hasActiveFragments } from '../../shared/utils/has-active-fragments';
import { LAYOUT_GAP_S } from '../../tokens';
@Component({
templateUrl: 'analytics.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnalyticsComponent {
links = [
{
path: 'base',
name: 'Base',
otherActiveUrlFragments: [],
},
];
constructor(private router: Router, @Inject(LAYOUT_GAP_S) public layoutGapS: string) {}
hasActiveFragments(fragments: string[]): boolean {
const ulrFragments = this.router.url.split('/');
return hasActiveFragments(fragments, ulrFragments);
}
}

View File

@ -0,0 +1,74 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { ConfirmActionDialogModule } from '../../shared/components/confirm-action-dialog';
import { EmptySearchResultModule } from '../../shared/components/empty-search-result';
import { ShowMorePanelModule } from '../../shared/components/show-more-panel';
import { SharedPipesModule } from '../../shared/pipes';
import { AnalyticsRoutingModule } from './analytics-routing.module';
import { AnalyticsComponent } from './analytics.component';
import { BaseAnalyticsComponent } from './components/base/base-analytics.component';
import { BaseAnalyticsSearchComponent } from './components/search/base-analytics-search.component';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
NgxMatDatetimePickerModule,
NgxMatNativeDateModule,
NgxMatTimepickerModule,
} from '@angular-material-components/datetime-picker';
import { SearchFieldService } from '../../shared/services/utils/search-field.service';
import { MatSelectModule } from '@angular/material/select';
import { AnalyticsService } from '../../api/payments/analytics';
import { FbInfoCardModule } from '../../shared/components/fb-info-card';
@NgModule({
imports: [
AnalyticsRoutingModule,
MatTabsModule,
CommonModule,
MatCardModule,
MatTableModule,
MatSortModule,
MatButtonModule,
MatIconModule,
MatToolbarModule,
ConfirmActionDialogModule,
MatMenuModule,
EmptySearchResultModule,
SharedPipesModule,
FlexModule,
MatSnackBarModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
MatProgressBarModule,
ShowMorePanelModule,
MatDatepickerModule,
MatNativeDateModule,
MatTooltipModule,
NgxMatTimepickerModule,
NgxMatNativeDateModule,
NgxMatDatetimePickerModule,
MatSelectModule,
FbInfoCardModule,
],
declarations: [AnalyticsComponent, BaseAnalyticsComponent, BaseAnalyticsSearchComponent],
providers: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@ -0,0 +1,31 @@
<div fxLayout="column" [fxLayoutGap]="layoutGapM">
<div fxLayout fxLayoutAlign="space-between center">
<fb-base-analytics-search (valueChanges)="search($event)"></fb-base-analytics-search>
</div>
<div fxLayout="row" fxlayoutgap="10px grid" fxLayoutAlign="space-between center" [fxLayoutGap]="layoutGapM">
<fb-info-card
fxFlex="25"
[headerText]="'Attempted payments'"
[value]="'30 430 000'"
[type]="'success'"
></fb-info-card>
<fb-info-card fxFlex="25" [headerText]="'Blocked payments'" [value]="'507'" [type]="'error'"></fb-info-card>
<fb-info-card
fxFlex="25"
[headerText]="'Block rates'"
[value]="'0.24'"
[units]="'%'"
[type]="'error'"
></fb-info-card>
<fb-info-card
fxFlex="25"
[headerText]="'Block sum'"
[value]="'30 430'"
[units]="'$'"
[type]="'error'"
></fb-info-card>
</div>
<div fxLayout="column" fxLayoutAlign="space-between center" [fxLayoutGap]="layoutGapM">
<mat-card fxLayout fxFlex="100" fxLayoutAlign="center"> </mat-card>
</div>
</div>

View File

@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { LAYOUT_GAP_M } from '../../../../tokens';
import { Observable, Subject } from 'rxjs';
import { AnalyticsService } from '../../../../api/payments/analytics';
@Component({
templateUrl: 'base-analytics.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BaseAnalyticsComponent implements OnInit {
constructor(@Inject(LAYOUT_GAP_M) public layoutGapM: string) {}
search($event) {}
ngOnInit(): void {}
}

View File

@ -0,0 +1,20 @@
<form fxflexfill [formGroup]="form">
<div fxLayoutGap="10px grid" fxFlexFill fxLayout.lt-sm="column" class="row-margin">
<mat-form-field appearance="outline">
<input matInput placeholder="merchant" formControlName="partyId" autocomplete="false" />
</mat-form-field>
<mat-form-field appearance="outline">
<input matInput placeholder="shop" formControlName="shopId" autocomplete="false" />
</mat-form-field>
<mat-form-field class="multi-select" appearance="outline" *ngIf="currencies$ | async as currencies">
<mat-select formControlName="types" multiple>
<mat-option *ngFor="let name of currencies" value="{{ name }}">{{ name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="multi-select" appearance="outline" *ngIf="currencies$ | async as currencies">
<mat-select formControlName="times" multiple>
<mat-option *ngFor="let name of currencies" value="{{ name }}">{{ name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
</form>

View File

@ -0,0 +1,44 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { debounceTime, map, take } from 'rxjs/operators';
import { removeEmptyProperties } from '../../../../shared/utils/remove-empty-properties';
import { AnalyticsService } from '../../../../api/payments/analytics';
import { Observable } from 'rxjs';
@Component({
selector: 'fb-base-analytics-search',
templateUrl: 'base-analytics-search.component.html',
styleUrls: ['search.component.scss'],
})
export class BaseAnalyticsSearchComponent {
@Output() valueChanges: EventEmitter<string> = new EventEmitter();
currencies$: Observable<string[]>;
form: FormGroup = this.fb.group({
partyId: '.*',
shopId: '.*',
types: [],
times: '',
});
constructor(
private route: ActivatedRoute,
private router: Router,
private fb: FormBuilder,
private analyticsService: AnalyticsService
) {
this.form.valueChanges.pipe(debounceTime(600), map(removeEmptyProperties)).subscribe((v) => {
const params = Object.create(v);
params.partyId = v.partyId;
params.shopId = v.shopId;
params.types = v.types;
params.times = v.times;
this.router.navigate([location.pathname], { queryParams: params });
this.valueChanges.emit(v);
});
this.route.queryParams.pipe(take(1)).subscribe((v) => this.form.patchValue(v));
this.currencies$ = this.analyticsService.getCurrencies({});
}
}

View File

@ -0,0 +1,5 @@
export interface SearchAnalyticsCurrenciesParams {
from?: string;
to?: string;
shopId?: string;
}

View File

@ -0,0 +1,42 @@
::ng-deep {
form {
.mat-form-field:not(.mat-form-field-appearance-legacy) .mat-form-field-prefix .mat-icon-button,
.mat-form-field:not(.mat-form-field-appearance-legacy) .mat-form-field-suffix .mat-icon-button {
margin-left: -30px;
font-size: revert;
height: 1.5em;
width: 1.5em;
}
.mat-form-field:not(.mat-form-field-appearance-legacy)
.mat-form-field-prefix
.mat-datepicker-toggle-default-icon,
.mat-form-field:not(.mat-form-field-appearance-legacy)
.mat-form-field-suffix
.mat-datepicker-toggle-default-icon {
display: inherit;
}
mat-form-field.date-picker {
div {
.mat-form-field-flex {
padding: 0;
background-color: transparent;
}
}
}
.mat-form-field-wrapper {
padding: 0;
margin: 0;
}
.mat-form-field-appearance-outline .mat-form-field-flex {
margin-top: 0;
}
div.row-margin {
margin: 0 !important;
}
}
}

View File

@ -0,0 +1 @@
export * from './analytics.module';

View File

@ -7,6 +7,10 @@ const ROUTES: Routes = [
redirectTo: 'emulation/template',
pathMatch: 'full',
},
{
path: 'analytics',
loadChildren: () => import('./analytics').then((m) => m.AnalyticsModule),
},
{
path: 'templates',
loadChildren: () => import('./templates').then((m) => m.TemplatesModule),

View File

@ -0,0 +1,35 @@
@use '@angular/material' as mat;
@import '../../../styles/utils/shadow';
@mixin dsh-charts-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
.apexcharts {
&-text {
fill: mat.get-color-from-palette($foreground, text);
}
&-legend-text {
color: mat.get-color-from-palette($foreground, text) !important;
}
&-tooltip {
@include dsh-shadow($theme);
background-color: mat.get-color-from-palette($background, card);
}
}
}
@mixin dsh-charts-typography($config) {
.apexcharts-text,
.apexcharts-legend-text {
line-height: 20px;
font: {
family: mat.font-family($config, caption) !important;
size: mat.font-size($config, caption) !important;
weight: mat.font-weight($config, caption) !important;
}
}
}

View File

@ -0,0 +1,14 @@
@import '../../../styles/utils/shadow';
@import '../charts-theme';
@mixin dsh-bar-chart-theme($theme) {
$foreground: map-get($theme, foreground);
@include dsh-charts-theme($theme);
.dsh-bar-chart-tooltip {
&-title {
color: map-get($foreground, light-text);
}
}
}

View File

@ -0,0 +1,13 @@
<apx-chart
[chart]="config.chart"
[series]="series"
[dataLabels]="config.dataLabels"
[plotOptions]="config.plotOptions"
[xaxis]="config.xaxis"
[yaxis]="config.yaxis"
[legend]="config.legend"
[fill]="config.fill"
[tooltip]="config.tooltip"
[colors]="colors"
[states]="config.states"
></apx-chart>

View File

@ -0,0 +1,28 @@
$tooltip-round-size: 15px;
$tooltip-round-margin: 5px;
$tooltip-padding: 10px;
$tooltip-item-padding: 4px 0;
$tooltip-border-radius: 4px;
:host ::ng-deep {
.apexcharts-tooltip {
border: none !important;
border-radius: $tooltip-border-radius;
padding: $tooltip-padding;
}
.dsh-bar-chart-tooltip {
&-round {
margin-right: $tooltip-round-margin;
height: $tooltip-round-size;
width: $tooltip-round-size;
border-radius: $tooltip-round-size;
}
&-container {
padding: $tooltip-item-padding;
display: flex;
align-items: center;
}
}
}

View File

@ -0,0 +1,29 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import cloneDeep from 'lodash-es/cloneDeep';
import { ApexAxisChartSeries } from 'ng-apexcharts/lib/model/apex-types';
import { DEFAULT_CONFIG } from './default-config';
@Component({
selector: 'dsh-bar-chart',
templateUrl: 'bar-chart.component.html',
styleUrls: ['bar-chart.component.scss'],
})
export class BarChartComponent implements OnChanges {
@Input()
series?: ApexAxisChartSeries;
@Input()
colors?: string[];
@Input()
height?: number;
config = cloneDeep(DEFAULT_CONFIG);
ngOnChanges(changes: SimpleChanges) {
if (changes.height && changes.height.currentValue !== changes.height.previousValue) {
this.config.chart.height = this.height;
}
}
}

View File

@ -0,0 +1,12 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgApexchartsModule } from 'ng-apexcharts';
import { BarChartComponent } from './bar-chart.component';
@NgModule({
declarations: [BarChartComponent],
exports: [BarChartComponent],
imports: [CommonModule, NgApexchartsModule],
})
export class BarChartModule {}

View File

@ -0,0 +1,29 @@
import { formatNumber } from '@angular/common';
import { locale } from 'moment';
const getGetTooltipTitle = (x: string | null): string =>
x.includes('hide') || x.includes('show') ? x.split('#')[0] : x;
export const customTooltip = ({ series, dataPointIndex, w }) => {
let values = '';
for (let i = 0; i < series.length; i += 1) {
const formattedAmount = formatNumber(series[i][dataPointIndex], locale());
const tooltipValue = w.globals.initialSeries[i].name
? `${w.globals.seriesNames[i]} - ${formattedAmount}`
: formattedAmount;
values += `
<div class="dsh-bar-chart-tooltip-container">
<div class="dsh-bar-chart-tooltip-round mat-caption" style="background-color: ${w.globals.colors[i]}"></div>
${tooltipValue}
</div>`;
}
const x = getValueX(w.config.series, dataPointIndex);
const tooltipTitle = getGetTooltipTitle(x);
return `
<div class="dsh-bar-chart-tooltip-title mat-caption">${tooltipTitle}</div>
${values}
`;
};
const getValueX = (series: any[], index: number): string =>
series.reduce((acc, curr) => (acc ? acc : curr.data.length ? curr.data[index].x : acc), null);

View File

@ -0,0 +1,65 @@
import { ApexOptions } from 'ng-apexcharts/lib/model/apex-types';
import { DEFAULT_ANIMATION } from '@dsh/components/charts/default-animation';
import { formatAmount } from '@dsh/components/charts/format-amount';
import { DEFAULT_LEGEND } from '../default-legend';
import { DEFAULT_STATES } from '../default-states';
import { customTooltip } from './custom-tooltip';
const COLUMN_WIDTH = '30%';
export const DEFAULT_CONFIG: ApexOptions = {
chart: {
type: 'bar',
stacked: true,
height: 300,
toolbar: {
show: false,
},
animations: DEFAULT_ANIMATION,
},
dataLabels: {
enabled: false,
},
legend: DEFAULT_LEGEND,
fill: {
opacity: 1,
},
tooltip: {
custom: customTooltip,
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: COLUMN_WIDTH,
},
},
xaxis: {
type: 'category',
labels: {
offsetY: -5,
rotate: 0,
formatter(value: string): string {
const splitted = typeof value === 'string' ? value.split('#') : '';
return splitted[1] === 'hide' ? '' : splitted[0];
},
},
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
crosshairs: {
show: false,
},
},
yaxis: {
forceNiceScale: true,
labels: {
formatter: formatAmount,
},
},
states: DEFAULT_STATES,
};

View File

@ -0,0 +1 @@
export * from './bar-chart.module';

View File

@ -0,0 +1,15 @@
import { ApexChart } from 'ng-apexcharts';
export const DEFAULT_ANIMATION: ApexChart['animations'] = {
enabled: true,
easing: 'easeinout',
speed: 500,
animateGradually: {
enabled: true,
delay: 150,
},
dynamicAnimation: {
enabled: true,
speed: 350,
},
};

View File

@ -0,0 +1,22 @@
import { ApexLegend } from 'ng-apexcharts';
const LEGEND_ROUND_SIZE = 15;
const VERTICAL_MARGIN = 4;
const HORIZONTAL_MARGIN = 5;
export const DEFAULT_LEGEND: ApexLegend = {
position: 'bottom',
horizontalAlign: 'center',
markers: {
width: LEGEND_ROUND_SIZE,
height: LEGEND_ROUND_SIZE,
radius: LEGEND_ROUND_SIZE,
},
itemMargin: {
vertical: VERTICAL_MARGIN,
horizontal: HORIZONTAL_MARGIN,
},
onItemHover: {
highlightDataSeries: false,
},
};

View File

@ -0,0 +1,14 @@
import { ApexStates } from 'ng-apexcharts';
export const DEFAULT_STATES: ApexStates = {
hover: {
filter: {
type: 'none',
},
},
active: {
filter: {
type: 'none',
},
},
};

View File

@ -0,0 +1,15 @@
@import '../../../../styles/utils/shadow';
@import '../charts-theme';
@mixin dsh-donut-chart-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
@include dsh-charts-theme($theme);
.dsh-donut-chart-tooltip {
&-title {
color: map-get($foreground, light-text);
}
}
}

View File

@ -0,0 +1,45 @@
import { ApexOptions } from 'ng-apexcharts/lib/model/apex-types';
import { DEFAULT_ANIMATION } from '@dsh/components/charts/default-animation';
import { DEFAULT_LEGEND } from '../default-legend';
import { DEFAULT_STATES } from '../default-states';
const INNER_DONUT_RADIUS = '92';
export const DEFAULT_CONFIG: ApexOptions = {
chart: {
type: 'donut',
width: '100%',
toolbar: {
show: false,
},
animations: DEFAULT_ANIMATION,
events: {},
},
dataLabels: {
enabled: false,
},
legend: {
...DEFAULT_LEGEND,
formatter: (seriesName, opts) => {
return `${seriesName} - ${opts.w.globals.series[opts.seriesIndex].toFixed(2)}%`;
},
showForNullSeries: true,
},
plotOptions: {
pie: {
expandOnClick: false,
donut: {
size: INNER_DONUT_RADIUS,
labels: {
show: false,
},
},
},
},
tooltip: {
enabled: false,
},
states: DEFAULT_STATES,
};

View File

@ -0,0 +1,12 @@
<apx-chart
[chart]="config.chart"
[dataLabels]="config.dataLabels"
[legend]="config.legend"
[grid]="config.grid"
[tooltip]="config.tooltip"
[plotOptions]="config.plotOptions"
[states]="config.states"
[series]="series"
[labels]="labels"
[colors]="colors"
></apx-chart>

View File

@ -0,0 +1,6 @@
:host ::ng-deep {
.apexcharts-legend-series,
.apexcharts-legend-marker {
cursor: default !important;
}
}

View File

@ -0,0 +1,33 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import cloneDeep from 'lodash-es/cloneDeep';
import { ApexAxisChartSeries } from 'ng-apexcharts/lib/model/apex-types';
import { DEFAULT_CONFIG } from './default-config';
@Component({
selector: 'dsh-donut-chart',
templateUrl: 'donut-chart.component.html',
styleUrls: ['donut-chart.component.scss'],
})
export class DonutChartComponent implements OnInit {
@Input()
series: ApexAxisChartSeries;
@Input()
labels: string[];
@Input()
colors?: string[];
@Output()
dataSelect = new EventEmitter<number>();
config = cloneDeep(DEFAULT_CONFIG);
ngOnInit() {
this.config.chart.events.dataPointSelection = this.updateDataPointSelection;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
updateDataPointSelection = (_: any, __: any, options?: any) => this.dataSelect.emit(options.dataPointIndex);
}

View File

@ -0,0 +1,12 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgApexchartsModule } from 'ng-apexcharts';
import { DonutChartComponent } from './donut-chart.component';
@NgModule({
declarations: [DonutChartComponent],
exports: [DonutChartComponent],
imports: [CommonModule, NgApexchartsModule],
})
export class DonutChartModule {}

View File

@ -0,0 +1 @@
export * from './donut-chart.module';

View File

@ -0,0 +1,22 @@
import { formatNumber } from '@angular/common';
import { locale } from 'moment';
export const formatAmount = (num: number): string => {
for (const amountUnit of AMOUNT_UNITS) {
if (num >= amountUnit.amount) {
return formatNumber(num / amountUnit.amount, locale(), '1.0-2') + amountUnit.unit;
}
}
return formatNumber(num, locale(), '1.0-2');
};
interface AmountUnit {
amount: number;
unit: '' | 'K' | 'M' | 'B';
}
const AMOUNT_UNITS: AmountUnit[] = [
{ amount: 1000000000, unit: 'B' },
{ amount: 1000000, unit: 'M' },
{ amount: 1000, unit: 'K' },
];

View File

@ -0,0 +1,2 @@
export * from './bar-chart';
export * from './donut-chart';

View File

@ -0,0 +1,6 @@
<mat-card fxLayoutAlign="center">
<mat-card-content>
<h4 fxLayoutAlign="center">{{ headerText }}</h4>
<h1 [class]="type" fxLayoutAlign="center">{{ value }} {{ units }}</h1>
</mat-card-content>
</mat-card>

View File

@ -0,0 +1,7 @@
.error {
color: #cf1c1d;
}
.success {
color: #1ab152;
}

View File

@ -0,0 +1,13 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'fb-info-card',
templateUrl: 'fb-info-card.component.html',
styleUrls: ['fb-info-card.component.scss'],
})
export class FbInfoCardComponent {
@Input() headerText: string;
@Input() value: string;
@Input() units = '';
@Input() type: string;
}

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { MatCardModule } from '@angular/material/card';
import { FbInfoCardComponent } from './fb-info-card.component';
@NgModule({
declarations: [FbInfoCardComponent],
exports: [FbInfoCardComponent],
imports: [MatCardModule, FlexModule, CommonModule],
})
export class FbInfoCardModule {}

View File

@ -0,0 +1 @@
export * from './fb-info-card.module';

View File

@ -3,6 +3,12 @@ import { NavItem } from './nav-item';
export class MenuModel {
public static navItems: NavItem[] = [
{
displayName: 'Analytics',
iconName: 'pie_chart',
route: 'analytics',
roles: [Roles.FraudOfficer, Roles.FraudMonitoring],
},
{
displayName: 'Templates',
iconName: 'business',

View File

@ -0,0 +1,14 @@
@use '@angular/material' as mat;
@function get-shadow($color, $opacity) {
$color: rgba($color, $opacity);
@return '0px 4px 8px #{$color}';
}
@mixin dsh-shadow($theme) {
$primary: map-get($theme, primary);
$shadow-color: mat.get-color-from-palette($primary, default);
box-shadow: #{get-shadow($shadow-color, 0.12)} !important;
}