IMP-23: Init payouts (#42)

This commit is contained in:
Rinat Arsaev 2022-03-14 15:38:47 +03:00 committed by GitHub
parent eebb64595c
commit bbfc9699bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 777 additions and 77 deletions

13
.idea/.gitignore vendored
View File

@ -1,5 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
**/*
!*.iml
!jsLinters
!inspectionProfiles
!modules.xml
!prettier.xml
!vcs.xml

View File

@ -3,6 +3,6 @@
<component name="PrettierConfiguration">
<option name="myRunOnSave" value="true" />
<option name="myRunOnReformat" value="true" />
<option name="myFilesPattern" value="{**/*,*}.{js,ts,jsx,tsx,json,yaml,yml}" />
<option name="myFilesPattern" value="{**/*,*}.{js,ts,jsx,tsx,json,yaml,yml,html,scss,css}" />
</component>
</project>

11
package-lock.json generated
View File

@ -28,6 +28,7 @@
"@s-libs/micro-dash": "^13.1.0",
"@s-libs/ng-core": "^13.1.0",
"@s-libs/rxjs-core": "^13.1.0",
"@vality/magista-proto": "^1.0.1-2ec22d8.0",
"@vality/thrift-ts": "2.2.0-alpha",
"@vality/woody": "^0.1.0",
"angular-file": "3.0.1",
@ -5266,6 +5267,11 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@vality/magista-proto": {
"version": "1.0.1-2ec22d8.0",
"resolved": "https://registry.npmjs.org/@vality/magista-proto/-/magista-proto-1.0.1-2ec22d8.0.tgz",
"integrity": "sha512-+wQqTe3F3STDuGbuIg+GSEV/V+NFxXS5vbY+6STfUSG8a+dzq9Wgi+qMYgnNhDByH98utClmWJ/pxjPCpSm+Yg=="
},
"node_modules/@vality/thrift-ts": {
"version": "2.2.0-alpha",
"resolved": "https://registry.npmjs.org/@vality/thrift-ts/-/thrift-ts-2.2.0-alpha.tgz",
@ -26542,6 +26548,11 @@
}
}
},
"@vality/magista-proto": {
"version": "1.0.1-2ec22d8.0",
"resolved": "https://registry.npmjs.org/@vality/magista-proto/-/magista-proto-1.0.1-2ec22d8.0.tgz",
"integrity": "sha512-+wQqTe3F3STDuGbuIg+GSEV/V+NFxXS5vbY+6STfUSG8a+dzq9Wgi+qMYgnNhDByH98utClmWJ/pxjPCpSm+Yg=="
},
"@vality/thrift-ts": {
"version": "2.2.0-alpha",
"resolved": "https://registry.npmjs.org/@vality/thrift-ts/-/thrift-ts-2.2.0-alpha.tgz",

View File

@ -42,6 +42,7 @@
"@s-libs/micro-dash": "^13.1.0",
"@s-libs/ng-core": "^13.1.0",
"@s-libs/rxjs-core": "^13.1.0",
"@vality/magista-proto": "^1.0.1-2ec22d8.0",
"@vality/thrift-ts": "2.2.0-alpha",
"@vality/woody": "^0.1.0",
"angular-file": "3.0.1",
@ -115,4 +116,4 @@
"ts-node": "~8.8.2",
"typescript": "~4.5.4"
}
}
}

View File

@ -1,5 +1,5 @@
const THRIFT_PROXY_CONFIG = {
context: ['/v1', '/stat', '/fistful', '/papi', '/file_storage', '/deanonimus'],
context: ['/v1', '/v3', '/stat', '/fistful', '/papi', '/file_storage', '/deanonimus'],
target: '',
secure: false,
logLevel: 'debug',

View File

@ -0,0 +1 @@
export * from './merchant-statistics.service';

View File

@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
import * as context from '@vality/magista-proto/lib/magista/context.js';
import { ProviderSettings, ThriftInstanceProvider } from '../thrift-instance-provider';
@Injectable({ providedIn: 'root' })
export class MagistaInstanceProviderService extends ThriftInstanceProvider {
protected getProviderSettings(): ProviderSettings {
return {
context,
metadataLoad: () =>
import('@vality/magista-proto/lib/metadata.json').then((m) => m.default),
defaultNamespace: 'magista',
};
}
}

View File

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { StatPayoutResponse, PayoutSearchQuery } from '@vality/magista-proto';
import * as ThriftMerchantStatisticsService from '@vality/magista-proto/lib/magista/gen-nodejs/MerchantStatisticsService';
import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { ThriftConnector } from '@cc/app/api/thrift-connector';
import { KeycloakTokenInfoService } from '@cc/app/shared/services';
import { MagistaInstanceProviderService } from './magista-instance-provider.service';
@Injectable({
providedIn: 'root',
})
export class MerchantStatisticsService extends ThriftConnector {
constructor(
protected keycloakTokenInfoService: KeycloakTokenInfoService,
private instanceProvider: MagistaInstanceProviderService
) {
super(keycloakTokenInfoService, ThriftMerchantStatisticsService, '/v3/stat');
}
searchPayouts(payoutSearchQuery: PayoutSearchQuery): Observable<StatPayoutResponse> {
return this.instanceProvider.methods$.pipe(
switchMap(({ toPlainObject, toThriftInstance }) =>
this.callThriftServiceMethod<StatPayoutResponse>(
'SearchPayouts',
toThriftInstance('PayoutSearchQuery', payoutSearchQuery)
).pipe(map((v) => toPlainObject('StatPayoutResponse', v)))
)
);
}
}

View File

@ -12,19 +12,26 @@ export abstract class ThriftInstanceProvider {
methods$: Observable<ProviderMethods>;
constructor(private thriftMetaLoader: ThriftMetaLoader) {
const { metadataName, defaultNamespace, context } = this.getProviderSettings();
this.methods$ = this.thriftMetaLoader.get(metadataName).pipe(
map((metadata) => ({
toPlainObject: partial(thriftInstanceToObject, metadata, defaultNamespace),
toThriftInstance: partial(
createThriftInstance,
metadata,
context,
defaultNamespace
),
})),
first()
);
const {
metadataName,
metadataLoad,
defaultNamespace,
context,
} = this.getProviderSettings();
this.methods$ = this.thriftMetaLoader
.get(metadataName || defaultNamespace, metadataLoad)
.pipe(
map((metadata) => ({
toPlainObject: partial(thriftInstanceToObject, metadata, defaultNamespace),
toThriftInstance: partial(
createThriftInstance,
metadata,
context,
defaultNamespace
),
})),
first()
);
}
protected abstract getProviderSettings(): ProviderSettings;

View File

@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { from, Observable, ObservableInput } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
import { ThriftAstMetadata } from '../../thrift-services';
@ -11,12 +11,13 @@ export class ThriftMetaLoader {
constructor(private http: HttpClient) {}
get(name: string): Observable<ThriftAstMetadata[]> {
get(name: string, load?: () => ObservableInput<any>): Observable<ThriftAstMetadata[]> {
const req = this.requests[name];
return req
? req
: (this.requests[name] = this.http
.get<ThriftAstMetadata[]>(`assets/api-meta/${name}.json`)
.pipe(shareReplay(1)));
: (this.requests[name] = (load
? from(load())
: this.http.get<ThriftAstMetadata[]>(`assets/api-meta/${name}.json`)
).pipe(shareReplay(1)));
}
}

View File

@ -1,7 +1,10 @@
import { ObservableInput } from 'rxjs';
import { ThriftInstanceContext } from '../../../thrift-services';
export interface ProviderSettings {
context: ThriftInstanceContext;
metadataName: string;
defaultNamespace: string;
metadataName?: string;
metadataLoad?: () => ObservableInput<any>;
}

View File

@ -9,7 +9,7 @@ import { AppAuthGuardService } from '@cc/app/shared/services';
[
{
path: '',
redirectTo: '/payouts',
redirectTo: '/old-payouts',
pathMatch: 'full',
},
],

View File

@ -40,7 +40,7 @@ export class AppComponent implements OnInit {
private getMenuItems() {
const menuItems = [
{ name: 'Domain config', route: '/domain', activateRoles: [DomainConfigRole.Checkout] },
{ name: 'Payouts', route: '/payouts', activateRoles: [PayoutRole.Read] },
{ name: 'Payouts', route: '/old-payouts', activateRoles: [PayoutRole.Read] },
{ name: 'Claims', route: '/claims', activateRoles: [ClaimManagementRole.GetClaims] },
{
name: 'Payment adjustment',

View File

@ -1,9 +1,5 @@
import { NgModule } from '@angular/core';
import {
MAT_MOMENT_DATE_ADAPTER_OPTIONS,
MAT_MOMENT_DATE_FORMATS,
MomentDateAdapter,
} from '@angular/material-moment-adapter';
import { MAT_MOMENT_DATE_ADAPTER_OPTIONS } from '@angular/material-moment-adapter';
import { MatButtonModule } from '@angular/material/button';
import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from '@angular/material/core';
import { MatIconModule, MatIconRegistry } from '@angular/material/icon';
@ -15,9 +11,14 @@ import { BrowserModule, DomSanitizer } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import * as moment from 'moment';
import { KeycloakTokenInfoModule } from '@cc/app/shared/services';
import 'moment/locale/ru';
import {
KeycloakTokenInfoModule,
QUERY_PARAMS_SERIALIZERS,
MomentUtcDateAdapter,
} from '@cc/app/shared/services';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { ClaimModule } from './claim/claim.module';
@ -25,18 +26,21 @@ import { CoreModule } from './core/core.module';
import { DomainModule } from './domain';
import icons from './icons.json';
import { NotFoundModule } from './not-found';
import { PayoutsModule } from './payouts/payouts.module';
import { PayoutsModule as OldPayoutsModule } from './payouts/payouts.module';
import { RepairingModule } from './repairing/repairing.module';
import { DomainConfigModule } from './sections/domain-config';
import { OperationsModule } from './sections/operations/operations.module';
import { PartyModule } from './sections/party/party.module';
import { PaymentAdjustmentModule } from './sections/payment-adjustment/payment-adjustment.module';
import { PayoutsModule } from './sections/payouts';
import { SearchClaimsModule } from './sections/search-claims/search-claims.module';
import { SearchPartiesModule } from './sections/search-parties/search-parties.module';
import { SettingsModule } from './settings';
import { ThemeManager, ThemeManagerModule, ThemeName } from './theme-manager';
import {
DEFAULT_DIALOG_CONFIG,
DEFAULT_MAT_DATE_FORMATS,
DEFAULT_QUERY_PARAMS_SERIALIZERS,
DEFAULT_SEARCH_LIMIT,
DEFAULT_SMALL_SEARCH_LIMIT,
DIALOG_CONFIG,
@ -63,7 +67,7 @@ moment.locale('en');
MatSidenavModule,
MatListModule,
ClaimModule,
PayoutsModule,
OldPayoutsModule,
PaymentAdjustmentModule,
DomainModule,
RepairingModule,
@ -75,17 +79,19 @@ moment.locale('en');
OperationsModule,
DomainConfigModule,
KeycloakTokenInfoModule,
PayoutsModule,
// It is important that NotFoundModule module should be last
NotFoundModule,
],
providers: [
{ provide: MAT_MOMENT_DATE_ADAPTER_OPTIONS, useValue: { useUtc: true } },
{ provide: MAT_DATE_FORMATS, useValue: MAT_MOMENT_DATE_FORMATS },
{ provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE] },
{ provide: MAT_DATE_FORMATS, useValue: DEFAULT_MAT_DATE_FORMATS },
{ provide: DateAdapter, useClass: MomentUtcDateAdapter, deps: [MAT_DATE_LOCALE] },
{ provide: MAT_DATE_LOCALE, useValue: 'en' },
{ provide: SEARCH_LIMIT, useValue: DEFAULT_SEARCH_LIMIT },
{ provide: SMALL_SEARCH_LIMIT, useValue: DEFAULT_SMALL_SEARCH_LIMIT },
{ provide: DIALOG_CONFIG, useValue: DEFAULT_DIALOG_CONFIG },
{ provide: QUERY_PARAMS_SERIALIZERS, useValue: DEFAULT_QUERY_PARAMS_SERIALIZERS },
],
bootstrap: [AppComponent],
})

View File

@ -9,7 +9,7 @@ import { PayoutsComponent } from './payouts.component';
imports: [
RouterModule.forChild([
{
path: 'payouts',
path: 'old-payouts',
component: PayoutsComponent,
canActivate: [AppAuthGuardService],
data: {

View File

@ -82,5 +82,6 @@ import { SearchFormComponent } from './search-form/search-form.component';
ConfirmPayoutsComponent,
],
providers: [PayoutsService],
exports: [PayoutsTableComponent],
})
export class PayoutsModule {}

View File

@ -0,0 +1,40 @@
<div [formGroup]="control" gdGap="16px" gdColumns="1fr 1fr 1fr 1fr">
<mat-form-field>
<mat-label>Date Range</mat-label>
<mat-date-range-input [rangePicker]="picker">
<input matStartDate matInput formControlName="fromTime" placeholder="Start Date" />
<input matEndDate matInput formControlName="toTime" placeholder="End Date" />
</mat-date-range-input>
<mat-datepicker-toggle matSuffix [for]="picker">
<mat-icon matDatepickerToggleIcon>keyboard_arrow_down</mat-icon>
</mat-datepicker-toggle>
<mat-date-range-picker #picker></mat-date-range-picker>
</mat-form-field>
<mat-form-field>
<input matInput placeholder="Payout ID" formControlName="payoutId" autocomplete="off" />
</mat-form-field>
<mat-form-field>
<mat-select multiple formControlName="payoutStatuses" placeholder="Payout Status">
<mat-option *ngFor="let status of statuses" [value]="status">
{{ status }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-select formControlName="payoutType" placeholder="Payout Tool Type">
<mat-option [value]="null">any</mat-option>
<mat-option *ngFor="let type of types" [value]="payoutToolType[type]">
{{ type }}
</mat-option>
</mat-select>
</mat-form-field>
<cc-merchant-field formControlName="partyId"></cc-merchant-field>
<mat-form-field gdColumn="auto/span 3">
<mat-label>Shops</mat-label>
<mat-select multiple formControlName="shops" [disabled]="!control.value.partyId">
<mat-option *ngFor="let shop of shops$ | async" [value]="shop.id">
{{ shop.details.name }}
</mat-option>
</mat-select>
</mat-form-field>
</div>

View File

@ -0,0 +1,60 @@
import { Component, Injector } from '@angular/core';
import { Validators } from '@angular/forms';
import { FormBuilder } from '@ngneat/reactive-forms';
import { PayoutToolType } from '@vality/magista-proto';
import { Party, Shop } from '@vality/magista-proto/lib/domain';
import { PayoutStatus } from '@vality/magista-proto/lib/payout_manager';
import { Moment } from 'moment';
import * as moment from 'moment';
import { of } from 'rxjs';
import { share, startWith, switchMap } from 'rxjs/operators';
import {
createValidatedAbstractControlProviders,
ValidatedWrappedAbstractControlSuperclass,
} from '@cc/utils/forms';
import { PartyService } from '../../../../papi/party.service';
export interface PayoutsSearchForm {
payoutId: string;
partyId: Party['id'];
fromTime: Moment;
toTime: Moment;
shops: Shop['id'][];
payoutStatuses: (keyof PayoutStatus)[];
payoutType: PayoutToolType;
}
@Component({
selector: 'cc-payouts-search-form',
templateUrl: './payouts-search-form.component.html',
providers: createValidatedAbstractControlProviders(PayoutsSearchFormComponent),
})
export class PayoutsSearchFormComponent extends ValidatedWrappedAbstractControlSuperclass<
PayoutsSearchForm
> {
control = this.fb.group<PayoutsSearchForm>({
payoutId: null,
partyId: null,
fromTime: [moment().subtract(1, 'year').startOf('d'), Validators.required],
toTime: [moment().endOf('d'), Validators.required],
shops: null,
payoutStatuses: null,
payoutType: null,
});
shops$ = this.control.controls.partyId.valueChanges.pipe(
startWith(this.control.value.partyId),
switchMap((partyId) => (partyId ? this.partyService.getShops(partyId) : of([]))),
share()
);
statuses: PayoutsSearchForm['payoutStatuses'] = ['unpaid', 'paid', 'cancelled', 'confirmed'];
types: string[] = Object.values(PayoutToolType).filter(
(v) => typeof v === 'string'
) as string[];
payoutToolType = PayoutToolType;
constructor(private fb: FormBuilder, private partyService: PartyService, injector: Injector) {
super(injector);
}
}

View File

@ -0,0 +1,72 @@
<table mat-table [dataSource]="payouts">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Payout ID</th>
<td mat-cell *matCellDef="let payout">{{ payout.id }}</td>
</ng-container>
<ng-container matColumnDef="party">
<th mat-header-cell *matHeaderCellDef>Party ID</th>
<td mat-cell *matCellDef="let payout">{{ payout.party_id }}</td>
</ng-container>
<ng-container matColumnDef="shop">
<th mat-header-cell *matHeaderCellDef>Shop</th>
<td mat-cell *matCellDef="let payout">{{ payout.shop_id | shopName: payout.party_id }}</td>
</ng-container>
<ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef>Created At</th>
<td mat-cell *matCellDef="let payout">
{{ payout.created_at | date: 'dd.MM.yyyy HH:mm:ss' }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let payout">
<cc-status [color]="getColorByStatus(payout.status | ccUnionKey)">{{
payout.status | ccUnionKey
}}</cc-status>
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let payout">
{{ payout.amount | ccFormatAmount }}
{{ payout.currency_symbolic_code | ccCurrency }}
</td>
</ng-container>
<ng-container matColumnDef="fee">
<th mat-header-cell *matHeaderCellDef>Fee</th>
<td mat-cell *matCellDef="let payout">
{{ payout.fee | ccFormatAmount }}
{{ payout.currency_symbolic_code | ccCurrency }}
</td>
</ng-container>
<ng-container matColumnDef="payoutToolType">
<th mat-header-cell *matHeaderCellDef>Payout Tool Type</th>
<td mat-cell *matCellDef="let payout">
{{ payout.payout_tool_info | ccUnionKey }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="action-cell"></th>
<td mat-cell *matCellDef="let payout" class="action-cell">
<button mat-icon-button [matMenuTriggerFor]="menu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="navigateToPayout(payout.id)">
Details
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>

View File

@ -0,0 +1,9 @@
:host {
table {
width: 100%;
}
.action-cell {
width: 8px;
}
}

View File

@ -0,0 +1,46 @@
import { Component, Input } from '@angular/core';
import { Router } from '@angular/router';
import { PayoutID, PayoutStatus, StatPayout } from '@vality/magista-proto';
import { StatusColor } from '@cc/app/shared/components/status/types/status-color';
@Component({
selector: 'cc-payouts-table',
templateUrl: './payouts-table.component.html',
styleUrls: ['./payouts-table.component.scss'],
})
export class PayoutsTableComponent {
@Input() payouts: StatPayout[];
displayedColumns: string[] = [
'id',
'party',
'shop',
'createdAt',
'status',
'amount',
'fee',
'payoutToolType',
'actions',
];
constructor(private router: Router) {}
async navigateToPayout(id: PayoutID) {
await this.router.navigate([`/payouts/${id}`]);
}
getColorByStatus(status: keyof PayoutStatus) {
switch (status) {
case 'confirmed':
return StatusColor.Success;
case 'paid':
return StatusColor.Pending;
case 'cancelled':
case 'unpaid':
return StatusColor.Warn;
default:
return StatusColor.Neutral;
}
}
}

View File

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

View File

@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AppAuthGuardService } from '@cc/app/shared/services';
import { PayoutsComponent } from './payouts.component';
@NgModule({
imports: [
RouterModule.forChild([
{
path: 'payouts',
component: PayoutsComponent,
canActivate: [AppAuthGuardService],
data: {
roles: ['payout:read'],
},
},
]),
],
exports: [RouterModule],
})
export class PayoutsRoutingModule {}

View File

@ -0,0 +1,32 @@
<div fxLayout="column" fxLayoutGap="24px">
<div fxLayout="row" fxLayoutAlign="space-between">
<h1 class="cc-headline">Payouts</h1>
<div><button mat-button>CREATE</button></div>
</div>
<mat-card>
<mat-card-content>
<cc-payouts-search-form
[formControl]="control"
(ngModelChange)="search($event)"
></cc-payouts-search-form>
</mat-card-content>
<mat-card-footer *ngIf="inProgress$ | async">
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</mat-card-footer>
</mat-card>
<ng-container *ngIf="payouts$ | async as payouts">
<cc-empty-search-result *ngIf="payouts.length === 0"></cc-empty-search-result>
<mat-card *ngIf="payouts.length > 0" fxLayout="column" fxLayoutGap="18px">
<cc-payouts-table [payouts]="payouts"></cc-payouts-table>
<button
fxFlex="100"
mat-button
*ngIf="hasMore$ | async"
(click)="fetchMore()"
[disabled]="inProgress$ | async"
>
{{ (inProgress$ | async) ? 'LOADING...' : 'SHOW MORE' }}
</button>
</mat-card>
</ng-container>
</div>

View File

@ -0,0 +1,4 @@
:host {
display: block;
padding: 24px 16px;
}

View File

@ -0,0 +1,58 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl } from '@ngneat/reactive-forms';
import { UntilDestroy } from '@ngneat/until-destroy';
import isNil from 'lodash-es/isNil';
import omitBy from 'lodash-es/omitBy';
import { QueryParamsService } from '@cc/app/shared/services';
import { PayoutsSearchForm } from './components/payouts-search-form/payouts-search-form.component';
import { FetchPayoutsService, SearchParams } from './services/fetch-payouts.service';
@UntilDestroy()
@Component({
selector: 'cc-payouts',
templateUrl: './payouts.component.html',
styleUrls: ['./payouts.component.scss'],
providers: [FetchPayoutsService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PayoutsComponent {
control = new FormControl<PayoutsSearchForm>(this.qp.params);
inProgress$ = this.fetchPayoutsService.doAction$;
payouts$ = this.fetchPayoutsService.searchResult$;
hasMore$ = this.fetchPayoutsService.hasMore$;
constructor(
private fetchPayoutsService: FetchPayoutsService,
private qp: QueryParamsService<PayoutsSearchForm>
) {}
fetchMore() {
this.fetchPayoutsService.fetchMore();
}
search(value: PayoutsSearchForm) {
void this.qp.set(value);
this.fetchPayoutsService.search(
omitBy(
{
common_search_query_params: omitBy(
{
from_time: value.fromTime?.utc()?.format(),
to_time: value.toTime?.utc()?.format(),
party_id: value.partyId,
shop_ids: value.shops,
},
isNil
),
payout_id: value.payoutId,
// TODO: Should be enum
payout_statuses: value.payoutStatuses?.map((key) => ({ [key]: {} })),
payout_type: value.payoutType,
},
isNil
) as SearchParams
);
}
}

View File

@ -0,0 +1,52 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDatepickerModule } from '@angular/material/datepicker';
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 { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { StatusModule } from '@cc/app/shared/components';
import { MerchantFieldModule } from '@cc/app/shared/components/merchant-field';
import { ApiModelPipesModule, CommonPipesModule, ThriftPipesModule } from '@cc/app/shared/pipes';
import { EmptySearchResultModule } from '@cc/components/empty-search-result';
import { PayoutsSearchFormComponent } from './components/payouts-search-form/payouts-search-form.component';
import { PayoutsTableComponent } from './components/payouts-table/payouts-table.component';
import { PayoutsRoutingModule } from './payouts-routing.module';
import { PayoutsComponent } from './payouts.component';
@NgModule({
declarations: [PayoutsComponent, PayoutsTableComponent, PayoutsSearchFormComponent],
imports: [
CommonModule,
PayoutsRoutingModule,
MatButtonModule,
MatCardModule,
MatProgressBarModule,
EmptySearchResultModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
MerchantFieldModule,
FlexLayoutModule,
ReactiveFormsModule,
MatSelectModule,
MatDatepickerModule,
MatIconModule,
MatTableModule,
MatMenuModule,
ApiModelPipesModule,
CommonPipesModule,
ThriftPipesModule,
StatusModule,
],
})
export class PayoutsModule {}

View File

@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';
import { StatPayout, PayoutSearchQuery } from '@vality/magista-proto';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Overwrite } from 'utility-types';
import { MerchantStatisticsService } from '@cc/app/api/magista';
import { FetchResult, PartialFetcher } from '@cc/app/shared/services';
export type SearchParams = Overwrite<
PayoutSearchQuery,
{
common_search_query_params: Omit<
PayoutSearchQuery['common_search_query_params'],
'continuation_token' | 'limit'
>;
}
>;
@Injectable()
export class FetchPayoutsService extends PartialFetcher<StatPayout, SearchParams> {
constructor(private merchantStatisticsService: MerchantStatisticsService) {
super();
}
protected fetch(
params: SearchParams,
continuationToken: string
): Observable<FetchResult<StatPayout>> {
return this.merchantStatisticsService
.searchPayouts({
...params,
common_search_query_params: {
...params.common_search_query_params,
continuation_token: continuationToken,
},
})
.pipe(
map(({ continuation_token, payouts }) => ({
result: payouts,
continuationToken: continuation_token,
}))
);
}
}

View File

@ -22,8 +22,8 @@ export class SearchClaimsComponent implements OnInit {
);
}
search({ merchant, ...v }: ClaimSearchForm): void {
this.searchClaimService.search({ ...v, party_id: merchant?.id });
search(v: ClaimSearchForm): void {
this.searchClaimService.search(v);
}
fetchMore(): void {

View File

@ -18,6 +18,6 @@
<cc-merchant-field
*ngIf="!hideMerchantSearch"
fxFlex
formControlName="merchant"
formControlName="party_id"
></cc-merchant-field>
</form>

View File

@ -32,7 +32,7 @@ export class ClaimSearchFormComponent implements OnInit {
form = this.fb.group<ClaimSearchForm>({
statuses: null,
claim_id: null,
merchant: null,
party_id: null,
});
claimStatuses: (keyof ClaimStatus)[] = [
@ -50,20 +50,13 @@ export class ClaimSearchFormComponent implements OnInit {
this.form.valueChanges
.pipe(debounceTime(600), map(removeEmptyProperties), untilDestroyed(this))
.subscribe((value) => {
const { merchant, ...v } = value;
void this.router.navigate([location.pathname], {
queryParams: Object.assign(v, !!merchant?.id && { merchantId: merchant?.id }),
});
void this.router.navigate([location.pathname], { queryParams: value });
this.valueChanges.emit(formValueToSearchParams(value));
});
this.route.queryParams
.pipe(
take(1),
map(queryParamsToFormValue),
map(({ merchantId, ...v }) => ({
...v,
merchant: merchantId ? { id: merchantId } : null,
})),
map(removeEmptyProperties),
untilDestroyed(this)
)

View File

@ -1,9 +1,10 @@
import { PartyID } from '@cc/app/api/damsel/gen-model/domain';
import { ClaimStatus } from '../../../papi/model';
import { ClaimID } from '../../../thrift-services/damsel/gen-model/claim_management';
import { Party } from '../../../thrift-services/deanonimus/gen-model/deanonimus';
export interface ClaimSearchForm {
claim_id?: ClaimID;
statuses?: ClaimStatus[];
merchant?: Party;
claim_id: ClaimID;
statuses: ClaimStatus[];
party_id: PartyID;
}

View File

@ -2,7 +2,7 @@
[label]="label || 'Merchant'"
[required]="required"
[options]="options$ | async"
[formControl]="formControl"
[formControl]="control"
(searchChange)="this.searchChange$.next($event)"
isExternalSearch
></cc-select-search-field>

View File

@ -15,10 +15,10 @@ import {
withLatestFrom,
} from 'rxjs/operators';
import { PartyID } from '@cc/app/api/damsel/gen-model/domain';
import { Option } from '@cc/components/select-search-field';
import { DeanonimusService } from '../../../thrift-services/deanonimus';
import { Party } from '../../../thrift-services/deanonimus/gen-model/deanonimus';
@UntilDestroy()
@Component({
@ -27,13 +27,14 @@ import { Party } from '../../../thrift-services/deanonimus/gen-model/deanonimus'
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideValueAccessor(MerchantFieldComponent)],
})
export class MerchantFieldComponent extends WrappedFormControlSuperclass<Party> implements OnInit {
export class MerchantFieldComponent extends WrappedFormControlSuperclass<PartyID>
implements OnInit {
@Input() label: string;
@Input() @coerceBoolean required: boolean;
formControl = new FormControl<Party>();
incomingValue$ = new Subject<Partial<Party>>();
options$ = new ReplaySubject<Option<Party>[]>(1);
control = new FormControl<PartyID>();
incomingValue$ = new Subject<Partial<PartyID>>();
options$ = new ReplaySubject<Option<PartyID>[]>(1);
searchChange$ = new Subject<string>();
constructor(
@ -47,24 +48,23 @@ export class MerchantFieldComponent extends WrappedFormControlSuperclass<Party>
ngOnInit(): void {
this.incomingValue$
.pipe(
withLatestFrom(this.options$.pipe(startWith<Option<Party>[]>([]))),
withLatestFrom(this.options$.pipe(startWith<Option<PartyID>[]>([]))),
switchMap(([value, options]) => {
if (!value?.id) return of<Party>(null);
const v = options.find((o) => o.value.id === value.id);
if (!value) return of<PartyID>(null);
const v = options.find((o) => o.value === value);
if (v) return of(v.value);
return this.searchOptions(value?.id).pipe(
return this.searchOptions(value).pipe(
tap((options) => this.options$.next(options)),
map(
(options) =>
options?.find((o) => o.value.id === this.formControl.value?.id)
?.value || null
options?.find((o) => o.value === this.control.value)?.value || null
)
);
}),
untilDestroyed(this)
)
.subscribe((v) => this.formControl.setValue(v));
this.formControl.valueChanges.subscribe((v) => this.emitOutgoingValue(v));
.subscribe((v) => this.control.setValue(v));
this.control.valueChanges.subscribe((v) => this.emitOutgoingValue(v));
this.searchChange$
.pipe(
debounceTime(600),
@ -74,14 +74,14 @@ export class MerchantFieldComponent extends WrappedFormControlSuperclass<Party>
.subscribe((options) => this.options$.next(options));
}
handleIncomingValue(partyLike: Partial<Party>): void {
this.formControl.setValue(partyLike as Party);
this.incomingValue$.next(partyLike);
handleIncomingValue(partyId: PartyID): void {
this.control.setValue(partyId);
this.incomingValue$.next(partyId);
}
private searchOptions(str: string): Observable<Option<Party>[]> {
private searchOptions(str: string): Observable<Option<PartyID>[]> {
return this.deanonimusService.searchParty(str).pipe(
map((parties) => parties.map((p) => ({ label: p.party.email, value: p.party }))),
map((parties) => parties.map((p) => ({ label: p.party.email, value: p.party.id }))),
catchError((err) => {
this.snackBar.open('Search error', 'OK', { duration: 2000 });
console.error(err);

View File

@ -4,3 +4,5 @@ export * from './fetch-parties.service';
export * from './keycloak-token-info';
export * from './user-info-based-id-generator';
export * from './partial-fetcher';
export * from './query-params';
export * from './moment-utc-date-adapter';

View File

@ -0,0 +1 @@
export * from './moment-utc-date-adapter';

View File

@ -0,0 +1,36 @@
import { Inject, Injectable, Optional } from '@angular/core';
import { MomentDateAdapter } from '@angular/material-moment-adapter';
import { MAT_DATE_LOCALE } from '@angular/material/core';
import { Moment } from 'moment';
import * as moment from 'moment';
@Injectable()
/**
* https://github.com/angular/components/issues/7167#issuecomment-385416948
*/
export class MomentUtcDateAdapter extends MomentDateAdapter {
constructor(@Optional() @Inject(MAT_DATE_LOCALE) dateLocale: string) {
super(dateLocale);
}
createDate(year: number, month: number, date: number): Moment {
// Moment.js will create an invalid date if any of the components are out of bounds, but we
// explicitly check each case so we can throw more descriptive errors.
if (month < 0 || month > 11) {
throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`);
}
if (date < 1) {
throw Error(`Invalid date "${date}". Date has to be greater than 0.`);
}
const result = moment.utc({ year, month, date }).locale(this.locale);
// If the result isn't valid, the date must have been out of bounds for this month.
if (!result.isValid()) {
throw Error(`Invalid date "${date}" for month with index "${month}".`);
}
return result;
}
}

View File

@ -5,6 +5,9 @@ import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators';
import { removeEmptyProperties } from '@cc/utils/remove-empty-properties';
/**
* @deprecated use QueryParamsService
*/
export abstract class QueryParamsStore<D> {
data$: Observable<Partial<D>> = this.route.queryParams.pipe(
distinctUntilChanged(isEqual),

View File

@ -0,0 +1,2 @@
export * from './query-params.service';
export * from './utils/query-params-serializers';

View File

@ -0,0 +1,77 @@
import { Inject, Injectable, Optional } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import isEqual from 'lodash-es/isEqual';
import negate from 'lodash-es/negate';
import { Observable } from 'rxjs';
import { distinctUntilChanged, map, shareReplay, startWith } from 'rxjs/operators';
import { isEmptyValue } from '@cc/app/shared/utils';
import { Serializer } from './types/serializer';
import { deserializeQueryParam } from './utils/deserialize-query-param';
import { QUERY_PARAMS_SERIALIZERS } from './utils/query-params-serializers';
import { serializeQueryParam } from './utils/serialize-query-param';
type Options = {
filter?: (param: unknown, key: string) => boolean;
};
@UntilDestroy()
@Injectable({ providedIn: 'root' })
export class QueryParamsService<Params> {
params$: Observable<Params> = this.route.queryParams.pipe(
map((params) => this.deserialize(params)),
startWith(this.params),
distinctUntilChanged<Params>(isEqual),
shareReplay({ refCount: true, bufferSize: 1 })
);
get params(): Params {
return this.deserialize(this.route.snapshot.queryParams);
}
constructor(
private router: Router,
private route: ActivatedRoute,
@Optional() @Inject(QUERY_PARAMS_SERIALIZERS) private serializers?: Serializer[]
) {
// Angular @Optional not support TS syntax: `serializers: Serializer[] = []`
if (!this.serializers) {
this.serializers = [];
}
}
async set(params: Params, options?: Options): Promise<boolean> {
return await this.router.navigate([], { queryParams: this.serialize(params, options) });
}
async patch(param: Partial<Params>): Promise<boolean> {
return await this.set({ ...this.params, ...param });
}
async init(param: Params): Promise<boolean> {
return await this.set({ ...param, ...this.params });
}
private serialize(
params: Params,
{ filter = negate(isEmptyValue) }: Options = {}
): { [key: string]: string } {
return Object.entries(params).reduce((acc, [k, v]) => {
if (filter(v, k)) acc[k] = serializeQueryParam(v, this.serializers);
return acc;
}, {} as { [key: string]: string });
}
private deserialize(params: { [key: string]: string }): Params {
return Object.entries(params).reduce((acc, [k, v]) => {
try {
acc[k] = deserializeQueryParam<Params[keyof Params]>(v, this.serializers);
} catch (err) {
console.error(err);
}
return acc;
}, {} as Params);
}
}

View File

@ -0,0 +1,6 @@
export type Serializer<T = unknown> = {
id: string;
serialize: (v: T) => string;
deserialize: (v: string) => T;
recognize: (v: T) => boolean;
};

View File

@ -0,0 +1,8 @@
import { Serializer } from '../types/serializer';
export function deserializeQueryParam<P>(value: string, serializers: Serializer[] = []): P {
const serializer = serializers.find((s) => value.startsWith(s.id));
return (serializer
? serializer.deserialize(value.slice(serializer.id.length))
: JSON.parse(value || '')) as P;
}

View File

@ -0,0 +1,7 @@
import { InjectionToken } from '@angular/core';
import { Serializer } from '../types/serializer';
export const QUERY_PARAMS_SERIALIZERS = new InjectionToken<Serializer[]>(
'query params serializers'
);

View File

@ -0,0 +1,6 @@
import { Serializer } from '../types/serializer';
export function serializeQueryParam(value: unknown, serializers: Serializer[] = []): string {
const serializer = serializers.find((s) => s.recognize(value));
return serializer ? serializer.id + serializer.serialize(value) : JSON.stringify(value);
}

View File

@ -3,3 +3,4 @@ export * from './component-changes';
export * from './polling-conditions';
export * from './sort-units';
export * from './deposit-status';
export * from './is-empty-value';

View File

@ -0,0 +1,6 @@
import isEmpty from 'lodash-es/isEmpty';
import isNil from 'lodash-es/isNil';
export function isEmptyValue(v: unknown): boolean {
return typeof v === 'object' ? isEmpty(v) : isNil(v);
}

View File

@ -1 +1 @@
export type ThriftInstanceContext = { [N in string]: any };
export type ThriftInstanceContext = { [N in string]: unknown };

View File

@ -1,5 +1,10 @@
import { InjectionToken } from '@angular/core';
import { MatDateFormats } from '@angular/material/core';
import { MatDialogConfig } from '@angular/material/dialog';
import { Moment } from 'moment';
import * as moment from 'moment';
import { Serializer } from '@cc/app/shared/services/query-params/types/serializer';
export const SEARCH_LIMIT = new InjectionToken<number>('searchLimit');
export const DEFAULT_SEARCH_LIMIT = 10;
@ -23,3 +28,24 @@ export const DEFAULT_DIALOG_CONFIG: DialogConfig = {
medium: { ...BASE_CONFIG, width: '552px' },
large: { ...BASE_CONFIG, width: '648px' },
};
export const DEFAULT_QUERY_PARAMS_SERIALIZERS: Serializer[] = [
{
id: 'moment',
serialize: (date: Moment) => date.utc().format(),
deserialize: (value) => moment(value),
recognize: (value) => moment.isMoment(value),
},
];
export const DEFAULT_MAT_DATE_FORMATS: MatDateFormats = {
parse: {
dateInput: ['l', 'LL'],
},
display: {
dateInput: 'DD.MM.YYYY',
monthYearLabel: 'DD.MM.YYYY',
dateA11yLabel: 'DD.MM.YYYY',
monthYearA11yLabel: 'DD.MM.YYYY',
},
};

View File

@ -22,7 +22,7 @@ async function execWithLog(cmd: string) {
},
(error, stdout, stderr) => {
if (error === null) {
console.log(stderr);
console.log(stdout);
res(stdout);
} else {
console.error(error);