MI-9: Add withdrawal reports (only dev) (#135)

This commit is contained in:
Rinat Arsaev 2023-07-26 15:35:13 +04:00 committed by GitHub
parent e4f63bacd6
commit c057293387
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 870 additions and 18 deletions

View File

@ -9,6 +9,7 @@
"build": "ng build && transloco-optimize dist/assets/i18n",
"test": "ng test",
"i18n:extract": "transloco-keys-manager extract",
"i18n:clean": "transloco-keys-manager extract --remove-extra-keys",
"i18n:check": "transloco-keys-manager find --emit-error-on-extra-keys",
"coverage": "npx http-server -c-1 -o -p 9875 ./coverage",
"lint": "ng lint --max-warnings=0",

View File

@ -5,3 +5,4 @@ export * from './withdrawals.service';
export * from './identities.service';
export * from './deposits.service';
export * from './wallet-dictionary.service';
export * from './reports.service';

View File

@ -0,0 +1,11 @@
import { Injectable } from '@angular/core';
import { ReportsService as ApiService } from '@vality/swag-wallet';
import { PartyIdExtension } from '@dsh/app/api/utils/extensions';
import { createApi } from '../utils';
@Injectable({
providedIn: 'root',
})
export class ReportsService extends createApi(ApiService, [PartyIdExtension]) {}

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { TranslocoService } from '@ngneat/transloco';
import { DepositRevert, WithdrawalsTopic, DestinationsTopic, Deposit, Withdrawal } from '@vality/swag-wallet';
import { DepositRevert, WithdrawalsTopic, DestinationsTopic, Deposit, Withdrawal, Report } from '@vality/swag-wallet';
import { DictionaryService } from '../utils';
@ -64,5 +64,13 @@ export class WalletDictionaryService {
/* eslint-enable @typescript-eslint/naming-convention */
}));
reportStatus$ = this.dictionaryService.create<Report.StatusEnum>(() => ({
/* eslint-disable @typescript-eslint/naming-convention */
pending: this.t.translate('wallet.reportStatus.pending', null, 'dictionary'),
created: this.t.translate('wallet.reportStatus.created', null, 'dictionary'),
canceled: this.t.translate('wallet.reportStatus.canceled', null, 'dictionary'),
/* eslint-enable @typescript-eslint/naming-convention */
}));
constructor(private t: TranslocoService, private dictionaryService: DictionaryService) {}
}

View File

@ -14,7 +14,7 @@ export const formValueToCreateValue = ({
reportType: 'paymentRegistry',
});
const getDateWithTime = (date: string, time: string): string =>
export const getDateWithTime = (date: string, time: string): string =>
moment(`${moment(date).format('YYYY-MM-DD')}, ${time}`, 'YYYY-MM-DD, HH:mm:ss')
.utc()
.format();

View File

@ -22,7 +22,6 @@ import { DepositRevertsModule } from './deposit-reverts/deposit-reverts.module';
LayoutModule,
FlexLayoutModule,
CommonModule,
ApiModelRefsModule,
EmptySearchResultModule,
ShowMorePanelModule,

View File

@ -1,8 +1,4 @@
<div
*transloco="let t; scope: 'wallet-section'; read: 'walletSection.integrations'"
fxLayout="column"
fxLayoutGap="32px"
>
<div fxLayout="column" fxLayoutGap="32px">
<nav mat-tab-nav-bar>
<a
mat-tab-link

View File

@ -0,0 +1,39 @@
<dsh-base-dialog
*transloco="let t; scope: 'wallet-section'; read: 'walletSection.reports.createReportDialog'"
[title]="t('title')"
[subtitle]="t('subtitle')"
(cancel)="cancel()"
>
<div [formGroup]="form" fxLayout="column" fxLayoutGap="16px">
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap="24px" fxLayoutGap.xs="16px">
<mat-form-field fxFlex>
<mat-label>{{ t('from') }}</mat-label>
<input required formControlName="fromDate" matInput [matDatepicker]="from" [max]="form.value.toTime" />
<mat-datepicker-toggle matSuffix [for]="from"></mat-datepicker-toggle>
<mat-datepicker #from></mat-datepicker>
</mat-form-field>
<mat-form-field fxFlex>
<mat-label>{{ t('time') }}</mat-label>
<input required dshFormatTimeInput formControlName="fromTime" matInput placeholder="00:00:00" />
</mat-form-field>
</div>
<div fxLayout="row" fxLayout.xs="column" fxLayoutGap="24px" fxLayoutGap.xs="16px">
<mat-form-field fxFlex>
<mat-label>{{ t('to') }}</mat-label>
<input required formControlName="toDate" matInput [matDatepicker]="to" [min]="form.value.fromTime" />
<mat-datepicker-toggle matSuffix [for]="to"></mat-datepicker-toggle>
<mat-datepicker #to></mat-datepicker>
</mat-form-field>
<mat-form-field fxFlex>
<mat-label>{{ t('time') }}</mat-label>
<input required dshFormatTimeInput formControlName="toTime" matInput placeholder="00:00:00" />
</mat-form-field>
</div>
<dsh-identity-field formControlName="identityID" required></dsh-identity-field>
</div>
<ng-container dshBaseDialogActions>
<button dsh-button color="accent" [disabled]="form.invalid || !!(progress$ | async)" (click)="confirm()">
{{ t('confirm') }}
</button>
</ng-container>
</dsh-base-dialog>

View File

@ -0,0 +1,72 @@
import { Component, Inject } from '@angular/core';
import { NonNullableFormBuilder, Validators } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { TranslocoService } from '@ngneat/transloco';
import { untilDestroyed, UntilDestroy } from '@ngneat/until-destroy';
import { NotifyLogService, progressTo } from '@vality/ng-core';
import moment from 'moment/moment';
import { BehaviorSubject } from 'rxjs';
import { ReportsService } from '@dsh/app/api/wallet';
import { getDateWithTime } from '@dsh/app/sections/payment-section/reports/create-report/form-value-to-create-value';
import { BaseDialogResponseStatus } from '@dsh/app/shared/components/dialog/base-dialog';
const TIME_PATTERN = /^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/;
@UntilDestroy()
@Component({
selector: 'dsh-create-report-dialog',
templateUrl: 'create-report-dialog.component.html',
styles: [],
})
export class CreateReportDialogComponent {
form = this.fb.group({
identityID: this.data.identityID as string,
fromDate: [moment().startOf('month').format(), Validators.required],
fromTime: ['00:00:00', Validators.pattern(TIME_PATTERN)],
toDate: [moment().endOf('day').format(), Validators.required],
toTime: ['23:59:59', Validators.pattern(TIME_PATTERN)],
});
progress$ = new BehaviorSubject(0);
constructor(
private dialogRef: MatDialogRef<CreateReportDialogComponent>,
@Inject(MAT_DIALOG_DATA) private data: { identityID?: string },
private fb: NonNullableFormBuilder,
private reportsService: ReportsService,
private log: NotifyLogService,
private transloco: TranslocoService
) {}
confirm(): void {
const { identityID, fromTime, toTime, fromDate, toDate } = this.form.value;
this.reportsService
.createReport({
identityID,
reportParams: {
fromTime: getDateWithTime(fromDate, fromTime),
toTime: getDateWithTime(toDate, toTime),
reportType: 'withdrawalRegistry',
},
})
.pipe(progressTo(this.progress$), untilDestroyed(this))
.subscribe({
next: () => {
this.log.success(
this.transloco.translate('reports.createReportDialog.success', {}, 'wallet-section')
);
this.dialogRef.close(BaseDialogResponseStatus.Success);
},
error: (err) => {
this.log.error(
err,
this.transloco.translate('reports.createReportDialog.error', {}, 'wallet-section')
);
},
});
}
cancel(): void {
this.dialogRef.close(BaseDialogResponseStatus.Cancelled);
}
}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { FetchSuperclass, FetchResult, NotifyLogService } from '@vality/ng-core';
import { GetReportsRequestParams, Report } from '@vality/swag-wallet';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { ReportsService } from '@dsh/app/api/wallet';
@Injectable()
export class FetchReportsService extends FetchSuperclass<
Report,
Omit<GetReportsRequestParams, 'xRequestID' | 'xRequestDeadline'>
> {
constructor(private reportsService: ReportsService, private log: NotifyLogService) {
super();
}
protected fetch(
params: Omit<GetReportsRequestParams, 'xRequestID' | 'xRequestDeadline'>
): Observable<FetchResult<Report, string>> {
return this.reportsService.getReports(params).pipe(
map((result) => ({ result })),
catchError((err) => {
this.log.error(err);
return of({ result: [] });
})
);
}
}

View File

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

View File

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ReportsComponent } from './reports.component';
const ROUTES: Routes = [
{
path: '',
component: ReportsComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
exports: [RouterModule],
})
export class ReportsRoutingModule {}

View File

@ -0,0 +1,62 @@
<div fxLayout="column" fxLayoutGap="32px" *transloco="let t; scope: 'wallet-section'; read: 'walletSection.reports'">
<div
gdColumns="1fr"
gdColumns.gt-sm="repeat(2, auto)"
gdAlignColumns="center center"
gdAlignRows="space-between start"
gdGap="32px"
>
<dsh-filters-group [formGroup]="form">
<div class="dsh-body-1" fxHide.lt-md>{{ t('dateRangeDescription') }}:</div>
<dsh-date-range-filter
formControlName="dateRange"
[default]="defaultDateRange"
[maxDate]="defaultDateRange.end"
></dsh-date-range-filter>
<dsh-identity-filter formControlName="identityID"></dsh-identity-filter>
</dsh-filters-group>
<button dsh-button color="accent" (click)="create()">
{{ t('openCreateReport') }}
</button>
</div>
<dsh-accordion-table
[error]="form.value.identityID ? undefined : t('errors.identityNotSpecified')"
[lastUpdated]="lastUpdated$ | async"
[columns]="columns$ | async"
[data]="reports$ | async"
(update)="load()"
(more)="more()"
[hasMore]="hasMore$ | async"
[inProgress]="isLoading$ | async"
[contentHeader]="contentHeader"
[expanded]="expanded"
>
<ng-template let-report>
<div fxLayout="column" fxLayoutGap="24px">
<div gdColumns="1fr" gdGap="24px">
<div gdColumns="1fr" gdColumns.gt-sm="1fr 1fr 1fr" gdGap="24px">
<dsh-details-item [title]="t('status')">
<dsh-status
[color]="reportStatusColor[report.status]"
>{{ (reportStatusDict$| async)?.[report.status] }}</dsh-status
>
</dsh-details-item>
<dsh-details-item [title]="t('createdAt')">{{
report.createdAt | date : 'dd MMMM yyyy, HH:mm:ss'
}}</dsh-details-item>
</div>
<dsh-details-item [title]="t('period')"
>{{ report.fromTime | date : 'dd MMMM yyyy, HH:mm:ss' }} -
{{ report.toTime | date : 'dd MMMM yyyy, HH:mm:ss' }}</dsh-details-item
>
</div>
<ng-container *ngIf="report.status === 'created'">
<mat-divider></mat-divider>
<dsh-report-files [reportID]="report.id" [files]="report.files"></dsh-report-files>
</ng-container>
</div>
</ng-template>
</dsh-accordion-table>
</div>

View File

@ -0,0 +1,127 @@
import { Breakpoints } from '@angular/cdk/layout';
import { Component, OnInit } from '@angular/core';
import { NonNullableFormBuilder } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { TranslocoService } from '@ngneat/transloco';
import { untilDestroyed, UntilDestroy } from '@ngneat/until-destroy';
import { Report } from '@vality/swag-wallet';
import isEqual from 'lodash-es/isEqual';
import moment from 'moment';
import { Observable } from 'rxjs';
import { startWith, distinctUntilChanged, filter, map } from 'rxjs/operators';
import { WalletDictionaryService } from '@dsh/app/api/wallet';
import { mapToTimestamp } from '@dsh/app/custom-operators';
import { QueryParamsService } from '@dsh/app/shared';
import { Column, ExpandedFragment } from '@dsh/app/shared/components/accordion-table';
import { BaseDialogResponseStatus } from '@dsh/app/shared/components/dialog/base-dialog';
import { StatusColor } from '@dsh/app/theme-manager';
import { createDateRangeWithPreset, Preset, DateRange } from '@dsh/components/date-range-filter';
import { CreateReportDialogComponent } from './components/create-report-dialog/create-report-dialog.component';
import { FetchReportsService } from './fetch-reports.service';
interface Form {
dateRange: DateRange;
identityID: string;
}
const REPORT_STATUS_COLOR = {
[Report.StatusEnum.Created]: StatusColor.Success,
[Report.StatusEnum.Pending]: StatusColor.Pending,
[Report.StatusEnum.Canceled]: StatusColor.Warn,
};
@UntilDestroy()
@Component({
selector: 'dsh-reports',
templateUrl: './reports.component.html',
providers: [FetchReportsService],
})
export class ReportsComponent implements OnInit {
reports$ = this.fetchReportsService.result$;
hasMore$ = this.fetchReportsService.hasMore$;
isLoading$ = this.fetchReportsService.isLoading$;
columns$: Observable<Column<Report>[]> = this.walletDictionaryService.reportStatus$.pipe(
map((reportStatus) => [
{ label: 'Created at', field: (r) => r.createdAt, type: 'datetime' },
{
label: 'Status',
field: (d) => d.status,
type: 'tag',
typeParameters: {
color: REPORT_STATUS_COLOR,
label: reportStatus,
},
hide: Breakpoints.Small,
},
{
label: 'Reporting period',
field: (d): DateRange => ({
start: moment(d.fromTime),
end: moment(d.toTime),
}),
type: 'daterange',
hide: Breakpoints.Medium,
},
])
);
contentHeader = [{ label: (r) => `${this.transloco.translate('reports.report', {}, 'wallet-section')} #${r.id}` }];
defaultDateRange = createDateRangeWithPreset(Preset.Last90days);
form = this.fb.group<Form>({ dateRange: this.defaultDateRange, identityID: undefined, ...this.qp.params });
lastUpdated$ = this.fetchReportsService.result$.pipe(mapToTimestamp);
reportStatusDict$ = this.walletDictionaryService.reportStatus$;
reportStatusColor = REPORT_STATUS_COLOR;
expanded = new ExpandedFragment(
this.fetchReportsService.result$,
() => this.fetchReportsService.more(),
this.fetchReportsService.hasMore$
);
constructor(
private fetchReportsService: FetchReportsService,
private fb: NonNullableFormBuilder,
private qp: QueryParamsService<Partial<Form>>,
private dialog: MatDialog,
private transloco: TranslocoService,
private walletDictionaryService: WalletDictionaryService
) {}
ngOnInit() {
this.form.valueChanges
.pipe(startWith(this.form.value), distinctUntilChanged(isEqual), untilDestroyed(this))
.subscribe((value) => {
void this.qp.set(value);
if (value.identityID) {
this.load();
}
});
}
load() {
const { dateRange, identityID } = this.form.value;
this.fetchReportsService.load({
fromTime: dateRange.start.utc().format(),
toTime: dateRange.end.utc().format(),
identityID,
type: 'withdrawalRegistry',
});
}
more() {
this.fetchReportsService.more();
}
create() {
this.dialog
.open(CreateReportDialogComponent, { data: { identityID: this.form.value.identityID } })
.afterClosed()
.pipe(
filter((r) => r === BaseDialogResponseStatus.Success),
untilDestroyed(this)
)
.subscribe(() => {
this.load();
});
}
}

View File

@ -0,0 +1,66 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule, GridModule, ExtendedModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatOptionModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { TranslocoModule } from '@ngneat/transloco';
import { ReportFilesModule } from '@dsh/app/sections/payment-section/reports/report-files';
import { ReportPipesModule } from '@dsh/app/sections/payment-section/reports/report-pipes';
import { IdentityFilterModule, ApiModelRefsModule } from '@dsh/app/shared';
import { AccordionTableModule } from '@dsh/app/shared/components/accordion-table';
import { DialogModule } from '@dsh/app/shared/components/dialog';
import { ClaimFieldModule } from '@dsh/app/shared/components/inputs/claim-field';
import { IdentityFieldComponent } from '@dsh/app/shared/components/inputs/identity-field';
import { ButtonModule } from '@dsh/components/buttons';
import { DateRangeFilterModule } from '@dsh/components/date-range-filter';
import { FilterModule } from '@dsh/components/filter';
import { FiltersGroupModule } from '@dsh/components/filters-group';
import { BootstrapIconModule, StatusModule } from '@dsh/components/indicators';
import { DetailsItemModule } from '@dsh/components/layout';
import { CreateReportDialogComponent } from './components/create-report-dialog/create-report-dialog.component';
import { ReportsRoutingModule } from './reports-routing.module';
import { ReportsComponent } from './reports.component';
@NgModule({
declarations: [ReportsComponent, CreateReportDialogComponent],
imports: [
CommonModule,
ReportsRoutingModule,
AccordionTableModule,
FlexModule,
DateRangeFilterModule,
FiltersGroupModule,
ReactiveFormsModule,
MatFormFieldModule,
MatOptionModule,
MatSelectModule,
ClaimFieldModule,
FilterModule,
TranslocoModule,
IdentityFilterModule,
ButtonModule,
GridModule,
ExtendedModule,
DialogModule,
MatInputModule,
MatDatepickerModule,
MatDialogModule,
IdentityFieldComponent,
BootstrapIconModule,
MatDividerModule,
ReportFilesModule,
ApiModelRefsModule,
DetailsItemModule,
ReportPipesModule,
StatusModule,
],
})
export class ReportsModule {}

View File

@ -1,10 +1,12 @@
import { BootstrapIconName } from '@dsh/components/indicators';
import { environment } from '@dsh/environments';
export enum NavbarRouterLink {
Wallets = 'wallets',
Deposits = 'deposits',
Withdrawals = 'withdrawals',
Integrations = 'integrations',
Reports = 'reports',
}
export interface NavbarItemConfig {
@ -18,7 +20,8 @@ export const toNavbarItemConfig = ({
deposits,
withdrawals,
integrations,
}: Record<'wallets' | 'deposits' | 'withdrawals' | 'integrations', string>): NavbarItemConfig[] => [
reports,
}: Record<'wallets' | 'deposits' | 'withdrawals' | 'integrations' | 'reports', string>): NavbarItemConfig[] => [
{
routerLink: NavbarRouterLink.Wallets,
icon: BootstrapIconName.Wallet2,
@ -34,6 +37,15 @@ export const toNavbarItemConfig = ({
icon: BootstrapIconName.ArrowUpRightCircle,
label: withdrawals,
},
...(environment.production
? []
: [
{
routerLink: NavbarRouterLink.Reports,
icon: BootstrapIconName.FileText,
label: reports,
},
]),
{
routerLink: NavbarRouterLink.Integrations,
icon: BootstrapIconName.Plug,

View File

@ -1,6 +1,8 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { environment } from '@dsh/environments';
import { WalletSectionComponent } from './wallet-section.component';
const WALLET_SECTION_ROUTES: Routes = [
@ -24,6 +26,14 @@ const WALLET_SECTION_ROUTES: Routes = [
path: 'integrations',
loadChildren: () => import('./integrations').then((m) => m.IntegrationsModule),
},
...(environment.production
? []
: [
{
path: 'reports',
loadChildren: () => import('./reports').then((m) => m.ReportsModule),
},
]),
{
path: '',
redirectTo: 'wallets',

View File

@ -32,6 +32,7 @@ export class WalletSectionComponent implements OnInit {
private getNavbarLabels() {
return {
reports: this.transloco.translate('walletSection.nav.reports', null, 'wallet-section'),
wallets: this.transloco.translate('walletSection.nav.wallets', null, 'wallet-section'),
deposits: this.transloco.translate('walletSection.nav.deposits', null, 'wallet-section'),
withdrawals: this.transloco.translate('walletSection.nav.withdrawals', null, 'wallet-section'),

View File

@ -0,0 +1,64 @@
<div fxLayout="column" fxLayoutGap="32px">
<div fxLayout="column" fxLayoutGap="16px">
<dsh-last-updated [lastUpdated]="lastUpdated" (update)="update.emit($event)"></dsh-last-updated>
<dsh-row fxLayout="row" fxLayoutAlign="space-between center" fxLayoutGap="24px">
<dsh-row-header-label
*ngFor="let column of columns"
[fxHide]="isHided(column.hide) | async"
[fxFlex]="column.width ?? true"
>{{ column.label }}</dsh-row-header-label
>
</dsh-row>
<dsh-accordion
*ngIf="data?.length"
fxLayout="column"
fxLayoutGap="16px"
[expanded]="expanded ? (expanded.expanded$ | async) : undefined"
(expandedChange)="expanded ? expanded.set($event) : undefined"
>
<dsh-accordion-item *ngFor="let item of data" #accordionItem>
<dsh-row fxLayout="row" fxLayoutAlign="space-between center" fxLayoutGap="24px">
<dsh-row-label
*ngFor="let column of columns"
[fxHide]="isHided(column.hide) | async"
[fxFlex]="column.width ?? true"
>
<ng-container [ngSwitch]="column.type" *ngIf="column.field(item, column) as value">
<ng-template ngSwitchCase="daterange">
{{ value.start | date : 'dd MMMM yyyy' }} - {{ value.end | date : 'dd MMMM yyyy' }}
</ng-template>
<ng-template ngSwitchCase="datetime">
{{ value | date : 'dd MMMM yyyy, HH:mm' }}
</ng-template>
<ng-template ngSwitchCase="tag">
<dsh-status [color]="column.typeParameters.color[value]">{{
column.typeParameters.label[value]
}}</dsh-status>
</ng-template>
<ng-template ngSwitchDefault>
{{ value }}
</ng-template>
</ng-container>
</dsh-row-label>
</dsh-row>
<ng-template dshLazyPanelContent>
<dsh-card fxFlexFill fxLayout="column" fxLayoutGap="24px">
<dsh-accordion-item-content-header (collapse)="accordionItem.collapse($event)">
<div fxLayout fxLayoutAlign="space-between" fxLayoutGap="24px">
<div *ngFor="let header of contentHeader">{{ header.label(item) }}</div>
</div>
</dsh-accordion-item-content-header>
<ng-container *ngTemplateOutlet="contentTemplate; context: { $implicit: item }"></ng-container>
</dsh-card>
</ng-template>
</dsh-accordion-item>
</dsh-accordion>
<dsh-show-more-panel
*ngIf="hasMore"
[isLoading]="inProgress"
(showMore)="more.emit($event)"
></dsh-show-more-panel>
</div>
<dsh-empty-search-result [text]="error" *ngIf="!data?.length && !inProgress"></dsh-empty-search-result>
<dsh-spinner *ngIf="inProgress && !data?.length" fxLayoutAlign="center"></dsh-spinner>
</div>

View File

@ -0,0 +1,66 @@
import { Breakpoints, BreakpointObserver } from '@angular/cdk/layout';
import { Component, Input, Output, EventEmitter, TemplateRef, ContentChild } from '@angular/core';
import { of } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { StatusColor } from '@dsh/app/theme-manager';
import { ExpandedFragment } from './expanded-fragment';
const HIDED_BREAKPOINTS = [
Breakpoints.XSmall,
Breakpoints.Small,
Breakpoints.Medium,
Breakpoints.Large,
Breakpoints.XLarge,
];
export interface Column<T extends object> {
label: string;
width?: string;
hide?: boolean | string; // Material Breakpoint
field?: (row: T, columns: Column<T>) => unknown; // | string
type?: 'daterange' | 'datetime' | 'tag';
typeParameters?: {
color: Record<PropertyKey, StatusColor>;
label: Record<PropertyKey, string>;
};
}
export interface ContentHeader<T extends object> {
label: (row: T) => unknown;
}
@Component({
selector: 'dsh-accordion-table',
templateUrl: './accordion-table.component.html',
styles: [],
})
export class AccordionTableComponent<T extends object> {
@Input() lastUpdated: string;
@Input() columns: Column<T>[];
@Input() contentHeader: ContentHeader<T>[];
@Input() data: T[];
@Input() inProgress: boolean;
@Input() hasMore: boolean;
@Input() error?: string;
@Output() update = new EventEmitter<void>();
@Output() more = new EventEmitter<void>();
@Input() expanded?: ExpandedFragment;
@ContentChild(TemplateRef, { static: true }) contentTemplate!: TemplateRef<unknown>;
constructor(private breakpointObserver: BreakpointObserver) {}
isHided(hide: Column<T>['hide']) {
if (hide === true) return of(true);
if (!hide) return of(false);
const idx = HIDED_BREAKPOINTS.findIndex((h) => h === hide);
return this.breakpointObserver.observe(HIDED_BREAKPOINTS.slice(0, idx)).pipe(
map((s) => s.matches),
startWith(false)
);
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { EmptySearchResultModule } from '@dsh/components/empty-search-result';
import { IndicatorsModule } from '@dsh/components/indicators';
import { RowModule, AccordionModule, CardModule } from '@dsh/components/layout';
import { ShowMorePanelModule } from '@dsh/components/show-more-panel';
import { AccordionTableComponent } from './accordion-table.component';
@NgModule({
declarations: [AccordionTableComponent],
imports: [
CommonModule,
EmptySearchResultModule,
ShowMorePanelModule,
IndicatorsModule,
RowModule,
AccordionModule,
CardModule,
FlexLayoutModule,
],
exports: [AccordionTableComponent],
})
export class AccordionTableModule {}

View File

@ -0,0 +1,65 @@
import { inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import isNil from 'lodash-es/isNil';
import { Observable, BehaviorSubject, defer, of } from 'rxjs';
import { take, switchMap, shareReplay, map, tap, withLatestFrom, distinctUntilChanged } from 'rxjs/operators';
import { Fragment } from '@dsh/app/shared';
const EMIT_LIMIT = 4;
export class ExpandedFragment<T extends { id: unknown } = { id: unknown }> {
expanded$ = defer(() => this.expandedIndex$);
private expandedIndex$ = new BehaviorSubject(0);
private route = inject(ActivatedRoute);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
constructor(
private data$: Observable<T[]>,
private more: () => void,
private hasMore$: Observable<boolean> = of(true)
) {
this.route.fragment
.pipe(
take(1),
switchMap((fragment) =>
this.data$.pipe(
take(EMIT_LIMIT),
map((data) => (fragment ? data.findIndex((item) => this.serialize(item) === fragment) : -1)),
withLatestFrom(hasMore$),
tap(([index, hasMore]) => {
if (!isNil(fragment) && index === -1 && hasMore) {
more();
}
}),
map(([index]) => index)
)
),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef),
shareReplay(1)
)
.subscribe((index) => {
this.set(index);
});
this.expandedIndex$
.pipe(
withLatestFrom(this.data$),
map(([index, data]) => this.serialize(data[index])),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((fragment) => this.router.navigate([], { fragment, queryParamsHandling: 'preserve' }));
}
set(expandedIndex: number) {
this.expandedIndex$.next(expandedIndex);
}
private serialize(item?: T): Fragment {
if (!item) return '';
return String(item.id);
}
}

View File

@ -0,0 +1,3 @@
export * from './accordion-table.module';
export * from './accordion-table.component';
export * from './expanded-fragment';

View File

@ -26,7 +26,10 @@
<ng-template #titleBlock>
<div fxLayout="row" fxLayoutAlign="space-between">
<div class="dsh-headline">{{ title }}</div>
<div fxLayout="column" fxLayoutGap="16px">
<div class="dsh-headline">{{ title }}</div>
<div class="dsh-body-1" *ngIf="subtitle">{{ subtitle }}</div>
</div>
<dsh-bi class="base-dialog-title-close" icon="x" size="lg" (click)="cancelDialog()"></dsh-bi>
</div>
</ng-template>

View File

@ -37,8 +37,8 @@ $max-height-desktop: 90vh;
flex: 1 1 100%;
box-sizing: border-box;
// to make right visual padding
margin: -24px;
padding: 24px;
margin: -24px -24px 0;
padding: 24px 24px 0;
overflow: auto;
}
}

View File

@ -9,6 +9,7 @@ import { coerceBoolean } from 'coerce-property';
})
export class BaseDialogComponent {
@Input() title: string;
@Input() subtitle: string;
@coerceBoolean @Input() disabled: boolean;
@coerceBoolean @Input() hasDivider = true;
@coerceBoolean @Input() noActions = false;

View File

@ -0,0 +1,13 @@
<ng-container *transloco="let t; scope: 'components'; read: 'components.identityFilter'">
<dsh-filter
[active]="isActive"
[label]="t('label')"
[activeLabel]="t('label') + ' #' + savedValue"
[content]="content"
(save)="save()"
(clear)="clear()"
></dsh-filter>
<ng-template #content>
<dsh-identity-field [formControl]="control"></dsh-identity-field>
</ng-template>
</ng-container>

View File

@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { createControlProviders } from '@vality/ng-core';
import { Identity } from '@vality/swag-wallet';
import { FilterSuperclass } from '@dsh/components/filter';
@Component({
selector: 'dsh-identity-filter',
templateUrl: 'identity-filter.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: createControlProviders(() => IdentityFilterComponent),
})
export class IdentityFilterComponent extends FilterSuperclass<Identity['id']> {}

View File

@ -0,0 +1,32 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { TranslocoModule } from '@ngneat/transloco';
import { IdentityFieldComponent } from '@dsh/app/shared/components/inputs/identity-field';
import { FilterModule } from '@dsh/components/filter';
import { IdentityFilterComponent } from './identity-filter.component';
import { ClaimFieldModule } from '../../inputs/claim-field';
@NgModule({
imports: [
CommonModule,
TranslocoModule,
ReactiveFormsModule,
FilterModule,
ClaimFieldModule,
FlexModule,
MatFormFieldModule,
MatOptionModule,
MatSelectModule,
IdentityFieldComponent,
],
declarations: [IdentityFilterComponent],
exports: [IdentityFilterComponent],
})
export class IdentityFilterModule {}

View File

@ -0,0 +1,2 @@
export * from './identity-filter.component';
export * from './identity-filter.module';

View File

@ -3,3 +3,4 @@ export * from './invoices-filter';
export * from './invoice-status-filter';
export * from './refund-status-filter';
export * from './currency-filter';
export * from './identity-filter';

View File

@ -0,0 +1,8 @@
<mat-form-field fxFlex *transloco="let t; scope: 'components'; read: 'components.inputs.identityField'">
<mat-label>{{ t('label') }}</mat-label>
<mat-select [formControl]="control" [required]="required">
<mat-option *ngFor="let identity of identities$ | async" [value]="identity.id">
{{ identity.id }} {{ identity.name }}
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { TranslocoModule } from '@ngneat/transloco';
import { FormControlSuperclass, createControlProviders } from '@vality/ng-core';
import { Identity } from '@vality/swag-wallet';
import { IdentitiesService } from '@dsh/app/api/wallet';
@Component({
selector: 'dsh-identity-field',
standalone: true,
imports: [
CommonModule,
FlexModule,
MatFormFieldModule,
MatOptionModule,
MatSelectModule,
ReactiveFormsModule,
TranslocoModule,
],
templateUrl: './identity-field.component.html',
providers: createControlProviders(() => IdentityFieldComponent),
})
export class IdentityFieldComponent extends FormControlSuperclass<Identity['id']> {
@Input() required = false;
identities$ = this.identitiesService.identities$;
constructor(private identitiesService: IdentitiesService) {
super();
}
}

View File

@ -0,0 +1 @@
export * from './identity-field.component';

View File

@ -174,6 +174,14 @@
"hideAllStatuses": "Hide all statuses",
"showAllStatuses": "Show all statuses"
},
"identityFilter": {
"label": "Wallet holder"
},
"inputs": {
"identityField": {
"label": "Wallet holder"
}
},
"internationalBankAccountForm": {
"correspondentAccount": "Correspondent account"
},

View File

@ -174,6 +174,14 @@
"hideAllStatuses": "Скрыть все статусы",
"showAllStatuses": "Показать все статусы"
},
"identityFilter": {
"label": "Владелец кошелька"
},
"inputs": {
"identityField": {
"label": "Владелец кошелька"
}
},
"internationalBankAccountForm": {
"correspondentAccount": "Корреспондентский счет"
},

View File

@ -153,6 +153,11 @@
"DestinationCreated": "DestinationCreated (Receiver of funds is created)",
"DestinationUnauthorized": "DestinationUnauthorized (An attempt to authorize the funds receiver failed)"
},
"reportStatus": {
"canceled": "Cancelled",
"created": "Created",
"pending": "Pending"
},
"withdrawalStatus": {
"Failed": "Failed",
"Pending": "Pending",

View File

@ -153,6 +153,11 @@
"DestinationCreated": "DestinationCreated (Приемник средств создан)",
"DestinationUnauthorized": "DestinationUnauthorized (Попытка авторизации приемника средств завершилась неудачей)"
},
"reportStatus": {
"canceled": "Отменен",
"created": "Создан",
"pending": "В процессе"
},
"withdrawalStatus": {
"Failed": "Неуспешно",
"Pending": "В процессе",

View File

@ -43,10 +43,32 @@
"sourceID": "Source identifier",
"walletID": "Wallet"
},
"reports": {
"createReportDialog": {
"confirm": "Generate",
"error": "Error generating report",
"from": "Start of the period",
"subtitle": "Report with the register of operations for the selected period",
"success": "Report created successfully",
"time": "Time",
"title": "Report generation status",
"to": "End of the period"
},
"createdAt": "Created at",
"dateRangeDescription": "Reporting period",
"errors": {
"identityNotSpecified": "Wallet holder not specified"
},
"openCreateReport": "Create report",
"period": "Reporting period",
"report": "Report",
"status": "Status"
},
"walletSection": {
"nav": {
"deposits": "Deposits",
"integrations": "Integration",
"reports": "Reports",
"wallets": "Wallets",
"withdrawals": "Withdrawals"
}

View File

@ -43,10 +43,32 @@
"sourceID": "Идентификатор источника",
"walletID": "Кошелек"
},
"reports": {
"createReportDialog": {
"confirm": "Сформировать",
"error": "Ошибка при создании отчета",
"from": "Начало периода",
"subtitle": "Отчет с реестром операций за выбранный период",
"success": "Отчет успешно создан",
"time": "Время",
"title": "Параметры формирования отчета",
"to": "Конец периода"
},
"createdAt": "Дата и время создания",
"dateRangeDescription": "Период создания отчетов",
"errors": {
"identityNotSpecified": "Владелец кошелька не указан"
},
"openCreateReport": "Создать отчет",
"period": "Период отчетности",
"report": "Отчет",
"status": "Статус"
},
"walletSection": {
"nav": {
"deposits": "Пополнения",
"integrations": "Интеграция",
"reports": "Отчеты",
"wallets": "Кошельки",
"withdrawals": "Выводы"
}

View File

@ -1,4 +1,3 @@
import { Injector } from '@angular/core';
import { AbstractControl, FormControl } from '@angular/forms';
import { FormComponentSuperclass } from '@vality/ng-core';
import isEqual from 'lodash-es/isEqual';
@ -31,10 +30,6 @@ export abstract class FilterSuperclass<Inner, Outer = Inner> extends FormCompone
private _savedValue$ = new BehaviorSubject<Inner>(this.empty);
protected constructor(injector: Injector) {
super(injector);
}
handleIncomingValue(value: Outer): void {
this.set(this.outerToInnerValue(value));
}

View File

@ -1,7 +1,7 @@
import { Component, HostBinding, Input } from '@angular/core';
import { coerceBoolean } from 'coerce-property';
import { StatusColor } from '../../../app/theme-manager';
import { StatusColor } from '@dsh/app/theme-manager';
@Component({
selector: 'dsh-status',